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 team level device groups UI #5018

Merged
merged 12 commits into from
Jan 15, 2025
8 changes: 7 additions & 1 deletion frontend/src/api/team.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,11 @@ const getDependencies = (teamId) => {
.then(res => res.data)
}

const getTeamDeviceGroups = (teamId) => {
return client.get(`/api/v1/teams/${teamId}/device-groups`)
.then(res => res.data)
}

/**
* Calls api routes in team.js
* See [routes/api/team.js](../../../forge/routes/api/team.js)
Expand Down Expand Up @@ -464,5 +469,6 @@ export default {
deleteTeamDeviceProvisioningToken,
bulkDeviceDelete,
bulkDeviceMove,
getDependencies
getDependencies,
getTeamDeviceGroups
}
35 changes: 35 additions & 0 deletions frontend/src/components/icons/DeviceGroupOutline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = require('vue')

module.exports = function render (_ctx, _cache) {
return (
_openBlock(),
_createBlock(
'svg',
{
width: 16,
height: 16,
viewBox: '0 0 16 16',
fill: 'currentColor',
xmlns: 'http://www.w3.org/2000/svg'
},
[
_createVNode('path', {
d: 'M11.5122 7.47229H7.45898V11.5255H11.5122V7.47229Z',
fill: 'currentFill'
}),
_createVNode('path', {
'fill-rule': 'evenodd',
'clip-rule': 'evenodd',
d: 'M4.48778 11.5078V13.2373C4.48778 13.5654 4.62082 13.8936 4.86029 14.1331C5.09976 14.3725 5.41905 14.5056 5.75608 14.5056H7.48557V15.49C7.48557 15.6231 7.53878 15.7561 7.63634 15.8537C7.83146 16.0488 8.16849 16.0488 8.36361 15.8537C8.46117 15.7561 8.51439 15.6319 8.51439 15.49V14.5056H10.4745V15.49C10.4745 15.6231 10.5277 15.7561 10.6253 15.8537C10.8204 16.0488 11.1574 16.0488 11.3525 15.8537C11.4501 15.7561 11.5033 15.6319 11.5033 15.49V14.5056H13.2328C13.561 14.5056 13.8891 14.3725 14.1286 14.1331C14.368 13.8936 14.5011 13.5743 14.5011 13.2373V11.5078H15.4856C15.6186 11.5078 15.7516 11.4546 15.8492 11.357C15.9468 11.2594 16 11.1264 16 10.9934C16 10.8603 15.9468 10.7273 15.8492 10.6297C15.7516 10.5322 15.6275 10.479 15.4856 10.479H14.5011V8.51887H15.4856C15.6186 8.51887 15.7516 8.46565 15.8492 8.36809C15.9468 8.27053 16 8.13749 16 8.00445C16 7.87142 15.9468 7.73838 15.8492 7.64082C15.7516 7.54326 15.6186 7.49004 15.4856 7.49004H14.5011V5.76055C14.5011 5.43239 14.368 5.10423 14.1286 4.86476C13.8891 4.6253 13.5698 4.49226 13.2328 4.49226H8.51439V3.50778C8.51439 3.37474 8.46117 3.24171 8.36361 3.14414C8.16849 2.94902 7.83146 2.94902 7.63634 3.14414C7.53878 3.24171 7.48557 3.36587 7.48557 3.50778V4.49226H5.75608C5.41905 4.49226 5.09976 4.6253 4.86029 4.86476C4.62969 5.09536 4.48778 5.42352 4.48778 5.76055V7.49004H3.5033C3.37027 7.49004 3.23723 7.54326 3.13967 7.64082C3.04211 7.73838 2.98889 7.87142 2.98889 8.00445C2.98889 8.13749 3.04211 8.27053 3.13967 8.36809C3.23723 8.46565 3.37027 8.51887 3.5033 8.51887H4.48778V11.5078ZM13.4634 13.4679H5.51661V5.52108H13.4634V13.4679Z',
fill: 'currentFill'
}),
_createVNode('path', {
'fill-rule': 'evenodd',
'clip-rule': 'evenodd',
d: 'M4.49667 10.4789H2.53659V2.53215H10.4834V4.49224H11.5122V2.76275C11.5122 2.43459 11.3792 2.10643 11.1397 1.86696C10.9091 1.63636 10.5809 1.49446 10.2439 1.49446H8.51441V0.509978C8.51441 0.37694 8.4612 0.243902 8.36364 0.146341C8.16851 -0.0487805 7.83148 -0.0487805 7.63636 0.146341C7.5388 0.243902 7.48559 0.368071 7.48559 0.509978V1.49446H5.5255V0.509978C5.5255 0.37694 5.47228 0.243902 5.37472 0.146341C5.1796 -0.0487805 4.84257 -0.0487805 4.64745 0.146341C4.54989 0.243902 4.49667 0.368071 4.49667 0.509978V1.49446H2.76718C2.43016 1.49446 2.11086 1.62749 1.8714 1.86696C1.6408 2.09756 1.49889 2.42572 1.49889 2.76275V4.49224H0.514412C0.381375 4.49224 0.248337 4.54545 0.150776 4.64301C0.0532151 4.74058 0 4.87361 0 5.00665C0 5.13969 0.0532151 5.27273 0.150776 5.37029C0.248337 5.46785 0.381375 5.52106 0.514412 5.52106H1.49889V7.48115H0.514412C0.381375 7.48115 0.248337 7.53437 0.150776 7.63193C0.0532151 7.72949 0 7.85366 0 7.99556C0 8.13747 0.0532151 8.26164 0.150776 8.3592C0.248337 8.45676 0.381375 8.50998 0.514412 8.50998H1.49889V10.2395C1.49889 10.5676 1.63193 10.8958 1.8714 11.1353C2.11086 11.3747 2.43016 11.5078 2.76718 11.5078H4.4878V10.4789H4.49667Z',
fill: 'currentFill'
})
]
)
)
}
247 changes: 247 additions & 0 deletions frontend/src/pages/team/DeviceGroups/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<template>
<ff-page>
<template #header>
<ff-page-header title="Groups">
<template #context>
Groups provide a way of managing multiple remote instances together, for example deploying to multiple remote Instances via a Pipeline.
</template>
<template #pictogram>
<img alt="info" src="../../../images/pictograms/device_group_red.png">
</template>
<template #helptext>
<p>Groups permit the grouping of Application assigned remote Instances.</p>
<p>Groups can then be set as the target in a DevOps Pipeline to update multiple instances in a single operation</p>
</template>
</ff-page-header>
</template>
<EmptyState
v-if="!featuresCheck.isDeviceGroupsFeatureEnabled"
:feature-unavailable-to-team="!featuresCheck.isDeviceGroupsFeatureEnabled"
>
<template #img>
<img src="../../../images/empty-states/application-device-groups.png" alt="logo">
</template>
<template #header>
<span>Groups Not Available</span>
</template>
<template #message>
<p>Groups permit the grouping of Application assigned remote Instances.</p>
<p>Groups can then be set as the target in a DevOps Pipeline to update multiple devices in a single operation</p>
cstns marked this conversation as resolved.
Show resolved Hide resolved
</template>
</EmptyState>

<template v-else>
<div id="team-device-groups" class="space-y-6">
<ff-loading v-if="loading" message="Loading Groups..." />

<template v-else>
<section v-if="deviceGroups.length > 0" class="pipelines">
<ff-data-table
v-model:search="tableSearch"
:columns="tableColumns"
:rows="deviceGroups"
:show-search="true"
search-placeholder="Filter..."
data-el="device-groups-table"
:rows-selectable="true"
@row-selected="goToGroup"
>
<template #actions>
<ff-button data-action="create-device-group" @click="showCreateDeviceGroupDialog">
<template #icon-left><PlusSmIcon /></template>
Add Device Group
</ff-button>
</template>
</ff-data-table>
</section>

<EmptyState v-else>
<template #img>
<img src="../../../images/empty-states/application-device-groups.png" alt="logo">
</template>
<template #header>Start building your Groups</template>
<template #message>
<p>Groups permit the grouping of Application assigned remote Instances.</p>
<p>Groups can then be set as the target in a DevOps Pipeline to update multiple devices in a single operation</p>
cstns marked this conversation as resolved.
Show resolved Hide resolved
</template>
<template #actions>
<ff-button class="center" @click="showCreateDeviceGroupDialog">Create Group</ff-button>
</template>
</EmptyState>
</template>
</div>
</template>
</ff-page>
<ff-dialog ref="create-dialog" class="ff-dialog-box--info" header="Create Group">
<template #default>
<slot name="helptext">
<p>Enter the name and description of the Device Group to create.</p>
</slot>
<div class="flex gap-4">
<div class="flex-grow">
<div class="form-row max-w-sm mb-2">
<label>
<span class="block mb-1">
Application
</span>
<ff-listbox
v-model="input.application"
:options="applicationOptions"
data-el="snapshots-list"
label-key="label"
option-title-key="description"
class="flex-grow w-full"
/>
</label>
</div>
<FormRow v-model="input.name" class="mb-2" :error="!input.name ? 'required' : ''" data-form="name">Name</FormRow>
<FormRow v-model="input.description" data-form="name">Description</FormRow>
</div>
</div>
</template>
<template #actions>
<ff-button kind="secondary" @click="$refs['create-dialog'].close()">Cancel</ff-button>
<ff-button kind="primary" @click="createDeviceGroup">Create</ff-button>
</template>
</ff-dialog>
</template>

<script>
import { PlusSmIcon } from '@heroicons/vue/outline'
import { markRaw } from 'vue'
import { mapGetters } from 'vuex'

import ApplicationAPI from '../../../api/application.js'

import teamApi from '../../../api/team.js'

import EmptyState from '../../../components/EmptyState.vue'
import FormRow from '../../../components/FormRow.vue'
import usePermissions from '../../../composables/Permissions.js'
import Alerts from '../../../services/alerts.js'
import FfButton from '../../../ui-components/components/Button.vue'
import FfListbox from '../../../ui-components/components/form/ListBox.vue'
import TargetSnapshotCell from '../../application/components/cells/TargetSnapshot.vue'

export default {
name: 'DeviceGroups',
components: {
PlusSmIcon,
FfListbox,
FormRow,
FfButton,
EmptyState
},
setup () {
const { hasPermission } = usePermissions()
return { hasPermission }
},
data () {
return {
loading: false,
tableSearch: '',
deviceGroups: [],
applications: [],
input: {
name: '',
description: '',
application: ''
},
tableColumns: [
{
label: 'Name',
key: 'name',
sortable: true,
class: 'w-1/4 whitespace-nowrap'
},
{
label: 'Application',
key: 'application.name',
sortable: true,
class: 'w-1/4 whitespace-nowrap'
},
{
label: 'Description',
key: 'description',
sortable: true,
class: 'w-1/3'
},
{
label: 'Target Snapshot',
key: 'description',
sortable: true,
class: 'w-full',
component: { is: markRaw(TargetSnapshotCell) }
},
{
label: 'Device count',
key: 'deviceCount',
sortable: true,
class: 'w-1/4 whitespace-nowrap'
}
]
}
},
computed: {
...mapGetters('account', ['featuresCheck', 'team']),
applicationOptions () {
return this.applications.map(app => ({ label: app.name, value: app.id }))
}
},
mounted () {
if (this.hasPermission('team:device-group:list')) {
this.loadTeamDeviceGroups()
}
},
methods: {
async showCreateDeviceGroupDialog () {
this.getApplications()
.then(() => this.$refs['create-dialog'].show())
.catch(e => e)
},
getApplications () {
return teamApi.getTeamApplications(this.team.id, { includeApplicationSummary: false })
.then((res) => {
this.applications = res.applications
})
.catch(e => e)
},
async loadTeamDeviceGroups () {
return teamApi.getTeamDeviceGroups(this.team.id)
.then(res => {
this.deviceGroups = res.groups
})
.catch(e => e)
},
async createDeviceGroup () {
if (!this.input.name) {
Alerts.emit('Device Group name is required', 'warning')
return
}
if (!this.input.application) {
Alerts.emit('An application is required', 'warning')
return
}

ApplicationAPI.createDeviceGroup(this.input.application, this.input.name, this.input.description)
.then((result) => {
this.$refs['create-dialog'].close()
this.loadTeamDeviceGroups()
})
.catch((err) => {
console.error(err)
Alerts.emit('Failed to create Device Group. Check the console for more details', 'error', 7500)
})
},
goToGroup (row) {
return this.$router.push({
name: 'ApplicationDeviceGroupIndex',
params: {
deviceGroupId: row.id,
applicationId: row.application.id
}
})
}
}
}
</script>
9 changes: 9 additions & 0 deletions frontend/src/pages/team/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import TeamApplications from './Applications/index.vue'
import TeamAuditLog from './AuditLog.vue'
import TeamBillOfMaterials from './BOM/index.vue'
import TeamBilling from './Billing.vue'
import DeviceGroups from './DeviceGroups/index.vue'
import TeamDevices from './Devices/index.vue'
import TeamInstances from './Instances.vue'
import Library from './Library/index.vue'
Expand Down Expand Up @@ -215,6 +216,14 @@ export default [
meta: {
title: 'Team - Bill of Materials'
}
},
{
name: 'device-groups',
path: 'device-groups',
component: DeviceGroups,
meta: {
title: 'Team - Remote Instances Groups'
}
}
]
},
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/store/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ const getters = {
isBOMFeatureEnabled: preCheck.isBOMFeatureEnabledForPlatform && preCheck.isBOMFeatureEnabledForTeam,
isTimelineFeatureEnabled: preCheck.isTimelineFeatureEnabledForPlatform && preCheck.isTimelineFeatureEnabledForTeam,
isMqttBrokerFeatureEnabled: preCheck.isMqttBrokerFeatureEnabledForPlatform && preCheck.isMqttBrokerFeatureEnabledForTeam,
devOpsPipelinesFeatureEnabled: preCheck.devOpsPipelinesFeatureEnabledForPlatform
devOpsPipelinesFeatureEnabled: preCheck.devOpsPipelinesFeatureEnabledForPlatform,
isDeviceGroupsFeatureEnabled: !!state.team?.type?.properties?.features?.deviceGroups
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/store/ux.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TableIcon, TemplateIcon, UserGroupIcon, UsersIcon
} from '@heroicons/vue/outline'

import DeviceGroupOutlineIcon from '../components/icons/DeviceGroupOutline.js'
import PipelinesIcon from '../components/icons/Pipelines.js'
import ProjectsIcon from '../components/icons/Projects.js'
import usePermissions from '../composables/Permissions.js'
Expand Down Expand Up @@ -83,6 +84,15 @@ const getters = {
title: 'Operations',
hidden: !hasAMinimumTeamRoleOf(Roles.Viewer),
entries: [
{
label: 'Groups',
to: { name: 'device-groups', params: { team_slug: team.slug } },
tag: 'device-groups',
icon: DeviceGroupOutlineIcon,
disabled: noBilling,
featureUnavailable: !features.isDeviceGroupsFeatureEnabled,
hidden: hasALowerOrEqualTeamRoleThan(Roles.Member)
},
{
label: 'Pipelines',
to: { name: 'team-pipelines', params: { team_slug: team.slug } },
Expand Down
Loading