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

Add a devices/:deviceId/history API endpoint #5090

Merged
merged 8 commits into from
Feb 11, 2025
2 changes: 1 addition & 1 deletion .github/scripts/initial-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ kubectl run flowfuse-setup-4 \
--image bitnami/postgresql:14.10.0-debian-11-r3 \
-- psql -h flowfuse-pr-$PR_NUMBER-postgresql -U forge -d flowforge -c \
"INSERT INTO public.\"TeamMembers\" (\"role\",\"UserId\",\"TeamId\")\
VALUES
VALUES
(30, 2, 1),
(30, 2, 2),
(30, 2, 3),
Expand Down
61 changes: 48 additions & 13 deletions forge/db/models/AuditLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ module.exports = {
log: rows
}
},
forProjectHistory: async (projectId, pagination = {}) => {
forTimelineHistory: async (entityId, entityType, pagination = {}) => {
// Premise:
// we want to generate a timeline of events for a project, including snapshots
// so that user can see "things that changed" the project and any immediate snapshots.
Expand All @@ -107,21 +107,56 @@ module.exports = {
// Additionally, flag snapshot existence in the info object as { snapshotExists: true/false }
// (The info object is a permitted field in the audit log entry body (schema))
// * Return the log entries as { meta: Object, count: Number, timeline: Array<Object> }

let events = []
const limit = parseInt(pagination.limit) || 100

if (entityType === 'project') {
events = [
'project.created',
'project.deleted',
'flows.set', // flows deployed by user
'project.settings.updated',
'project.snapshot.created', // snapshot created manually or automatically
'project.snapshot.rolled-back', // snapshot rolled back by user
'project.snapshot.imported' // result of a pipeline deployment
]
} else if (entityType === 'device') {
events = [
'flows.set',
'device.restarted',
'device.settings.updated'
// 'device.assigned',
// 'device.credential.generated',
// 'device.developer-mode.disabled',
// 'device.developer-mode.enabled',
// 'device.remote-access.disabled',
// 'device.remote-access.enabled',
// 'team.device.assigned',
// 'team.device.bulk-deleted',
// 'team.device.created',
// 'team.device.credentials-generated',
// 'team.device.deleted',
// 'team.device.developer-mode.disabled',
// 'team.device.developer-mode.enabled',
// 'team.device.provisioning.created',
// 'team.device.remote-access.disabled',
// 'team.device.remote-access.enabled',
// 'team.device.unassigned',
// 'team.device.updated',
// 'application.device.assigned',
// 'application.device.snapshot.created',
// 'application.device.unassigned',
// 'application.deviceGroup.created',
// 'application.deviceGroup.deleted',
// 'application.deviceGroup.members.changed'
]
}

const where = {
entityId: projectId,
entityType: 'project',
entityId: '' + entityId,
entityType,
event: {
[Op.in]: [
'project.created',
'project.deleted',
'flows.set', // flows deployed by user
'project.settings.updated',
'project.snapshot.created', // snapshot created manually or automatically
'project.snapshot.rolled-back', // snapshot rolled back by user
'project.snapshot.imported' // result of a pipeline deployment
]
[Op.in]: events
}
}
const result = {
Expand Down
71 changes: 71 additions & 0 deletions forge/ee/routes/deviceHistory/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const deviceShared = require('../../../routes/api/shared/device.js')

module.exports = async function (app) {
app.addHook('preHandler', deviceShared.defaultPreHandler.bind(null, app))
app.addHook('preHandler', async (request, reply) => {
const id = request.device?.Team?.id
const team = await app.db.models.Team.byId(id)
if (team) {
request.team = team
// Check this feature is enabled for this team type.
if (team.TeamType.getFeatureProperty('projectHistory', true)) {
return
}
}
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
})
/**
* Get device history
* - returns a timeline of changes to the device
* - ?cursor= can be used to set the 'most recent log entry' to query from
* - ?limit= can be used to modify how many entries to return
* @name /api/v1/devices/:id/history
* @memberof forge.routes.api.devices
*/
app.get('/', {
preHandler: app.needsPermission('project:history'),
schema: {
summary: 'Get Device history',
hide: true, // mark as explicitly hidden (internal for now)
tags: [/* 'Devices' */], // no tag hides route from swagger (internal for now)
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
query: {
$ref: 'PaginationParams'
},
response: {
200: {
type: 'object',
properties: {
meta: { $ref: 'PaginationMeta' },
count: { type: 'number' },
timeline: { $ref: 'TimelineList' }
}
},
'4xx': {
$ref: 'APIError'
},
500: {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forTimelineHistory(
request.device.id,
'device',
paginationOptions
)
const timelineView = app.db.views.AuditLog.timelineList(logEntries?.timeline || [])
reply.send({
meta: logEntries.meta,
count: timelineView.length,
timeline: timelineView
})
})
}
1 change: 1 addition & 0 deletions forge/ee/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module.exports = async function (app) {
await app.register(require('./customHostnames'), { prefix: '/api/v1/projects/:projectId/customHostname', logLevel: app.config.logging.http })
await app.register(require('./staticAssets'), { prefix: '/api/v1/projects/:projectId/files', logLevel: app.config.logging.http })
await app.register(require('./projectHistory'), { prefix: '/api/v1/projects/:instanceId/history', logLevel: app.config.logging.http })
await app.register(require('./deviceHistory'), { prefix: '/api/v1/devices/:deviceId/history', logLevel: app.config.logging.http })
await app.register(require('./teamBroker'), { prefix: '/api/v1/teams/:teamId/broker', logLevel: app.config.logging.http })

// Important: keep SSO last to avoid its error handling polluting other routes.
Expand Down
6 changes: 5 additions & 1 deletion forge/ee/routes/projectHistory/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ module.exports = async function (app) {
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forProjectHistory(request.project.id, paginationOptions)
const logEntries = await app.db.models.AuditLog.forTimelineHistory(
request.project.id,
'project',
paginationOptions
)
const timelineView = app.db.views.AuditLog.timelineList(logEntries?.timeline || [])
reply.send({
meta: logEntries.meta,
Expand Down
2 changes: 1 addition & 1 deletion forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const Permissions = {
*/

// Projects
'project:history': { description: 'View project history', role: Roles.Member },
'project:history': { description: 'View Hosted Instances project history', role: Roles.Member },

// Application
'application:bom': { description: 'Get the Application Bill of Materials', role: Roles.Owner },
Expand Down
31 changes: 31 additions & 0 deletions forge/routes/api/shared/device.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module.exports = {
defaultPreHandler: async (app, request, reply) => {
if (request.params.deviceId !== undefined) {
if (request.params.deviceId) {
try {
// StorageFlow needed for last updates time (live status)
request.device = await app.db.models.Device.byId(request.params.deviceId, { includeStorageFlows: true })
if (!request.device) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return
}
if (request.session.User) {
request.teamMembership = await request.session.User.getTeamMembership(request.device.Team.id)
if (!request.teamMembership && !request.session.User.admin) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return // eslint-disable-line no-useless-return
}
} else if (request.session.ownerId !== request.params.deviceId) {
// AccesToken being used - but not owned by this project
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
return // eslint-disable-line no-useless-return
}
} catch (err) {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
} else {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
}
}
}
Loading
Loading