feat(xo-lite): add vdi page (#10269)

This commit is contained in:
Alina D
2026-08-21 17:31:45 +02:00
committed by GitHub
parent 8b4505a2d5
commit 9ef53890b8
15 changed files with 448 additions and 1 deletions

View File

@@ -3,6 +3,7 @@
## **next**
- [Pool/networks] Add the possibility to create new network or bonded network (PR [#10145](https://github.com/vatesfr/xen-orchestra/pull/10145))
- [VM] Add VDIs page with table and side panel (PR [#10269](https://github.com/vatesfr/xen-orchestra/pull/10269))
- [Host/Network] Add ability to rescan physical network interfaces (PIFs) (PR [#10147](https://github.com/vatesfr/xen-orchestra/pull/10147))
- Fix inconsistent spacing in side panel cards (PR [#10279](https://github.com/vatesfr/xen-orchestra/pull/10279))
- Fix missing collapse buttons on pools and hosts in the tree sidebar (added `hasChildren` prop) (PR [#10244](https://github.com/vatesfr/xen-orchestra/pull/10244))

View File

@@ -18,6 +18,9 @@
<RouterTab :to="{ name: '/vm/[uuid]/network', params: { uuid } }">
{{ t('network') }}
</RouterTab>
<RouterTab :to="{ name: '/vm/[uuid]/vdis', params: { uuid } }">
{{ t('vdis') }}
</RouterTab>
<RouterTab :to="{ name: '/vm/[uuid]/tasks', params: { uuid } }" disabled>
{{ t('tasks') }}
</RouterTab>

View File

@@ -48,8 +48,8 @@
</template>
<script lang="ts" setup>
import { getVdiIcon, getVbdsForVdi } from '@/libs/vdi.ts'
import type { XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { getVdiIcon, getVbdsForVdi } from '@/modules/vdi/utils/vdi.util.ts'
import { useVbdStore } from '@/stores/xen-api/vbd.store.ts'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'

View File

@@ -0,0 +1,29 @@
import type { XenApiVbd } from '@/libs/xen-api/xen-api.types.ts'
import { useVbdStore } from '@/stores/xen-api/vbd.store.ts'
import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
export type VbdAttachmentStatus = 'allAttached' | 'someAttached' | 'noneAttached'
export function useVbdsStatus(vbdRefs: MaybeRefOrGetter<XenApiVbd['$ref'][]>): ComputedRef<VbdAttachmentStatus> {
const { getByOpaqueRefs: getVbdsByOpaqueRefs } = useVbdStore().subscribe()
return computed(() => {
const vbds = getVbdsByOpaqueRefs(toValue(vbdRefs))
if (vbds.length === 0) {
return 'noneAttached'
}
const areAttached = vbds.map(vbd => vbd.currently_attached)
if (areAttached.every(Boolean)) {
return 'allAttached'
}
if (areAttached.some(Boolean)) {
return 'someAttached'
}
return 'noneAttached'
})
}

View File

@@ -0,0 +1,95 @@
<template>
<div class="vdis-table">
<UiTitle>
{{ t('vdis') }}
</UiTitle>
<UiQuerySearchBar @search="(value: string) => (searchQuery = value)" />
<div class="container">
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="vdi of paginatedVdis" :key="vdi.uuid" :selected="selectedVdiId === vdi.uuid">
<BodyCells :item="vdi" />
</VtsRow>
</tbody>
</VtsTable>
</div>
</div>
</template>
<script lang="ts" setup>
import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { getVdiFormat, getVdiIcon } from '@/modules/vdi/utils/vdi.util.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiQuerySearchBar from '@core/components/ui/query-search-bar/UiQuerySearchBar.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { usePagination } from '@core/composables/pagination.composable.ts'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useTableState } from '@core/composables/table-state.composable.ts'
import { useVdiColumns } from '@core/tables/column-sets/vdi-columns.ts'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { vdis, vbds, busy, error } = defineProps<{
vdis: XenApiVdi[]
vbds: XenApiVbd[]
busy?: boolean
error?: boolean
}>()
const { t } = useI18n()
const selectedVdiId = useRouteQuery('id')
const searchQuery = ref('')
const filteredVdis = computed(() => {
const searchTerm = searchQuery.value.trim().toLocaleLowerCase()
if (!searchTerm) {
return vdis
}
return vdis.filter(vdi => Object.values(vdi).some(value => String(value).toLocaleLowerCase().includes(searchTerm)))
})
const state = useTableState({
busy: () => busy,
error: () => error,
empty: () =>
vdis.length === 0 ? t('no-vdi-detected') : filteredVdis.value.length === 0 ? { type: 'no-result' } : false,
})
const { pageRecords: paginatedVdis, paginationBindings } = usePagination('vdis', filteredVdis)
const { HeadCells, BodyCells } = useVdiColumns({
exclude: ['actions'],
body: (vdi: XenApiVdi) => {
const vdiVbds = computed(() => vbds.filter(vbd => vbd.VDI === vdi.$ref))
const size = computed(() => formatSizeRaw(vdi.virtual_size, 2))
const format = computed(() => getVdiFormat(vdi.sm_config['image-format']))
return {
vdi: r =>
r({
label: vdi.name_label,
icon: getVdiIcon(vdiVbds.value),
}),
description: r => r(vdi.name_description),
usedSpace: r => r(vdi.physical_utilisation, vdi.virtual_size),
size: r => r(size.value.value, size.value.prefix),
format: r => r(format.value),
selectItem: r => r(() => (selectedVdiId.value = vdi.uuid)),
}
},
})
</script>
<style scoped lang="postcss">
.vdis-table,
.container {
display: flex;
flex-direction: column;
}
.vdis-table {
gap: 2.4rem;
.container {
gap: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<VtsSidePanel :has-selection="!!vdi" @close="emit('close')">
<template v-if="vdi" #default>
<VdiInfosCard :vdi :vbd />
<VdiSpaceCard :vdi />
<VdiConfigurationCard :vdi :vbd />
</template>
</VtsSidePanel>
</template>
<script setup lang="ts">
import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import VdiConfigurationCard from '@/modules/vdi/components/list/panel/cards/VdiConfigurationCard.vue'
import VdiInfosCard from '@/modules/vdi/components/list/panel/cards/VdiInfosCard.vue'
import VdiSpaceCard from '@/modules/vdi/components/list/panel/cards/VdiSpaceCard.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
defineProps<{
vdi?: XenApiVdi
vbd?: XenApiVbd
}>()
const emit = defineEmits<{
close: []
}>()
</script>

View File

@@ -0,0 +1,25 @@
<template>
<VtsCardRowKeyValue>
<template #key>
{{ t('format') }}
</template>
<template #value>{{ format }}</template>
<template v-if="rawFormat" #addons>
<VtsCopyButton :value="format" />
</template>
</VtsCardRowKeyValue>
</template>
<script setup lang="ts">
import { getVdiFormat } from '@/modules/vdi/utils/vdi.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { format: rawFormat } = defineProps<{ format?: string }>()
const { t } = useI18n()
const format = computed(() => getVdiFormat(rawFormat))
</script>

View File

@@ -0,0 +1,89 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
{{ t('configuration') }}
</UiCardTitle>
<div class="content">
<VdiFormatCardItem :format="vdi.sm_config['image-format']" />
<VtsCardRowKeyValue>
<template #key>
{{ t('storage') }}
</template>
<template #value>
<div v-if="vdiSr" class="storage">
<!-- TODO: add the `to` prop once the SR page exists in XO Lite -->
<UiLink size="small" icon="object:sr">
{{ vdiSr.name_label }}
</UiLink>
</div>
</template>
<template v-if="vdiSr" #addons>
<VtsCopyButton :value="vdiSr.name_label" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>
{{ t('read-only') }}
</template>
<template #value>
<VtsStatus :status="isReadOnly" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>
{{ t('change-block-tracking') }}
</template>
<template #value>
<VtsStatus :status="vdi.cbt_enabled" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>
{{ t('bootable') }}
</template>
<template #value>
<VtsStatus :status="isBootable" />
</template>
</VtsCardRowKeyValue>
</div>
</UiCard>
</template>
<script setup lang="ts">
import { VBD_MODE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import VdiFormatCardItem from '@/modules/vdi/components/list/panel/card-items/VdiFormatCardItem.vue'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { vdi, vbd } = defineProps<{
vdi: XenApiVdi
vbd?: XenApiVbd
}>()
const { t } = useI18n()
const { getByOpaqueRef: getSrByOpaqueRef } = useSrStore().subscribe()
const vdiSr = computed(() => getSrByOpaqueRef(vdi.SR))
const isReadOnly = computed(() => vbd?.mode === VBD_MODE.RO)
const isBootable = computed(() => vbd?.bootable ?? false)
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.storage {
display: flex;
gap: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<UiCard class="card-container">
<VtsCardObjectTitle :id="vdi.uuid" :label="vdi.name_label" :icon="vdiIcon" />
<div class="content">
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
<template #value>{{ vdi.name_description }}</template>
<template v-if="vdi.name_description" #addons>
<VtsCopyButton :value="vdi.name_description" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue align-top>
<template #key>{{ t('tags') }}</template>
<template #value>
<UiTagsList v-if="vdi.tags.length > 0">
<VtsTag v-for="tag in vdi.tags" :key="tag" :value="tag" />
</UiTagsList>
</template>
<template v-if="vdi.tags.length > 0" #addons>
<VtsCopyButton :value="vdi.tags.join(', ')" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('status') }}</template>
<template #value>
<VtsStatus :status="vbdsStatus" />
</template>
<template #addons>
<VtsCopyButton :value="vbdsStatus" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('device') }}</template>
<template #value>{{ vbd?.device }}</template>
<template v-if="vbd?.device" #addons>
<VtsCopyButton :value="vbd.device" />
</template>
</VtsCardRowKeyValue>
</div>
</UiCard>
</template>
<script lang="ts" setup>
import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { useVbdsStatus, type VbdAttachmentStatus } from '@/modules/vbd/composables/use-vbds-status.composable.ts'
import type { IconName } from '@core/icons'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardObjectTitle from '@core/components/card-object-title/VtsCardObjectTitle.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useMapper } from '@core/packages/mapper'
import { CONNECTION_STATUS } from '@core/types/connection.ts'
import { useI18n } from 'vue-i18n'
const { vdi } = defineProps<{
vdi: XenApiVdi
vbd?: XenApiVbd
}>()
const { t } = useI18n()
const vbdsAttachmentStatus = useVbdsStatus(() => vdi.VBDs)
const vdiIcon = useMapper<VbdAttachmentStatus, IconName>(
() => vbdsAttachmentStatus.value,
{
allAttached: 'object:vdi:attached',
someAttached: 'object:vdi:warning',
noneAttached: 'object:vdi:detached',
},
'noneAttached'
)
const vbdsStatus = useMapper<VbdAttachmentStatus, (typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS]>(
() => vbdsAttachmentStatus.value,
{
allAttached: CONNECTION_STATUS.CONNECTED,
someAttached: CONNECTION_STATUS.PARTIALLY_CONNECTED,
noneAttached: CONNECTION_STATUS.DISCONNECTED,
},
'noneAttached'
)
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<VtsSpaceCard :used="vdi.physical_utilisation" :total="vdi.virtual_size" :label="vdi.name_label" />
</template>
<script setup lang="ts">
import type { XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import VtsSpaceCard from '@core/components/space-card/VtsSpaceCard.vue'
defineProps<{
vdi: XenApiVdi
}>()
</script>

View File

@@ -19,3 +19,7 @@ export function getVbdsForVdi(
): XenApiVbd[] {
return vdi.VBDs.map(ref => getVbdByOpaqueRef(ref)).filter((vbd): vbd is XenApiVbd => vbd !== undefined)
}
export function getVdiFormat(format: string | undefined): string {
return format !== undefined ? format.toUpperCase() : 'VHD'
}

View File

@@ -0,0 +1,55 @@
<template>
<VtsContentSidePanel class="vdis">
<UiCard class="container">
<VdisTable v-if="vm" :vdis :vbds />
</UiCard>
<VdiSidePanel :vdi="selectedVdi" :vbd="selectedVbd" @close="selectedVdi = undefined" />
</VtsContentSidePanel>
</template>
<script lang="ts" setup>
import type { XenApiVdi, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import VdiSidePanel from '@/modules/vdi/components/list/panel/VdiSidePanel.vue'
import VdisTable from '@/modules/vdi/components/list/VdisTable.vue'
import { usePageTitleStore } from '@/stores/page-title.store'
import { useVbdStore } from '@/stores/xen-api/vbd.store'
import { useVdiStore } from '@/stores/xen-api/vdi.store'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import { useRouteQuery } from '@core/composables/route-query.composable'
import { useArrayFilter } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { records: vbdRecords } = useVbdStore().subscribe()
const { getByOpaqueRefs: getVdis } = useVdiStore().subscribe()
const { getByUuid } = useVmStore().subscribe()
const route = useRoute<'/vm/[uuid]/vdis'>()
const { t } = useI18n()
usePageTitleStore().setTitle(t('vdis'))
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm['uuid']))
const vbds = useArrayFilter(vbdRecords, vbd => vbd.VM === vm.value?.$ref && vbd.type === 'Disk')
const vdis = computed(() => getVdis(vbds.value.map(vbd => vbd.VDI)))
const selectedVdi = useRouteQuery<XenApiVdi | undefined>('id', {
toData: id => vdis.value.find(vdi => vdi.uuid === id),
toQuery: vdi => vdi?.uuid ?? '',
})
const selectedVbd = computed(() => vbds.value.find(vbd => vbd.VDI === selectedVdi.value?.$ref))
</script>
<style lang="postcss" scoped>
.vdis {
.container {
height: fit-content;
margin: 0.8rem;
gap: 4rem;
}
}
</style>

View File

@@ -980,6 +980,7 @@ declare module 'vue-router/auto-routes' {
| '/vm/[uuid]/stats'
| '/vm/[uuid]/system'
| '/vm/[uuid]/tasks'
| '/vm/[uuid]/vdis'
>,
'/vm/[uuid]/alarms': RouteRecordInfo<
'/vm/[uuid]/alarms',
@@ -1030,6 +1031,13 @@ declare module 'vue-router/auto-routes' {
{ uuid: ParamValue<false> },
| never
>,
'/vm/[uuid]/vdis': RouteRecordInfo<
'/vm/[uuid]/vdis',
'/vm/:uuid/vdis',
{ uuid: ParamValue<true> },
{ uuid: ParamValue<false> },
| never
>,
'/vm/new': RouteRecordInfo<
'/vm/new',
'/vm/new',
@@ -1875,6 +1883,7 @@ declare module 'vue-router/auto-routes' {
| '/vm/[uuid]/stats'
| '/vm/[uuid]/system'
| '/vm/[uuid]/tasks'
| '/vm/[uuid]/vdis'
views:
| 'default'
}
@@ -1920,6 +1929,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/vm/[uuid]/vdis.vue': {
routes:
| '/vm/[uuid]/vdis'
views:
| never
}
'src/pages/vm/new.vue': {
routes:
| '/vm/new'

View File

@@ -4,6 +4,7 @@ import { useLinkColumn } from '@core/tables/column-definitions/link-column.ts'
import { useLiteralColumn } from '@core/tables/column-definitions/literal-column.ts'
import { useNumberColumn } from '@core/tables/column-definitions/number-column.ts'
import { useProgressBarColumn } from '@core/tables/column-definitions/progress-bar-column.ts'
import { useSelectItemColumn } from '@core/tables/column-definitions/select-item-column.ts'
import { useTruncatedTextColumn } from '@core/tables/column-definitions/truncated-text-column.ts'
import { useI18n } from 'vue-i18n'
@@ -17,5 +18,6 @@ export const useVdiColumns = defineColumns(() => {
size: useNumberColumn({ headerLabel: () => t('size') }),
format: useLiteralColumn({ headerLabel: () => t('format') }),
actions: useActionColumn({}),
selectItem: useSelectItemColumn(),
}
})

View File

@@ -89,6 +89,7 @@ const state = useTableState({
const { pageRecords: paginatedVdis, paginationBindings } = usePagination('vdis', filteredVdis)
const { HeadCells, BodyCells } = useVdiColumns({
exclude: ['selectItem'],
body: (vdi: FrontXoVdi) => {
const vbds = useGetVbdsByIds(vdi.$VBDs)