Skip to content

Commit

Permalink
Improved Admin Team view
Browse files Browse the repository at this point in the history
  • Loading branch information
knolleary committed Nov 13, 2024
1 parent 073d514 commit 3196403
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 43 deletions.
53 changes: 40 additions & 13 deletions forge/db/models/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,36 +231,63 @@ module.exports = {
where = { id: queryId }
}
}
const order = [['id', 'ASC']]
if (pagination.sort === 'createdAt-desc') {
order[0][1] = 'DESC'
// Also need to 'fix' the cursor pagination
if (pagination.cursor) {
where[Op.and].forEach(rule => {
if (rule.id) {
rule.id = { [Op.lt]: pagination.cursor }
}
})
}
}
const include = [{ model: M.TeamType, attributes: ['hashid', 'id', 'name'] }]
if (app.billing) {
// Include subscription info
include.push({ model: app.db.models.Subscription })
}

const [rows, count] = await Promise.all([
this.findAll({
where,
order: [['id', 'ASC']],
order,
limit,
include: { model: M.TeamType, attributes: ['hashid', 'id', 'name'] },
include,
attributes: {
include: [
[
literal(`(
SELECT COUNT(*)
FROM "Projects" AS "project"
WHERE
"project"."TeamId" = "Team"."id"
)`),
SELECT COUNT(*)
FROM "Projects" AS "project"
WHERE
"project"."TeamId" = "Team"."id"
)`),
'projectCount'
],
[
literal(`(
SELECT COUNT(*)
FROM "TeamMembers" AS "members"
WHERE
"members"."TeamId" = "Team"."id"
)`),
SELECT COUNT(*)
FROM "TeamMembers" AS "members"
WHERE
"members"."TeamId" = "Team"."id"
)`),
'memberCount'
],
[
literal(`(
SELECT COUNT(*)
FROM "Devices" AS "devices"
WHERE
"devices"."TeamId" = "Team"."id"
)`),
'deviceCount'
]
]
}
}),
this.count({ where })
this.count({ where, include })
])
return {
meta: {
Expand Down
12 changes: 12 additions & 0 deletions forge/db/views/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ module.exports = function (app) {
updatedAt: result.updatedAt,
links: result.links
}
if (team.Subscription) {
filtered.billing = {}
filtered.billing.active = team.Subscription.isActive()
filtered.billing.unmanaged = team.Subscription.isUnmanaged()
filtered.billing.canceled = team.Subscription.isCanceled()
filtered.billing.pastDue = team.Subscription.isPastDue()
if (team.Subscription.isTrial()) {
filtered.billing.trial = true
filtered.billing.trialEnded = team.Subscription.isTrialEnded()
filtered.billing.trialEndsAt = team.Subscription.trialEndsAt
}
}
return filtered
} else {
return null
Expand Down
1 change: 1 addition & 0 deletions forge/ee/db/models/Subscription.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ module.exports = {
},
associations: function (M) {
this.belongsTo(M.Team)
M.Team.hasOne(this)
},
finders: function (M) {
const self = this
Expand Down
20 changes: 19 additions & 1 deletion forge/routes/api/team.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { Op } = require('sequelize')

const { Roles } = require('../../lib/roles')

const TeamDevices = require('./teamDevices.js')
Expand Down Expand Up @@ -250,8 +252,24 @@ module.exports = async function (app) {
}
}, async (request, reply) => {
// Admin request for all teams
const where = {}
const filters = []
if (request.query.teamType) {
const teamTypes = request.query.teamType.split(',').map(app.db.models.TeamType.decodeHashid).flat()
filters.push({ TeamTypeId: { [Op.in]: teamTypes } })
}
if (request.query.state === 'suspended') {
filters.push({ suspended: true })
} else if (app.billing && request.query.billing) {
filters.push({ suspended: false })
const billingStates = request.query.billing.split(',')
filters.push({ '$Subscription.status$': { [Op.in]: billingStates } })
}
if (filters.length > 0) {
where[Op.and] = filters
}
const paginationOptions = app.getPaginationOptions(request)
const teams = await app.db.models.Team.getAll(paginationOptions)
const teams = await app.db.models.Team.getAll(paginationOptions, where)
teams.teams = teams.teams.map(t => app.db.views.Team.team(t))
reply.send(teams)
})
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/teams.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import paginateUrl from '../utils/paginateUrl.js'

import client from './client.js'

const getTeams = async (cursor, limit, query) => {
const url = paginateUrl('/api/v1/teams', cursor, limit, query)
const getTeams = async (cursor, limit, query, filter) => {
const url = paginateUrl('/api/v1/teams', cursor, limit, query, filter)
return client.get(url).then(res => {
res.data.teams = res.data.teams.map(r => {
r.link = { name: 'Team', params: { team_slug: r.slug } }
Expand Down
181 changes: 155 additions & 26 deletions frontend/src/pages/admin/Teams.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,70 @@
<template>
<div>
<SectionTopMenu hero="Teams" />
<ff-data-table
v-model:search="teamSearch"
:columns="columns"
:rows="teams"
:rows-selectable="true"
:show-search="true"
search-placeholder="Search Teams..."
:search-fields="['name', 'id']"
:show-load-more="!!nextCursor"
:loading="loading"
loading-message="Loading Teams"
no-data-message="No Teams Found"
data-el="teams-table"
@row-selected="viewTeam"
@load-more="loadItems"
/>
<div class="ff-admin-audit">
<div>
<SectionTopMenu hero="Teams" />
<ff-data-table
v-model:search="teamSearch"
:columns="columns"
:rows="teams"
:rows-selectable="true"
:show-search="true"
search-placeholder="Search Teams..."
:search-fields="['name', 'id']"
:show-load-more="!!nextCursor"
:loading="loading"
loading-message="Loading Teams"
no-data-message="No Teams Found"
data-el="teams-table"
@row-selected="viewTeam"
@load-more="loadItems"
/>
</div>
<div>
<SectionTopMenu hero="Filters" />
<FormHeading class="mt-4">Sort:</FormHeading>
<div data-el="sort-options">
<ff-dropdown v-model="filters.sortBy" class="w-full">
<ff-dropdown-option
v-for="sortOpt in sortOptions" :key="sortOpt.id"
:label="`${sortOpt.label}`" :value="sortOpt.id"
/>
</ff-dropdown>
</div>
<FormHeading class="mt-4">Team Type:</FormHeading>
<div data-el="filter-team-types" class="pl-2 space-y-2">
<FormRow
v-for="teamType in teamTypes"
:key="teamType.id"
v-model="filters.teamType[teamType.id]"
type="checkbox"
>
{{ teamType.name }}
</FormRow>
</div>

<template v-if="features.billing">
<FormHeading class="mt-4">Billing State:</FormHeading>
<div data-el="filter-team-types" class="pl-2 space-y-2">
<FormRow v-model="filters.suspended" type="checkbox">Suspended</FormRow>
<FormRow v-model="filters.billing.active" :disabled="filters.suspended" type="checkbox">Active</FormRow>
<FormRow v-model="filters.billing.trial" :disabled="filters.suspended" type="checkbox">Trial</FormRow>
<FormRow v-model="filters.billing.canceled" :disabled="filters.suspended" type="checkbox">Canceled</FormRow>
<FormRow v-model="filters.billing.unmanaged" :disabled="filters.suspended" type="checkbox">Unmanaged</FormRow>
</div>
</template>
</div>
</div>
</template>

<script>
import { markRaw } from 'vue'
import { mapState } from 'vuex'
import teamTypesApi from '../../api/teamTypes.js'
import teamsApi from '../../api/teams.js'
import FormHeading from '../../components/FormHeading.vue'
import FormRow from '../../components/FormRow.vue'
import SectionTopMenu from '../../components/SectionTopMenu.vue'
import TeamCell from '../../components/tables/cells/TeamCell.vue'
Expand All @@ -33,22 +73,41 @@ import TeamTypeCell from '../../components/tables/cells/TeamTypeCell.vue'
export default {
name: 'AdminTeams',
components: {
SectionTopMenu
SectionTopMenu,
FormHeading,
FormRow
},
data () {
const columns = [
{ label: 'Name', component: { is: markRaw(TeamCell) } },
{ label: 'Created', class: ['w-64'], key: 'createdAtFormatted' },
{ label: 'Type', key: 'type', component: { is: markRaw(TeamTypeCell) } },
{ label: 'Members', class: ['w-54', 'text-center'], key: 'memberCount' },
{ label: 'Instances', class: ['w-54', 'text-center'], key: 'instanceCount' },
{ label: 'Devices', class: ['w-54', 'text-center'], key: 'deviceCount' }
]
return {
teams: [],
teamSearch: '',
teamTypes: [],
sortOptions: [
{ id: 'createdAt-desc', label: 'Newest' },
{ id: 'created-asc', label: 'Oldest' }
],
filters: {
teamType: {},
billing: {},
suspended: false,
sortBy: 'createdAt-desc'
},
loading: false,
nextCursor: null,
columns: [
{ label: 'Name', class: ['w-full'], component: { is: markRaw(TeamCell) }, sortable: true },
{ label: 'Type', key: 'type', component: { is: markRaw(TeamTypeCell) }, sortable: true },
{ label: 'Members', class: ['w-54', 'text-center'], key: 'memberCount', sortable: true },
{ label: 'Application Instances', class: ['w-54', 'text-center'], key: 'instanceCount', sortable: true }
]
columns
}
},
computed: {
...mapState('account', ['features'])
},
watch: {
teamSearch (v) {
if (this.pendingSearch) {
Expand All @@ -62,9 +121,39 @@ export default {
this.loadItems(true)
}, 300)
}
},
filters: {
handler () {
this.loading = true
this.loadItems(true)
},
deep: true
}
},
async created () {
const teamTypes = (await teamTypesApi.getTeamTypes(null, null, 'all')).types
this.teamTypes = teamTypes.map(tt => {
return {
order: tt.order,
id: tt.id,
name: tt.name,
active: tt.active
}
})
this.teamTypes.sort((A, B) => {
if (A.active === B.active) {
return A.order - B.order
} else if (A.active) {
return -1
} else {
return 1
}
})
if (this.features.billing) {
this.columns.push(
{ label: 'Billing', key: 'billingSummary' }
)
}
await this.loadItems(true)
},
methods: {
Expand All @@ -75,7 +164,27 @@ export default {
}
let result
try {
result = await teamsApi.getTeams(this.nextCursor, 30, this.teamSearch)
const filter = {
sort: this.filters.sortBy
}
for (const [teamType, enabled] of Object.entries(this.filters.teamType)) {
if (enabled) {
filter.teamType = filter.teamType || []
filter.teamType.push(teamType)
}
}
if (this.filters.suspended) {
filter.state = 'suspended'
} else if (this.features.billing) {
// Only apply billing filter if not filtering for suspended teams
for (const [billingFeature, enabled] of Object.entries(this.filters.billing)) {
if (enabled) {
filter.billing = filter.billing || []
filter.billing.push(billingFeature)
}
}
}
result = await teamsApi.getTeams(this.nextCursor, 30, this.teamSearch, filter)
} catch (err) {
if (err.response?.status === 403) {
this.$router.push('/')
Expand All @@ -87,6 +196,26 @@ export default {
}
this.nextCursor = result.meta.next_cursor
result.teams.forEach(v => {
v.createdAtFormatted = v.createdAt.replace('T', ' ').replace(/\..*$/, '')
if (v.billing) {
if (v.suspended) {
v.billingSummary = 'suspended'
} else if (v.billing.active) {
v.billingSummary = 'active'
} else if (v.billing.unmanaged) {
v.billingSummary = 'unmanaged'
} else if (v.billing.canceled) {
v.billingSummary = 'canceled'
} else if (v.billing.canceled) {
v.billingSummary = 'canceled'
} else if (v.billing.trialEnded) {
v.billingSummary = 'trial ended'
} else if (v.billing.trial) {
v.billingSummary = 'trial'
} else {
v.billingSummary = ''
}
}
this.teams.push(v)
})
this.loading = false
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/admin/Users/UserDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ export default {
{ label: 'Role', component: { is: markRaw(UserRoleCell) }, sortable: true },
{ label: 'Type', key: 'type', component: { is: markRaw(TeamTypeCell) }, sortable: true },
{ label: 'Members', class: ['w-54', 'text-center'], key: 'memberCount', sortable: true },
{ label: 'Application Instances', class: ['w-54', 'text-center'], key: 'instanceCount', sortable: true }
{ label: 'Instances', class: ['w-54', 'text-center'], key: 'instanceCount', sortable: true },
{ label: 'Devices', class: ['w-54', 'text-center'], key: 'deviceCount', sortable: true }
]
}
},
Expand Down

0 comments on commit 3196403

Please sign in to comment.