diff --git a/.eslintrc b/.eslintrc
index 5f678417bd..e1f5da1104 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -253,8 +253,6 @@
"frontend/src/components/tables/cells/TeamTypeCell.vue",
"frontend/src/components/tables/cells/UserCell.vue",
"frontend/src/components/tables/cells/UserRoleCell.vue",
- "frontend/src/pages/application/components/ExportProjectComponents.vue",
- "frontend/src/pages/application/components/ImportProjectComponents.vue",
"frontend/src/pages/application/Debug.vue",
"frontend/src/pages/device/components/DeviceLastSeenBadge.vue",
"frontend/src/pages/device/Overview.vue",
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1016001792..4356e31b08 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -26,7 +26,7 @@ jobs:
if: |
( github.event.workflow_run.conclusion == 'success' && github.ref == 'refs/heads/main' ) ||
( github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' )
- uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.34.0'
+ uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@v0.36.0'
with:
package_name: flowfuse
build_package: true
diff --git a/config/cypress-shared.config.js b/config/cypress-shared.config.js
index d7f8b56f8e..350360ffc7 100644
--- a/config/cypress-shared.config.js
+++ b/config/cypress-shared.config.js
@@ -28,14 +28,17 @@ module.exports = {
// fileName looks like a regex
debug = debug + JSON.stringify(files)
const filteredList = files.filter(file => !!file.match(re))
- return filteredList.length === 1
+ if (filteredList.length === 1) {
+ return filteredList[0]
+ }
+ return false
}
return false
}).then(result => {
if (!result) {
throw new Error(`${fileName} not found in ${baseDir} ${debug}`)
}
- return true
+ return result
})
},
// Clear all files in the downloads folder
diff --git a/docs/migration/introduction.md b/docs/migration/introduction.md
index 38b1211515..1c5817f9e3 100644
--- a/docs/migration/introduction.md
+++ b/docs/migration/introduction.md
@@ -45,5 +45,5 @@ will now be started with the modules installed.
### Static Files
Check your `settings.js` file to see if `httpStatic` has been set, if so then
-check for any files in this path. FlowFuse does not currently support serving
-static files so you will need to find alternative hosting for these such as AWS S3.
+check for any files in this path. The files in this path need to be manually
+migrated to [FlowFuse's Static Assets](/docs/user/static-asset-service/) service.
diff --git a/docs/user/snapshots.md b/docs/user/snapshots.md
index 550d992d40..4479206c19 100644
--- a/docs/user/snapshots.md
+++ b/docs/user/snapshots.md
@@ -44,9 +44,17 @@ A snapshot can be downloaded to your local machine for backup or sharing.
To download a snapshot:
-1. Go to the instance's page and select the **Snapshots** tab.
+1. Go to the desired application, instance or device overview page and select the **Snapshots** tab.
2. Open the dropdown menu to the right of the snapshot you want to download and
- select the **Download Snapshot** option.
+ click the **Download Snapshot** option to open the download dialog.
+3. Select the required components to download.
+ - **Flows**: Include the snapshot flows
+ - **Credentials**: Include the snapshot flows credentials
+ - **Environment Variables**: Include environment variables in the snapshot
+ - **Keys and Values**: Include the keys and values of the environment variables
+ - **Keys Only**: Include only the keys of the environment variables
+4. Enter a secret to encrypt any credentials in the snapshot (optional, depends on components selected).
+5. Click **Download**
## Upload a snapshot
@@ -54,13 +62,19 @@ A snapshot can be uploaded to a Node-RED instance in FlowFuse.
To upload a snapshot:
-1. Go to the instance's page and select the **Snapshots** tab.
+1. Go to the desired instance or device overview page and select the **Snapshots** tab.
2. Click the **Upload Snapshot** button.
3. Select the snapshot file from your local machine.
-4. If the snapshot contains credentials, you will be asked to enter the credentials secret.
- This is the secret that was used to encrypt the credentials in the snapshot.
-5. Update the name and description if required.
-6. Click **Upload**
+4. Update the name and description if required.
+5. Select the components to upload:
+ - **Flows**: Include the snapshots flows
+ - **Credentials**: Include the snapshots flows credentials (visible only if the snapshot contains credentials)
+ - **Environment Variables**: Include environment variables in the snapshot
+ - **Keys and Values**: Include the keys and values of the environment variables
+ - **Keys Only**: Include only the keys of the environment variables
+6. If the snapshot contains credentials and the `Credentials` component is checked,
+ you will be asked to enter a Secret. This will be used to later decrypt any credentials in the snapshots flows.
+7. Click **Upload**
## Delete a snapshot
diff --git a/forge/comms/v2AuthRoutes.js b/forge/comms/v2AuthRoutes.js
index a7ee0d4e39..398d3d814c 100644
--- a/forge/comms/v2AuthRoutes.js
+++ b/forge/comms/v2AuthRoutes.js
@@ -42,7 +42,7 @@ module.exports = async function (app) {
result: 'allow',
is_superuser: false,
client_attrs: {
- team: 'ff/v1/internal/c/'
+ team: ''
}
})
} else {
diff --git a/forge/db/controllers/Snapshot.js b/forge/db/controllers/Snapshot.js
index 3ed3dfa7c6..7bc7f13ce1 100644
--- a/forge/db/controllers/Snapshot.js
+++ b/forge/db/controllers/Snapshot.js
@@ -20,9 +20,22 @@ module.exports = {
* @param {String} [options.credentialSecret] secret to encrypt credentials with.
* @param {Object} [options.credentials] (Optional) credentials to export. If omitted, credentials of the current owner will be re-encrypted with the provided `credentialSecret`.
* @param {Object} [options.owner] (Optional) The owner project or device. If omitted, the snapshot's owner will be obtained from the database.
+ * @param {Object} [options.components] (Optional) Components to include in the snapshot. Defaults to all components.
+ * @param {Boolean} [options.components.flows] (Optional) Include flows in the snapshot. Defaults to true.
+ * @param {Boolean} [options.components.credentials] (Optional) Include credentials in the snapshot. Defaults to true if `options.components.flows` is true, otherwise false.
+ * @param {'all' | 'keys' | false} [options.components.envVars='all'] (Optional) Env Var options: `'All'` will keep keys and values, `'keys'` will keep only keys, `false` will remove them.
*/
exportSnapshot: async function (app, snapshot, options) {
- if (!options.credentialSecret) {
+ const components = {
+ flows: options.components?.flows ?? true,
+ credentials: options.components?.credentials ?? true,
+ envVars: ['all', 'keys', false].includes(options.components?.envVars) ? options.components.envVars : 'all'
+ }
+ if (components.flows === false) {
+ components.credentials = false // no flows, no credentials!
+ }
+
+ if (components.credentials !== false && !options.credentialSecret) {
return null
}
@@ -54,6 +67,28 @@ module.exports = {
...snapshot.toJSON()
}
+ // include/exclude components of the snapshot
+ if (components.flows === false) {
+ result.flows = {
+ flows: [],
+ credentials: {} // no flows, no credentials!
+ }
+ } else if (components.credentials === false) {
+ result.flows = {
+ flows: snapshot.flows.flows || [],
+ credentials: {} // no credentials!
+ }
+ }
+ if (components.envVars === false) {
+ result.settings.env = {}
+ } else if (components.envVars === 'keys' && snapshot.settings?.env && Object.keys(snapshot.settings.env).length > 0) {
+ const keysOnly = Object.keys(snapshot.settings.env).reduce((acc, key) => {
+ acc[key] = ''
+ return acc
+ }, {})
+ result.settings = Object.assign({}, snapshot.settings, { env: keysOnly })
+ }
+
// loop keys of result.settings.env and remove any that match FF_*
Object.keys(result.settings.env).forEach((key) => {
if (key.startsWith('FF_')) {
@@ -61,15 +96,19 @@ module.exports = {
}
})
- // use the secret stored in the snapshot, if available...
- const currentSecret = result.credentialSecret || owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret())
- const credentials = options.credentials ? options.credentials : result.flows.credentials
+ if (components.flows === false || components.credentials === false) {
+ result.flows.credentials = {}
+ } else {
+ // use the secret stored in the snapshot, if available...
+ const currentSecret = result.credentialSecret || owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret())
+ const credentials = options.credentials ? options.credentials : result.flows.credentials
- // if provided credentials already encrypted: "exportCredentials" will just return the same credentials
- // if provided credentials are raw: "exportCredentials" will encrypt them with the secret provided
- // if credentials are not provided: project's flows credentials will be used, they will be encrypted with the provided secret
- const keyToDecrypt = (options.credentials && options.credentials.$) ? options.credentialSecret : currentSecret
- result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret)
+ // if provided credentials already encrypted: "exportCredentials" will just return the same credentials
+ // if provided credentials are raw: "exportCredentials" will encrypt them with the secret provided
+ // if credentials are not provided: project's flows credentials will be used, they will be encrypted with the provided secret
+ const keyToDecrypt = (options.credentials && options.credentials.$) ? options.credentialSecret : currentSecret
+ result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret)
+ }
return result
},
@@ -143,8 +182,13 @@ module.exports = {
* @param {*} snapshot - snapshot data
* @param {String} credentialSecret - secret to encrypt credentials with. Can be null if the snapshot does not contain credentials.
* @param {*} user - user who uploaded the snapshot
+ * @param {Object} [options] - additional options
+ * @param {Object} [options.components] - Include flows in the snapshot. Defaults to true.
+ * @param {Boolean} [options.components.flows] - Include flows in the snapshot. Defaults to true.
+ * @param {Boolean} [options.components.credentials] - Include credentials in the snapshot. Defaults to true if `options.components.flows` is true, otherwise false.
+ * @param {'all' | 'keys' | false} [options.components.envVars='all'] - Env Var options: `'All'` will keep keys and values, `'keys'` will keep only keys, `false` will remove them.
*/
- async uploadSnapshot (app, owner, snapshot, credentialSecret, user) {
+ async uploadSnapshot (app, owner, snapshot, credentialSecret, user, options) {
// Validate the owner
let ownerType
if (owner.constructor.name === 'Project') {
@@ -155,33 +199,70 @@ module.exports = {
throw new Error('Invalid owner type')
}
+ const components = {
+ flows: options?.components?.flows ?? true,
+ credentials: options?.components?.credentials ?? true,
+ envVars: ['all', 'keys', false].includes(options?.components?.envVars) ? options?.components.envVars : 'all'
+ }
+ if (components.flows === false) {
+ components.credentials = false // no flows, no credentials!
+ }
+
+ // shallow clone the snapshot before modifying it
+ const importSnapshot = Object.assign({}, snapshot)
+
+ // include/exclude components of the snapshot
+ if (components.flows === false) {
+ importSnapshot.flows = {
+ flows: [],
+ credentials: {} // no flows, no credentials!
+ }
+ } else if (components.credentials === false) {
+ importSnapshot.flows = {
+ flows: snapshot.flows.flows || [],
+ credentials: {} // no credentials!
+ }
+ }
+ if (components.envVars === false) {
+ importSnapshot.settings = {
+ ...snapshot.settings,
+ env: {} // no env vars!
+ }
+ } else if (components.envVars === 'keys' && snapshot.settings?.env && Object.keys(snapshot.settings.env).length > 0) {
+ const keysOnly = Object.keys(snapshot.settings.env).reduce((acc, key) => {
+ acc[key] = ''
+ return acc
+ }, {})
+ importSnapshot.settings = Object.assign({}, snapshot.settings, { env: keysOnly })
+ }
+
const targetCredentialSecret = owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret()) || credentialSecret
// 1. If the snapshot includes credentials but no credentialSecret, we should reject it
// 2. if the snapshot includes credentials and a credentialSecret, we should reencrypt for the owner
- if (snapshot.flows.credentials?.$) {
+ if (importSnapshot.flows.credentials?.$) {
if (!credentialSecret) {
throw new Error('Missing credentialSecret')
}
// Need to re-encrypt the credentials for the target
- snapshot.flows.credentials = app.db.controllers.Project.exportCredentials(snapshot.flows.credentials, credentialSecret, targetCredentialSecret)
+ importSnapshot.flows.credentials = app.db.controllers.Project.exportCredentials(importSnapshot.flows.credentials, credentialSecret, targetCredentialSecret)
}
const ProjectId = ownerType === 'instance' ? owner.id : null
const DeviceId = ownerType === 'device' ? owner.id : null
const snapshotOptions = {
- name: snapshot.name,
- description: snapshot.description || '',
+ name: importSnapshot.name,
+ description: importSnapshot.description || '',
credentialSecret: targetCredentialSecret,
settings: {
- settings: snapshot.settings?.settings || {},
- env: snapshot.settings?.env || {},
- modules: snapshot.settings?.modules || {}
+ settings: importSnapshot.settings?.settings || {},
+ env: importSnapshot.settings?.env || {},
+ modules: importSnapshot.settings?.modules || {}
},
flows: {
- flows: snapshot.flows.flows || [],
- credentials: snapshot.flows.credentials || { }
+ flows: importSnapshot.flows.flows || [],
+ credentials: importSnapshot.flows.credentials || { }
},
ProjectId,
DeviceId,
diff --git a/forge/db/controllers/Team.js b/forge/db/controllers/Team.js
index ca09e6324c..91a2559dd0 100644
--- a/forge/db/controllers/Team.js
+++ b/forge/db/controllers/Team.js
@@ -12,6 +12,14 @@ module.exports = {
// Reinflate the object now the user has been added
const team = await app.db.models.Team.bySlug(newTeam.slug)
+ // Record in our Product tracking
+ app.product.capture(user.username, '$ff-team-created', {
+ 'team-name': team.name,
+ 'created-at': team.createdAt
+ }, {
+ team: team.id
+ })
+
return team
},
diff --git a/forge/db/migrations/20241010-01-EE-add-teambroker-client.js b/forge/db/migrations/20241021-01-EE-add-teambroker-client.js
similarity index 100%
rename from forge/db/migrations/20241010-01-EE-add-teambroker-client.js
rename to forge/db/migrations/20241021-01-EE-add-teambroker-client.js
diff --git a/forge/db/models/Notification.js b/forge/db/models/Notification.js
index f299238e68..0c3836f633 100644
--- a/forge/db/models/Notification.js
+++ b/forge/db/models/Notification.js
@@ -89,6 +89,25 @@ module.exports = {
count,
notifications: rows
}
+ },
+ updateNotificationsState: async ({ read = true, ids = [] }, user) => {
+ if (ids.length === 0) {
+ return
+ }
+
+ ids = ids.map(id => typeof id === 'string'
+ ? M.Notification.decodeHashid(id)?.[0]
+ : id)
+
+ await M.Notification.update(
+ { read: read ? 1 : 0 },
+ {
+ where: {
+ id: ids.filter(e => e),
+ UserId: user.id
+ }
+ }
+ )
}
}
}
diff --git a/forge/ee/routes/teamBroker/index.js b/forge/ee/routes/teamBroker/index.js
index 8af8fb8bd5..24d396e884 100644
--- a/forge/ee/routes/teamBroker/index.js
+++ b/forge/ee/routes/teamBroker/index.js
@@ -39,6 +39,7 @@ module.exports = async function (app) {
* @memberof forge.routes.api.team.broker
*/
app.get('/clients', {
+ preHandler: app.needsPermission('broker:clients:list'),
schema: {
summary: 'List MQTT clients for the team',
tags: ['MQTT Broker'],
@@ -80,7 +81,7 @@ module.exports = async function (app) {
* @memberof forge.routes.api.team.broker
*/
app.post('/client', {
- preHandler: app.needsPermission('project:create'),
+ preHandler: app.needsPermission('broker:clients:create'),
schema: {
summary: 'Create new MQTT client for the team',
tags: ['MQTT Broker'],
@@ -139,6 +140,7 @@ module.exports = async function (app) {
* @memberof forge.routes.api.team.broker
*/
app.get('/client/:username', {
+ preHandler: app.needsPermission('broker:clients:list'),
schema: {
summary: 'Get details about a specific MQTT client',
tags: ['MQTT Broker'],
@@ -181,8 +183,8 @@ module.exports = async function (app) {
* @static
* @memberof forge.routes.api.team.broker
*/
- app.patch('/client/:username', {
- preHandler: app.needsPermission('project:create'),
+ app.put('/client/:username', {
+ preHandler: app.needsPermission('broker:clients:edit'),
schema: {
summary: 'Modify a MQTT Client',
tags: ['MQTT Broker'],
@@ -264,7 +266,7 @@ module.exports = async function (app) {
* @memberof forge.routes.api.team.broker
*/
app.delete('/client/:username', {
- preHandler: app.needsPermission('project:create'),
+ preHandler: app.needsPermission('broker:clients:delete'),
schema: {
summary: 'Delete a MQTT client',
tags: ['MQTT Broker'],
diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js
index 0c9d2fe559..f92582e0e8 100644
--- a/forge/lib/permissions.js
+++ b/forge/lib/permissions.js
@@ -172,7 +172,13 @@ const Permissions = {
'project:files:list': { description: 'List files under a project', role: Roles.Member },
'project:files:create': { description: 'Upload files to a project', role: Roles.Member },
'project:files:edit': { description: 'Modify files in a project', role: Roles.Member },
- 'project:files:delete': { description: 'Delete files in a project', role: Roles.Member }
+ 'project:files:delete': { description: 'Delete files in a project', role: Roles.Member },
+
+ // Team Broker
+ 'broker:clients:list': { description: 'List Team Broker clients', role: Roles.Member },
+ 'broker:clients:create': { description: 'Create Team Broker clients', role: Roles.Admin },
+ 'broker:clients:edit': { description: 'Edit Team Broker clients', role: Roles.Admin },
+ 'broker:clients:delete': { description: 'Delete Team Broker clients', role: Roles.Admin }
}
module.exports = {
diff --git a/forge/routes/api/snapshot.js b/forge/routes/api/snapshot.js
index 4ab2de5314..7f2631f0dc 100644
--- a/forge/routes/api/snapshot.js
+++ b/forge/routes/api/snapshot.js
@@ -227,7 +227,20 @@ module.exports = async function (app) {
body: {
type: 'object',
properties: {
- credentialSecret: { type: 'string' }
+ credentialSecret: { type: 'string' },
+ components: {
+ type: 'object',
+ properties: {
+ flows: { type: 'boolean', default: true },
+ credentials: { type: 'boolean', default: true },
+ envVars: {
+ anyOf: [
+ { type: 'string', enum: ['all', 'keys'] },
+ { type: 'boolean', enum: [false] }
+ ]
+ }
+ }
+ }
}
},
response: {
@@ -243,7 +256,8 @@ module.exports = async function (app) {
const options = {
credentialSecret: request.body.credentialSecret,
credentials: request.body.credentials,
- owner: request.owner // the instance or device that owns the snapshot
+ owner: request.owner, // the instance or device that owns the snapshot
+ components: request.body.components
}
if (!options.credentialSecret) {
@@ -304,7 +318,20 @@ module.exports = async function (app) {
},
required: ['name', 'flows', 'settings']
},
- credentialSecret: { type: 'string' }
+ credentialSecret: { type: 'string' },
+ components: {
+ type: 'object',
+ properties: {
+ flows: { type: 'boolean', default: true },
+ credentials: { type: 'boolean', default: true },
+ envVars: {
+ anyOf: [
+ { type: 'string', enum: ['all', 'keys'] },
+ { type: 'boolean', enum: [false] }
+ ]
+ }
+ }
+ }
}
},
response: {
@@ -323,12 +350,17 @@ module.exports = async function (app) {
reply.code(400).send({ code: 'bad_request', error: 'owner and snapshot are mandatory in the body' })
return
}
- if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) {
- reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' })
- return
+ if (request.body.components?.credentials !== false) {
+ if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) {
+ reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' })
+ return
+ }
}
try {
- const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User)
+ const options = {
+ components: request.body.components || null
+ }
+ const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User, options)
if (!newSnapshot) {
throw new Error('Failed to upload snapshot')
}
diff --git a/forge/routes/api/userNotifications.js b/forge/routes/api/userNotifications.js
index e921f35a6c..72cc379026 100644
--- a/forge/routes/api/userNotifications.js
+++ b/forge/routes/api/userNotifications.js
@@ -1,3 +1,5 @@
+const { getNotifications } = require('../../services/notifications.js')
+
/**
* User Notification api routes
*
@@ -27,35 +29,46 @@ module.exports = async function (app) {
}
}
}, async (request, reply) => {
- const paginationOptions = app.getPaginationOptions(request)
- const notifications = await app.db.models.Notification.forUser(request.session.User, paginationOptions)
- notifications.notifications = app.db.views.Notification.notificationList(notifications.notifications)
+ const notifications = await getNotifications(app, request)
+
reply.send(notifications)
})
// Bulk update
- // app.put('/', {
- // schema: {
- // summary: 'Mark notifications as read',
- // tags: ['User'],
- // body: {
- // type: 'object',
- // properties: {
- // id: { type: 'string' },
- // read: { type: 'boolean' }
- // }
- // },
- // response: {
- // 200: {
- // $ref: 'APIStatus'
- // },
- // '4xx': {
- // $ref: 'APIError'
- // }
- // }
- // }
- // }, async (request, reply) => {
- // })
+ app.put('/', {
+ schema: {
+ summary: 'Bulk update notifications',
+ tags: ['User'],
+ body: {
+ type: 'object',
+ properties: {
+ ids: { type: 'array', items: { type: 'string' } },
+ read: { type: 'boolean' }
+ }
+ },
+ response: {
+ 200: {
+ type: 'object',
+ properties: {
+ meta: { $ref: 'PaginationMeta' },
+ count: { type: 'number' },
+ notifications: { $ref: 'NotificationList' }
+ }
+ },
+ '4xx': {
+ $ref: 'APIError'
+ }
+ }
+ }
+ }, async (request, reply) => {
+ const payload = { read: request.body.read, ids: request.body.ids }
+
+ await app.db.models.Notification.updateNotificationsState(payload, request.session.User)
+
+ const notifications = await getNotifications(app, request)
+
+ reply.send(notifications)
+ })
// Bulk delete
// app.delete('/', {
diff --git a/forge/services/notifications.js b/forge/services/notifications.js
new file mode 100644
index 0000000000..9e46b90c36
--- /dev/null
+++ b/forge/services/notifications.js
@@ -0,0 +1,8 @@
+module.exports.getNotifications = async (app, request) => {
+ const paginationOptions = app.getPaginationOptions(request)
+ const notifications = await app.db.models.Notification.forUser(request.session.User, paginationOptions)
+
+ notifications.notifications = app.db.views.Notification.notificationList(notifications.notifications)
+
+ return notifications
+}
diff --git a/frontend/src/api/snapshots.js b/frontend/src/api/snapshots.js
index 8cd5986b5d..b438388268 100644
--- a/frontend/src/api/snapshots.js
+++ b/frontend/src/api/snapshots.js
@@ -45,12 +45,13 @@ const exportSnapshot = (snapshotId, options) => {
* @param {Object} snapshot - snapshot object to import
* @param {String} [credentialSecret] - secret to use when decrypting credentials in the snapshot object (optional/only required when the snapshot contains credentials)
*/
-const importSnapshot = async (ownerId, ownerType, snapshot, credentialSecret) => {
+const importSnapshot = async (ownerId, ownerType, snapshot, credentialSecret, options) => {
return client.post('/api/v1/snapshots/import', {
ownerId,
ownerType,
snapshot,
- credentialSecret
+ credentialSecret,
+ components: options?.components
}).then(res => {
const props = {
'snapshot-id': res.data.id
diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js
index 8f21ada2ec..21bf7c906a 100644
--- a/frontend/src/api/user.js
+++ b/frontend/src/api/user.js
@@ -94,7 +94,18 @@ const markNotificationRead = async (id) => {
read: true
})
}
-
+const markNotificationsBulk = async (ids, data = { read: true }) => {
+ return client.put('/api/v1/user/notifications/', {
+ ids,
+ ...data
+ }).then(res => {
+ res.data.notifications = res.data.notifications.map(n => {
+ n.createdSince = daysSince(n.createdAt)
+ return n
+ })
+ return res.data
+ })
+}
const getTeamInvitations = async () => {
return client.get('/api/v1/user/invitations').then(res => {
res.data.invitations = res.data.invitations.map(r => {
@@ -240,6 +251,7 @@ export default {
deleteUser,
getNotifications,
markNotificationRead,
+ markNotificationsBulk,
getTeamInvitations,
acceptTeamInvitation,
rejectTeamInvitation,
diff --git a/frontend/src/components/SectionTopMenu.vue b/frontend/src/components/SectionTopMenu.vue
index 55075486a6..df5a9e3bdc 100644
--- a/frontend/src/components/SectionTopMenu.vue
+++ b/frontend/src/components/SectionTopMenu.vue
@@ -89,7 +89,6 @@ export default {
.wrapper {
flex: 1;
align-items: baseline;
- overflow: hidden;
.info {
text-overflow: ellipsis;
diff --git a/frontend/src/components/dialogs/SnapshotImportDialog.vue b/frontend/src/components/dialogs/SnapshotImportDialog.vue
index 91fbeafd82..3cbad24878 100644
--- a/frontend/src/components/dialogs/SnapshotImportDialog.vue
+++ b/frontend/src/components/dialogs/SnapshotImportDialog.vue
@@ -15,7 +15,6 @@
- Credentials Secret
Name
Description
@@ -23,6 +22,14 @@
+
+ Credentials Secret
@@ -32,6 +39,7 @@
import { DocumentIcon } from '@heroicons/vue/outline'
import snapshotsApi from '../../api/snapshots.js'
+import ImportInstanceComponents from '../../pages/instance/components/ExportImportComponents.vue'
import alerts from '../../services/alerts.js'
import { isSnapshot } from '../../utils/snapshot.js'
import FormRow from '../FormRow.vue'
@@ -40,7 +48,8 @@ export default {
name: 'SnapshotImportDialog',
components: {
FormRow,
- DocumentIcon
+ DocumentIcon,
+ ImportInstanceComponents
},
props: {
owner: {
@@ -60,11 +69,10 @@ export default {
setup () {
return {
show () {
- this.setKeys(this.input, '')
- this.setKeys(this.errors, '')
- this.setKeys(this.validateField, false)
+ this.clear()
this.$refs.dialog.show()
setTimeout(() => {
+ this.clear()
this.validate()
this.shown = true
}, 5)
@@ -84,21 +92,31 @@ export default {
errors: {
file: '',
secret: '',
- name: ''
+ name: '',
+ parts: ''
},
validateField: {
file: false,
secret: false,
- name: false
+ name: false,
+ parts: false
+ },
+ parts: {
+ flows: true,
+ credentials: true,
+ envVars: 'all'
}
}
},
computed: {
formValid () {
- return !Object.values(this.errors).some(error => !!error) && this.input.file && this.input.name && (!this.snapshotNeedsSecret || this.input.secret)
+ return !Object.values(this.errors).some(error => !!error) && this.input.file && this.input.name && (this.parts.flows || this.parts.envVars)
},
snapshotNeedsSecret () {
- return !!(this.input.snapshot && this.input.snapshot.flows?.credentials)
+ if (!this.input.snapshot?.flows?.credentials) {
+ return false
+ }
+ return Object.keys(this.input.snapshot.flows.credentials).length > 0
}
},
watch: {
@@ -119,16 +137,29 @@ export default {
this.validateField.secret = true
this.validate()
}
+ },
+ parts: {
+ handler () {
+ if (this.shown) {
+ this.validateField.parts = true
+ this.validate()
+ }
+ },
+ deep: true
}
},
mounted () {
this.$refs.fileUpload.addEventListener('change', (e) => {
+ if (!e.target.files?.length) {
+ return
+ }
const file = e.target.files[0]
this.input.snapshot = null
this.input.file = ''
this.errors.file = null
this.validateField.file = true
this.validateField.secret = true
+ this.validateField.parts = true
const reader = new FileReader()
reader.onload = () => {
const data = reader.result
@@ -157,7 +188,12 @@ export default {
validate () {
this.errors.file = !this.input.file ? 'Snapshot file is required' : ''
this.errors.name = !this.input.name ? 'Name is required' : ''
- this.errors.secret = this.snapshotNeedsSecret ? (this.input.secret ? '' : 'Secret is required') : ''
+ if (this.parts.flows && this.parts.credentials) {
+ this.errors.secret = this.snapshotNeedsSecret ? (this.input.secret ? '' : 'Secret is required') : ''
+ } else {
+ this.errors.secret = ''
+ }
+ this.errors.parts = this.parts.flows === false && this.parts.envVars === false ? 'At least one component must be selected' : ''
return this.formValid
},
confirm () {
@@ -177,7 +213,34 @@ export default {
if (this.snapshotNeedsSecret) {
secret = this.input.secret
}
- snapshotsApi.importSnapshot(this.owner.id, this.ownerType, importSnapshot, secret).then((response) => {
+ const components = {
+ flows: this.parts.flows,
+ credentials: this.parts.credentials,
+ envVars: this.parts.envVars
+ }
+ if (components.flows === false) {
+ importSnapshot.flows = {
+ flows: [],
+ credentials: {}
+ }
+ } else if (components.credentials === false) {
+ importSnapshot.flows = {
+ flows: importSnapshot.flows.flows,
+ credentials: {}
+ }
+ }
+
+ importSnapshot.settings = importSnapshot.settings || {}
+ if (components.envVars === false) {
+ importSnapshot.settings.env = {}
+ } else if (components.envVars === 'keys') {
+ importSnapshot.settings.env = Object.keys(importSnapshot.settings.env || {}).reduce((acc, key) => {
+ acc[key] = ''
+ return acc
+ }, {})
+ }
+
+ snapshotsApi.importSnapshot(this.owner.id, this.ownerType, importSnapshot, secret, { components }).then((response) => {
this.$emit('snapshot-import-success', response)
this.$refs.dialog.close()
this.shown = false
@@ -188,10 +251,21 @@ export default {
},
cancel () {
this.shown = false
+ this.$refs.fileUpload.value = ''
this.$refs.dialog.close()
this.$emit('canceled')
},
+ clear () {
+ this.$refs.fileUpload.value = ''
+ this.setKeys(this.input, '')
+ this.setKeys(this.errors, '')
+ this.setKeys(this.validateField, false)
+ this.parts.flows = true
+ this.parts.credentials = true
+ this.parts.envVars = 'all'
+ },
selectSnapshot () {
+ this.$refs.fileUpload.value = ''
const fileUpload = this.$refs.fileUpload
fileUpload.click()
},
diff --git a/frontend/src/components/drawers/notifications/NotificationsDrawer.vue b/frontend/src/components/drawers/notifications/NotificationsDrawer.vue
index 3719b76199..a8ec82c450 100644
--- a/frontend/src/components/drawers/notifications/NotificationsDrawer.vue
+++ b/frontend/src/components/drawers/notifications/NotificationsDrawer.vue
@@ -1,19 +1,47 @@