diff --git a/.eslintrc b/.eslintrc index c5fc56c99c..24a3a8fcad 100644 --- a/.eslintrc +++ b/.eslintrc @@ -81,6 +81,9 @@ "commonjs": false }, "parserOptions": { + "parser": { + "ts": "@typescript-eslint/parser" + }, "ecmaVersion": 2022, "sourceType": "module" }, diff --git a/forge/db/controllers/Device.js b/forge/db/controllers/Device.js index 3650fc23c1..e29c80035c 100644 --- a/forge/db/controllers/Device.js +++ b/forge/db/controllers/Device.js @@ -229,8 +229,9 @@ module.exports = { } if (paginationOptions.sort) { + // Prefer `dir`; fall back to legacy `order` query param if a caller still sends it. paginationOptions.order = { - [paginationOptions.sort]: paginationOptions.order + [paginationOptions.sort]: paginationOptions.dir || paginationOptions.order } delete paginationOptions.sort delete paginationOptions.dir diff --git a/forge/db/controllers/Project.js b/forge/db/controllers/Project.js index 89a3d15e0c..19ee3b8b23 100644 --- a/forge/db/controllers/Project.js +++ b/forge/db/controllers/Project.js @@ -22,6 +22,24 @@ module.exports = { app.caches.createCache(inflightDeploys) }, + getProjectPaginationOptions: function (app, request) { + const { page, limit, sort, dir } = request.query || {} + const pagination = {} + if (typeof page === 'number') { + const pageSize = limit || 25 + pagination.page = page + pagination.limit = pageSize + pagination.offset = (page - 1) * pageSize + } else if (limit) { + pagination.limit = limit + } + if (sort) { + pagination.sort = sort + pagination.dir = dir || 'asc' + } + return pagination + }, + /** * Get the in-flight state of a project * @param {*} app diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index d4cbff5d78..5de259477c 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -479,7 +479,13 @@ module.exports = { } = {}) => { // Pagination const limit = Math.min(parseInt(pagination.limit) || 100, 100) - if (pagination.cursor) { + const pageNum = pagination.page ? parseInt(pagination.page) : null + const usingOffset = pageNum && pageNum >= 1 + const offset = usingOffset ? (pageNum - 1) * limit : null + if (usingOffset) { + // Offset mode and cursor mode are mutually exclusive — drop any incoming cursor. + delete pagination.cursor + } else if (pagination.cursor) { const cursors = pagination.cursor.split(',') cursors[cursors.length - 1] = M.Device.decodeHashid(cursors[cursors.length - 1]) pagination.cursor = cursors.join(',') @@ -535,6 +541,8 @@ module.exports = { } } else if (key === 'instance') { order.unshift([M.Project, 'name', pagination.order[key] || 'ASC']) + } else if (key === 'deviceGroup.name') { + order.unshift([M.DeviceGroup, 'name', pagination.order[key] || 'ASC']) } else if (key === 'state-priority') { order.unshift([literal(` CASE @@ -627,18 +635,31 @@ module.exports = { } const statusOnlyIncludes = projectInclude.include?.where ? [projectInclude] : [] + const findAllOptions = { + where: buildPaginationSearchClause(pagination, where, ['Device.name', 'Device.type'], {}, order), + include: pagination.statusOnly ? statusOnlyIncludes : includes, + order, + limit: pagination.statusOnly ? null : limit + } + if (usingOffset && !pagination.statusOnly) { + findAllOptions.offset = offset + } + // Mirror findAll's search/filter so total matches; strip cursor so we count the full match, not rows after it. + const countWhere = buildPaginationSearchClause( + { ...pagination, cursor: null }, + where, + ['Device.name', 'Device.type'], + {}, + order + ) const [rows, count] = await Promise.all([ - this.findAll({ - where: buildPaginationSearchClause(pagination, where, ['Device.name', 'Device.type'], {}, order), - include: pagination.statusOnly ? statusOnlyIncludes : includes, - order, - limit: pagination.statusOnly ? null : limit - }), - this.count({ where, include: statusOnlyIncludes }) + this.findAll(findAllOptions), + this.count({ where: countWhere, include: statusOnlyIncludes }) ]) let nextCursors = [] - if (rows.length === limit && limit > 0) { + // Cursor-based forward continuation only makes sense in cursor mode. + if (!usingOffset && rows.length === limit && limit > 0) { const lastRow = rows[rows.length - 1] nextCursors = order.map((sortProps) => { // Model, key, dir @@ -666,10 +687,17 @@ module.exports = { }) } + const meta = { + next_cursor: nextCursors.length > 0 ? nextCursors.join(',') : undefined + } + if (usingOffset) { + meta.page = pageNum + meta.pageSize = limit + meta.total = count + meta.pageCount = Math.max(1, Math.ceil(count / limit)) + } return { - meta: { - next_cursor: nextCursors.length > 0 ? nextCursors.join(',') : undefined - }, + meta, count, devices: rows } diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 425e6e1ba7..fc4817f0e0 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -552,15 +552,23 @@ module.exports = { }) }, byTeam: async (teamIdOrHash, { + pagination = null, query = null, instanceId = null, includeAssociations = true, includeSettings = false, includeMeta = false, - limit = null, orderByMostRecentFlows = false, excludeApplications = null } = {}) => { + const { + page = null, + limit = null, + offset = null, + sort = null, + dir = 'asc' + } = pagination || {} + const withTotal = page !== null let teamId = teamIdOrHash if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) @@ -613,8 +621,31 @@ module.exports = { if (limit !== null) { queryObject.limit = limit } + if (offset !== null) { + queryObject.offset = offset + } - if (includeMeta && orderByMostRecentFlows) { + const direction = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC' + if (sort) { + const sortMap = { + name: ['name'], + createdAt: ['createdAt'], + updatedAt: ['updatedAt'], + 'application.name': [{ model: M.Application }, 'name'], + flowLastUpdatedAt: [{ model: M.StorageFlow }, 'updatedAt'] + } + const mapped = sortMap[sort] + if (mapped) { + // flowLastUpdatedAt needs the StorageFlow include — only valid with includeMeta + if (sort === 'flowLastUpdatedAt' && !includeMeta) { + // fall through to default ordering below + } else { + queryObject.order = [[...mapped, `${direction}${sort === 'flowLastUpdatedAt' ? ' NULLS LAST' : ''}`]] + } + } + } + + if (!queryObject.order && includeMeta && orderByMostRecentFlows) { queryObject.order = [ [literal(` CASE @@ -647,6 +678,18 @@ module.exports = { } } + if (withTotal) { + const countInclude = [{ + model: M.Team, + where: { id: teamId }, + attributes: [] + }] + const [rows, total] = await Promise.all([ + this.findAll(queryObject), + this.count({ where: queryObject.where, include: countInclude, distinct: true, col: 'id' }) + ]) + return { rows, total } + } return this.findAll(queryObject) }, getProjectTeamId: async (id) => { diff --git a/forge/routes/api-docs.js b/forge/routes/api-docs.js index 818d7b24ec..ec6c9dfb63 100644 --- a/forge/routes/api-docs.js +++ b/forge/routes/api-docs.js @@ -150,7 +150,11 @@ module.exports = fp(async function (app, opts) { properties: { query: { type: 'string' }, cursor: { type: 'string' }, - limit: { type: 'number' } + limit: { type: 'number' }, + page: { type: 'number', minimum: 1 }, + sort: { type: 'string' }, + dir: { type: 'string', enum: ['asc', 'desc'] }, + order: { type: 'string', enum: ['asc', 'desc'] } } }) @@ -159,7 +163,11 @@ module.exports = fp(async function (app, opts) { type: 'object', properties: { next_cursor: { type: 'string' }, - previous_cursor: { type: 'string' } + previous_cursor: { type: 'string' }, + page: { type: 'number' }, + pageSize: { type: 'number' }, + total: { type: 'number' }, + pageCount: { type: 'number' } } }) app.addSchema({ diff --git a/forge/routes/api/projectDevices.js b/forge/routes/api/projectDevices.js index d834f3489c..6f0357985e 100644 --- a/forge/routes/api/projectDevices.js +++ b/forge/routes/api/projectDevices.js @@ -21,6 +21,7 @@ module.exports = async function (app) { schema: { summary: 'Get a list of devices assigned to an instance', tags: ['Instances'], + query: { $ref: 'PaginationParams' }, params: { type: 'object', properties: { diff --git a/forge/routes/api/team.js b/forge/routes/api/team.js index 5f0e4d0915..812b06f46b 100644 --- a/forge/routes/api/team.js +++ b/forge/routes/api/team.js @@ -365,30 +365,32 @@ module.exports = async function (app) { */ app.get('/:teamId/projects', { preHandler: app.needsPermission('team:projects:list'), - query: { - type: 'object', - properties: { - limit: { - type: 'number', - nullable: true - }, - includeMeta: { - type: 'boolean', - nullable: true, - default: false - }, - orderByMostRecentFlows: { - type: 'boolean', - nullable: true, - default: false - } + schema: { + query: { + allOf: [ + { $ref: 'PaginationParams' }, + { + type: 'object', + properties: { + sort: { + type: 'string', + enum: ['name', 'createdAt', 'updatedAt', 'application.name', 'flowLastUpdatedAt'] + }, + includeMeta: { type: 'boolean', default: false }, + orderByMostRecentFlows: { type: 'boolean', default: false } + } + } + ] } } }, async (request, reply) => { const includeMeta = request.query.includeMeta + const pagination = app.db.controllers.Project.getProjectPaginationOptions(request) + const options = { includeSettings: true, - limit: request.query.limit, + pagination, + query: request.query.query?.trim() || null, includeMeta, orderByMostRecentFlows: request.query.orderByMostRecentFlows } @@ -406,7 +408,10 @@ module.exports = async function (app) { } } - const projects = await app.db.models.Project.byTeam(request.params.teamId, options) + const queryResult = await app.db.models.Project.byTeam(request.params.teamId, options) + const paginated = pagination.page != null + const projects = paginated ? queryResult.rows : queryResult + const total = paginated ? queryResult.total : null if (projects) { let result = await app.db.views.Project.instancesList(projects, { @@ -420,10 +425,19 @@ module.exports = async function (app) { return { id: e.id, name: e.name } }) } - reply.send({ - count: result.length, + const response = { + count: paginated ? total : result.length, projects: result - }) + } + if (paginated) { + response.meta = { + page: pagination.page, + pageSize: pagination.limit, + total, + pageCount: Math.max(1, Math.ceil(total / pagination.limit)) + } + } + reply.send(response) } else { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } diff --git a/forge/routes/api/teamDevices.js b/forge/routes/api/teamDevices.js index 6f405e66ce..41b5341901 100644 --- a/forge/routes/api/teamDevices.js +++ b/forge/routes/api/teamDevices.js @@ -95,7 +95,6 @@ module.exports = async function (app) { } const devices = await app.db.models.Device.getAll(paginationOptions, where, options) devices.devices = devices.devices.map(d => app.db.views.Device.device(d, { statusOnly: paginationOptions.statusOnly })) - devices.count = devices.devices.length reply.send(devices) }) diff --git a/frontend/src/api/team.js b/frontend/src/api/team.js index 362d649a83..fb798aa777 100644 --- a/frontend/src/api/team.js +++ b/frontend/src/api/team.js @@ -196,19 +196,39 @@ const getTeamInstancesList = async (teamId) => { } const getInstances = async (teamId, { - limit = 20, + pagination = null, includeMeta = false, orderByMostRecentFlows = false } = {}) => { + const { + page = null, + limit = 20, + query = null, + sort = null, + dir = null + } = pagination || {} const params = new URLSearchParams() params.append('limit', limit.toString()) + if (page !== null) params.append('page', String(page)) + if (query) params.append('query', query) + if (sort) params.append('sort', sort) + if (dir) params.append('dir', dir) if (includeMeta) params.append('includeMeta', includeMeta.toString()) if (orderByMostRecentFlows) params.append('orderByMostRecentFlows', orderByMostRecentFlows.toString()) - return await client.get(`/api/v1/teams/${teamId}/projects?${params.toString()}`) - .then(res => res.data) + const res = await client.get(`/api/v1/teams/${teamId}/projects?${params.toString()}`) + res.data.projects = res.data.projects.map(r => { + if (r.flowLastUpdatedAt) { + r.flowLastUpdatedSince = daysSince(r.flowLastUpdatedAt) + } + if (r.meta?.state) { + r.status = r.meta.state + } + return r + }) + return res.data } const getTeamMembers = (teamId) => { diff --git a/frontend/src/components/DevicesBrowser.vue b/frontend/src/components/DevicesBrowser.vue index 1b97a701db..24f8883cf0 100644 --- a/frontend/src/components/DevicesBrowser.vue +++ b/frontend/src/components/DevicesBrowser.vue @@ -20,13 +20,14 @@ :rows="devicesWithStatuses" :show-search="true" search-placeholder="Search Remote Instances" - :show-load-more="moreThanOnePage" + :pagination="paginationProps" :check-key="row => row.id" :show-row-checkboxes="hasPermission('team:device:bulk-edit', applicationContext)" @rows-checked="checkedDevices = $event" - @load-more="loadMoreDevices" @update:search="updateSearch" @update:sort="updateSort" + @update:page="onPageChange" + @update:page-size="onPageSizeChange" >