Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions frontend/src/api/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ const getApplication = (applicationId) => {
})
}

/**
* Get the instances within an application that have a dashboard installed
* @param {string} applicationId
*/
const getApplicationDashboards = async (applicationId) => {
const res = await client.get(`/api/v1/applications/${applicationId}/dashboard-instances`)
res.data.projects = res.data.projects.map(r => {
r.createdSince = daysSince(r.createdAt)
r.updatedSince = daysSince(r.updatedAt)
r.flowLastUpdatedSince = daysSince(r.flowLastUpdatedAt)
return r
})
return res.data
}

/**
* Get the audit log for an application
* @param {string} applicationId
Expand Down Expand Up @@ -371,6 +386,7 @@ export default {
deleteApplication,
getSnapshots,
getApplication,
getApplicationDashboards,
getApplicationAuditLog,
getApplicationDevices,
getApplicationInstances,
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/dashboard/DashboardList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<template>
<div class="dashboards-list flex-1 flex flex-col overflow-auto" data-el="dashboards-list">
<ff-loading v-if="loading && instances.length === 0" message="Loading Dashboards..." />
<ff-data-table
v-else-if="instances.length"
data-el="dashboards-table"
:columns="columns"
:rows="instances"
:show-search="true"
search-placeholder="Search Dashboards..."
:rows-selectable="true"
@row-selected="openDashboard"
/>
<EmptyState v-else>
<template #img>
<img src="@/images/empty-states/team-instances.png">
</template>
<template #header>There are no dashboards {{ scope === 'application' ? 'in this application' : 'in this team' }}.</template>
<template #message>
<p>Dashboards appear here once an instance has the Dashboard installed.</p>
</template>
</EmptyState>
<template v-if="!statusChannelLive">
<InstanceStatusPolling
v-for="instance in instances"
:key="instance.id"
:instance="instance"
@instance-updated="instanceUpdated"
/>
</template>
</div>
</template>

<script setup lang="ts">
import { computed, markRaw, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'

import EmptyState from '@/components/EmptyState.vue'
import InstanceStatusPolling from '@/components/InstanceStatusPolling.vue'
import { useDashboardScope, useDashboards } from '@/composables/Dashboards'
import { useNavigationHelper } from '@/composables/NavigationHelper.js'
import ApplicationLink from '@/pages/application/components/cells/ApplicationLink.vue'
import DeploymentName from '@/pages/application/components/cells/DeploymentName.vue'
import SimpleTextCell from '@/pages/application/components/cells/SimpleTextCell.vue'
import InstanceStatusBadge from '@/pages/instance/components/InstanceStatusBadge.vue'

defineOptions({ name: 'DashboardList' })

const props = defineProps({
scope: {
type: String,
default: 'team'
}
})

const route = useRoute()
const { navigateTo } = useNavigationHelper()
const { context, fetch, viewerRouteName } = useDashboardScope(props.scope)
const { instances, loading, statusChannelLive, fetchData, instanceUpdated } = useDashboards(fetch)

const columns = computed(() => [
{ label: 'Name', class: ['grow'], key: 'name', sortable: true, component: { is: markRaw(DeploymentName), map: { url: 'url' }, extraProps: { copyable: true } } },
{ label: 'Status', class: ['w-44'], component: { is: markRaw(InstanceStatusBadge), map: { instanceId: 'id', status: 'status' }, extraProps: { instanceType: 'instance' } } },
...(props.scope !== 'application'
? [{ label: 'Application', class: ['grow-[0.25]'], key: 'application.name', sortable: true, component: { is: markRaw(ApplicationLink), map: { id: 'application.id', name: 'application.name' } } }]
: []),
{ label: 'Last Updated', class: ['w-60'], key: 'flowLastUpdatedAt', sortable: true, component: { is: markRaw(SimpleTextCell), map: { text: 'flowLastUpdatedSince' } } }
])

function openDashboard (instance: { id: string }, event: MouseEvent) {
navigateTo({ name: viewerRouteName, params: { ...route.params, instanceId: instance.id } }, event)
}

watch(context, fetchData)
onMounted(fetchData)
</script>
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
<template>
<div class="ff--immersive-editor-wrapper team-dashboards" :class="{ resizing: isResizing }" data-el="team-dashboards-viewer">
<div class="ff--immersive-editor-wrapper dashboards-viewer" :class="{ resizing: isResizing }" data-el="dashboards-viewer">
<DashboardDrawer :home-route="homeRoute" @resizing="v => isResizing = v">
<ff-loading v-if="loading && instances.length === 0" message="Loading Dashboards..." />
<ul v-else-if="instances.length" class="team-dashboards--list">
<ul v-else-if="instances.length" class="dashboards-viewer--list">
<li
v-for="instance in instances"
:key="instance.id"
class="team-dashboards--list-item"
class="dashboards-viewer--list-item"
:class="{ selected: instance.id === selectedId }"
data-el="dashboard-list-item"
@click="selectDashboard(instance.id)"
>
<span class="team-dashboards--list-item-name">{{ instance.name }}</span>
<span class="dashboards-viewer--list-item-name">{{ instance.name }}</span>
<InstanceStatusBadge :status="instance.status" :instanceId="instance.id" instanceType="instance" />
</li>
</ul>
</DashboardDrawer>

<div class="ff-layout--immersive--content team-dashboards--content">
<div class="ff-layout--immersive--content dashboards-viewer--content">
<DashboardView v-if="selectedInstance" :instance="selectedInstance" :disable-events="isResizing" />
<DrawerTrigger :is-hidden="drawerOpen" @toggle="drawersStore.toggleEditorImmersiveDrawer" />
</div>
Expand All @@ -41,43 +41,31 @@ import InstanceStatusPolling from '@/components/InstanceStatusPolling.vue'
import DashboardView from '@/components/dashboard/index.vue'
import DashboardDrawer from '@/components/drawers/dashboard/DashboardDrawer.vue'
import DrawerTrigger from '@/components/immersive-editor/DrawerTrigger.vue'
import { useTeamDashboards } from '@/composables/TeamDashboards'
import { useDashboardScope, useDashboards } from '@/composables/Dashboards'
import InstanceStatusBadge from '@/pages/instance/components/InstanceStatusBadge.vue'
import { useAccountStore } from '@/stores/account.js'
import { useContextStore } from '@/stores/context.js'
import { useUxDrawersStore } from '@/stores/ux-drawers.js'

defineOptions({ name: 'TeamDashboardViewer' })
defineOptions({ name: 'DashboardViewer' })

const route = useRoute()
const router = useRouter()
const contextStore = useContextStore()
const drawersStore = useUxDrawersStore()

const { instances, instancesMap, loading, statusChannelLive, fetchData, instanceUpdated } = useTeamDashboards()
const { context, fetch, viewerRouteName, homeRoute, ensureContext } = useDashboardScope(route.meta.scope as string)
const { instances, instancesMap, loading, statusChannelLive, fetchData, instanceUpdated } = useDashboards(fetch)

const selectedId = ref<string | null>(null)
const isResizing = ref(false)

const team = computed(() => contextStore.team)
const drawerOpen = computed(() => drawersStore.editorImmersiveDrawer.state)
const selectedInstance = computed(() => instancesMap.value.get(selectedId.value) || null)
const homeRoute = computed(() => {
if (!team.value) return null
return { name: 'team-dashboards', params: { team_slug: team.value.slug } }
})

function ensureTeam () {
const slug = route.params.team_slug
if (slug && contextStore.team?.slug !== slug) {
useAccountStore().setTeam(slug)
}
}

function selectDashboard (id) {
selectedId.value = id
if (route.params.instanceId !== id) {
router.replace({ name: 'team-dashboards-view', params: { ...route.params, instanceId: id } })
router.replace({ name: viewerRouteName, params: { ...route.params, instanceId: id } })
}
}

Expand All @@ -99,15 +87,16 @@ function resolveSelection () {
}

watch(instances, resolveSelection)
watch(() => route.params.team_slug, ensureTeam)
watch(context, fetchData)
watch(() => route.params, ensureContext, { deep: true })
watch(() => route.params.instanceId as string, id => {
if (id && instancesMap.value.has(id)) {
selectedId.value = id
}
})

onMounted(() => {
ensureTeam()
ensureContext()
contextStore.setIsImmersive(true)
fetchData()
})
Expand All @@ -118,7 +107,7 @@ onUnmounted(() => {
</script>

<style scoped>
.team-dashboards {
.dashboards-viewer {
display: flex;
flex-direction: row;
flex: 1;
Expand All @@ -127,21 +116,21 @@ onUnmounted(() => {
overflow: hidden;
}

.team-dashboards--content {
.dashboards-viewer--content {
flex: 1;
min-width: 0;
height: 100%;
position: relative;
}

.team-dashboards--list {
.dashboards-viewer--list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px;
}

.team-dashboards--list-item {
.dashboards-viewer--list-item {
display: flex;
align-items: center;
justify-content: space-between;
Expand All @@ -161,12 +150,12 @@ onUnmounted(() => {
background: var(--ff-color-bg-surface-raised);
}

&.selected .team-dashboards--list-item-name {
&.selected .dashboards-viewer--list-item-name {
font-weight: 600;
}
}

.team-dashboards--list-item-name {
.dashboards-viewer--list-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'

import applicationApi from '@/api/application.js'
import teamApi from '@/api/team.js'
import Alerts from '@/services/alerts.js'
import { useAccountStore } from '@/stores/account.js'
import { useContextStore } from '@/stores/context.js'
import { useLiveStatusStore } from '@/stores/live-status'
import { applyLiveState } from '@/utils/applyLiveState.js'
Expand All @@ -14,14 +17,14 @@ interface DashboardInstance {
[key: string]: unknown
}

export function useTeamDashboards () {
const contextStore = useContextStore()
type DashboardsResponse = { projects: DashboardInstance[] }

export function useDashboards (fetchProjects: () => Promise<DashboardsResponse> | null) {
const liveStatusStore = useLiveStatusStore()

const loading = ref(false)
const instancesMap = ref<Map<string, DashboardInstance>>(new Map())

const team = computed(() => contextStore.team)
const statusChannelLive = computed(() => liveStatusStore.live)
const liveInstanceMetadata = computed(() => liveStatusStore.instanceMetadata)
const instances = computed(() => Array.from(instancesMap.value.values()))
Expand Down Expand Up @@ -49,13 +52,11 @@ export function useTeamDashboards () {
}

async function fetchData () {
if (!team.value?.id) {
loading.value = false
return
}
const request = fetchProjects()
if (!request) return
loading.value = true
try {
const response = await teamApi.getTeamDashboards(team.value.id)
const response = await request
const projects = response?.projects || []
const nextMap = new Map<string, DashboardInstance>()
projects.forEach((instance: DashboardInstance) => nextMap.set(instance.id, seedMeta(instance)))
Expand All @@ -80,8 +81,58 @@ export function useTeamDashboards () {
})
}

watch(team, fetchData)
watch(liveInstanceMetadata, applyLiveStatus, { deep: true })

return { instances, instancesMap, loading, statusChannelLive, fetchData, instanceUpdated }
}

export function useDashboardScope (scope: string) {
const contextStore = useContextStore()
const route = useRoute()

if (scope === 'application') {
return {
context: computed(() => contextStore.application),
fetch: () => {
const id = contextStore.application?.id
return id ? applicationApi.getApplicationDashboards(id) : null
},
viewerRouteName: 'application-dashboards-view',
homeRoute: computed(() => {
const app = contextStore.application
if (!app) return null
return { name: 'ApplicationDashboards', params: { team_slug: route.params.team_slug, id: app.id } }
}),
ensureContext: () => {
const slug = route.params.team_slug as string
if (slug && contextStore.team?.slug !== slug) {
useAccountStore().setTeam(slug)
}
const id = route.params.id as string
if (id && contextStore.application?.id !== id) {
return applicationApi.getApplication(id).then(app => contextStore.setApplication(app))
}
}
}
}

return {
context: computed(() => contextStore.team),
fetch: () => {
const id = contextStore.team?.id
return id ? teamApi.getTeamDashboards(id) : null
},
viewerRouteName: 'team-dashboards-view',
homeRoute: computed(() => {
const team = contextStore.team
if (!team) return null
return { name: 'team-dashboards', params: { team_slug: team.slug } }
}),
ensureContext: () => {
const slug = route.params.team_slug as string
if (slug && contextStore.team?.slug !== slug) {
useAccountStore().setTeam(slug)
}
}
}
}
11 changes: 11 additions & 0 deletions frontend/src/pages/application/Dashboards.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<template>
<div class="flex-1 flex flex-col" data-el="application-dashboards">
<DashboardList scope="application" />
</div>
</template>

<script setup>
import DashboardList from '@/components/dashboard/DashboardList.vue'
defineOptions({ name: 'ApplicationDashboards' })
</script>
8 changes: 7 additions & 1 deletion frontend/src/pages/application/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default {
},
computed: {
...mapState(useContextStore, ['team']),
...mapState(useAccountSettingsStore, ['features']),
...mapState(useAccountSettingsStore, ['features', 'featuresCheck']),
...mapState(useLiveStatusStore, { liveInstanceMetadata: 'instanceMetadata', statusChannelLive: 'live' }),
navigation () {
const routes = [
Expand All @@ -81,6 +81,12 @@ export default {
tag: 'application-devices-overview'
// icon: CpuChipIcon
},
{
label: 'Dashboards',
to: { name: 'ApplicationDashboards' },
tag: 'application-dashboards',
hidden: !this.featuresCheck?.isEmbeddedDashboardEnabled
},
{
label: 'Device Groups',
to: { name: 'ApplicationDeviceGroups' },
Expand Down
Loading
Loading