Skip to content

Commit 38cba84

Browse files
authored
Merge pull request #4764 from FlowFuse/4756-search-api
Add search api for apps/devices/instances
2 parents becdc3e + 81900e9 commit 38cba84

File tree

8 files changed

+370
-14
lines changed

8 files changed

+370
-14
lines changed

forge/db/models/Application.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* An application definition
33
* @namespace forge.db.models.Application
44
*/
5-
const { DataTypes, Op, literal } = require('sequelize')
5+
const { col, fn, DataTypes, Op, literal, where } = require('sequelize')
66

77
const { KEY_SETTINGS, KEY_HA } = require('./ProjectSettings')
88

@@ -47,7 +47,7 @@ module.exports = {
4747
]
4848
})
4949
},
50-
byTeam: async (teamIdOrHash, { includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => {
50+
byTeam: async (teamIdOrHash, { query = null, includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => {
5151
let id = teamIdOrHash
5252
if (typeof teamIdOrHash === 'string') {
5353
id = M.Team.decodeHashid(teamIdOrHash)
@@ -117,12 +117,18 @@ module.exports = {
117117
includes.push(include)
118118
}
119119

120-
const query = {
120+
const queryObject = {
121121
include: includes
122122
}
123+
if (query) {
124+
queryObject.where = where(
125+
fn('lower', col('Application.name')),
126+
{ [Op.like]: `%${query.toLowerCase()}%` }
127+
)
128+
}
123129

124130
if (includeApplicationSummary) {
125-
query.attributes = {
131+
queryObject.attributes = {
126132
include: [
127133
[
128134
literal(`(
@@ -166,7 +172,7 @@ module.exports = {
166172
// You can add a license without a restart, so we also need to check if the Model is loaded
167173
// If the model is loaded, it can be assumed the table exists
168174
if (app.license.active() && app.db.models.Pipeline) {
169-
query.attributes.include.push([
175+
queryObject.attributes.include.push([
170176
literal(`(
171177
SELECT count(*)
172178
FROM "Pipelines"
@@ -177,7 +183,7 @@ module.exports = {
177183
}
178184
}
179185

180-
return this.findAll(query)
186+
return this.findAll(queryObject)
181187
}
182188
},
183189
instance: {

forge/db/models/Device.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const crypto = require('crypto')
66

77
const SemVer = require('semver')
88

9-
const { DataTypes, Op } = require('sequelize')
9+
const { col, fn, DataTypes, Op, where } = require('sequelize')
1010

1111
const Controllers = require('../controllers')
1212
const { buildPaginationSearchClause } = require('../utils')
@@ -305,6 +305,23 @@ module.exports = {
305305
]
306306
})
307307
},
308+
byTeam: async (teamIdOrHash, { query = null } = {}) => {
309+
let teamId = teamIdOrHash
310+
if (typeof teamId === 'string') {
311+
teamId = M.Team.decodeHashid(teamId)
312+
}
313+
const queryObject = {
314+
where: { [Op.and]: [{ TeamId: teamId }] }
315+
}
316+
317+
if (query) {
318+
queryObject.where[Op.and].push(where(
319+
fn('lower', col('Device.name')),
320+
{ [Op.like]: `%${query.toLowerCase()}%` }
321+
))
322+
}
323+
return this.getAll({}, queryObject.where)
324+
},
308325
getAll: async (pagination = {}, where = {}, { includeInstanceApplication = false, includeDeviceGroup = false } = {}) => {
309326
// Pagination
310327
const limit = Math.min(parseInt(pagination.limit) || 100, 100)

forge/db/models/Project.js

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
const crypto = require('crypto')
1515

16-
const { DataTypes, Op } = require('sequelize')
16+
const { col, fn, DataTypes, Op, where } = require('sequelize')
1717

1818
const Controllers = require('../controllers')
1919

@@ -469,15 +469,20 @@ module.exports = {
469469
include
470470
})
471471
},
472-
byTeam: async (teamHashId, { includeSettings = false } = {}) => {
473-
const teamId = M.Team.decodeHashid(teamHashId)
472+
byTeam: async (teamIdOrHash, { query = null, includeAssociations = true, includeSettings = false } = {}) => {
473+
let teamId = teamIdOrHash
474+
if (typeof teamId === 'string') {
475+
teamId = M.Team.decodeHashid(teamId)
476+
}
474477
const include = [
475478
{
476479
model: M.Team,
477480
where: { id: teamId },
478481
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId']
479-
},
480-
{
482+
}
483+
]
484+
if (includeAssociations) {
485+
include.push({
481486
model: M.Application,
482487
attributes: ['hashid', 'id', 'name', 'links']
483488
},
@@ -492,7 +497,8 @@ module.exports = {
492497
model: M.ProjectTemplate,
493498
attributes: ['hashid', 'id', 'name', 'links']
494499
}
495-
]
500+
)
501+
}
496502

497503
if (includeSettings) {
498504
include.push({
@@ -502,7 +508,17 @@ module.exports = {
502508
})
503509
}
504510

505-
return this.findAll({ include })
511+
const queryObject = {
512+
include
513+
}
514+
515+
if (query) {
516+
queryObject.where = where(
517+
fn('lower', col('Project.name')),
518+
{ [Op.like]: `%${query.toLowerCase()}%` }
519+
)
520+
}
521+
return this.findAll(queryObject)
506522
},
507523
getProjectTeamId: async (id) => {
508524
const project = await this.findOne({

forge/db/views/Application.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ module.exports = function (app) {
6363
}
6464

6565
if (detailed) {
66+
summary.deviceCount = application.get('deviceCount')
67+
summary.instanceCount = application.get('instanceCount')
6668
summary.deviceGroupCount = application.get('deviceGroupCount')
6769
summary.snapshotCount = application.get('snapshotCount')
6870
summary.pipelineCount = application.get('pipelineCount')

forge/lib/permissions.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const Permissions = {
3131
'team:user:invite': { description: 'Invite Members', role: Roles.Owner },
3232
'team:user:remove': { description: 'Remove Member', role: Roles.Owner, self: true },
3333
'team:user:change-role': { description: 'Modify Member role', role: Roles.Owner },
34+
35+
'team:search': { description: 'Search a Teams resources', role: Roles.Viewer },
36+
3437
// Applications
3538
'application:audit-log': { description: 'Access Application Audit Log', role: Roles.Owner },
3639
// Projects

forge/routes/api/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const Assistant = require('./assistant.js')
1111
const Device = require('./device.js')
1212
const Project = require('./project.js')
1313
const ProjectType = require('./projectType.js')
14+
const Search = require('./search.js')
1415
const Settings = require('./settings.js')
1516
const Snapshot = require('./snapshot.js')
1617
const Stack = require('./stack.js')
@@ -36,6 +37,7 @@ module.exports = async function (app) {
3637
app.register(ProjectType, { prefix: '/project-types' })
3738
app.register(Snapshot, { prefix: '/snapshots' })
3839
app.register(Assistant, { prefix: '/assistant' })
40+
app.register(Search, { prefix: '/search' })
3941
app.get('*', function (request, reply) {
4042
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
4143
})

forge/routes/api/search.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Search API
3+
*
4+
* - /api/v1/search
5+
*
6+
*/
7+
module.exports = async function (app) {
8+
/**
9+
* Search API
10+
*
11+
* Query Params:
12+
* - team : team hash id (required)
13+
* - query: <string> search query term (required)
14+
*/
15+
app.get('/', {
16+
schema: {
17+
summary: 'Search for resources',
18+
tags: ['Search'],
19+
query: {
20+
type: 'object',
21+
// For now, require 'team' query param to scope the search to
22+
// a single team. This *could* be relaxed in the future to operate
23+
// across all teams the user is a member of.
24+
required: ['team'],
25+
properties: {
26+
team: { type: 'string' },
27+
query: { type: 'string' }
28+
},
29+
additionalProperties: true
30+
},
31+
response: {
32+
200: {
33+
type: 'object',
34+
properties: {
35+
count: { type: 'number' },
36+
results: { type: 'array', items: { type: 'object', additionalProperties: true } }
37+
}
38+
},
39+
'4xx': {
40+
$ref: 'APIError'
41+
}
42+
}
43+
}
44+
}, async (request, reply) => {
45+
const query = request.query.query?.trim()
46+
const teamHashId = request.query.team
47+
const [teamId] = app.db.models.Team.decodeHashid(teamHashId)
48+
// Only search if query is not blank
49+
if (query) {
50+
// Only search if a valid team has been provided
51+
if (teamId !== undefined) {
52+
const membership = await request.session.User.getTeamMembership(teamId)
53+
// Check user has access to this team - either admin or at least Viewer role
54+
if (request.session.User.admin || app.hasPermission(membership, 'team:search')) {
55+
// Now do the search
56+
const applicationSearchPromise = app.db.models.Application.byTeam(
57+
teamId,
58+
{
59+
query,
60+
includeApplicationSummary: true
61+
}
62+
)
63+
const instanceSearchPromise = app.db.models.Project.byTeam(
64+
teamId,
65+
{
66+
query,
67+
includeAssociations: false
68+
}
69+
)
70+
const deviceSearchPromise = app.db.models.Device.byTeam(
71+
teamId,
72+
{
73+
query
74+
}
75+
)
76+
const results = await Promise.all([
77+
applicationSearchPromise,
78+
instanceSearchPromise,
79+
deviceSearchPromise
80+
])
81+
const rr = [
82+
...results[0].map(application => {
83+
return {
84+
object: 'application',
85+
...app.db.views.Application.applicationSummary(application, { detailed: true })
86+
}
87+
}),
88+
...results[1].map(instance => {
89+
return {
90+
object: 'instance',
91+
...app.db.views.Project.projectSummary(instance)
92+
}
93+
}),
94+
...results[2].devices.map(device => {
95+
return {
96+
object: 'device',
97+
...app.db.views.Device.deviceSummary(device)
98+
}
99+
})
100+
]
101+
102+
reply.send({
103+
count: rr.length,
104+
results: rr.flat()
105+
})
106+
return
107+
}
108+
}
109+
}
110+
// Invalid team - send empty results
111+
reply.send({
112+
count: 0,
113+
results: []
114+
})
115+
})
116+
}

0 commit comments

Comments
 (0)