feat(lite): add storage tabs and read-only SR views (#10005)

This commit is contained in:
Sylvère
2026-06-29 13:31:52 +02:00
committed by GitHub
parent 91badc593c
commit 276c4e5f7e
26 changed files with 1004 additions and 91 deletions

View File

@@ -11,6 +11,7 @@
- [XOA deploy] Update log visualization component (PR [#9995](https://github.com/vatesfr/xen-orchestra/pull/9995))
- [SidePanels] Add and use new `VtsCardObjectTitle` component to display object title and ID in side panels (PR [#9755](https://github.com/vatesfr/xen-orchestra/pull/9755))
- Replacement of the UiSpinner component with UiLoader from web-core (PR [#10023](https://github.com/vatesfr/xen-orchestra/pull/10023))
- [Pool,Host/Storage] Add Storage tabs (PR [#10005](https://github.com/vatesfr/xen-orchestra/pull/10005))
## **0.22.0** (2026-05-28)

View File

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

View File

@@ -15,7 +15,7 @@
<RouterTab :to="{ name: '/pool/[uuid]/network', params: { uuid: pool?.uuid ?? '-' } }">
{{ t('network') }}
</RouterTab>
<RouterTab :to="{ name: '/pool/[uuid]/storage', params: { uuid: pool?.uuid ?? '-' } }" disabled>
<RouterTab :to="{ name: '/pool/[uuid]/storage', params: { uuid: pool?.uuid ?? '-' } }">
{{ t('storage') }}
</RouterTab>
<RouterTab :to="{ name: '/pool/[uuid]/tasks', params: { uuid: pool?.uuid ?? '-' } }">
@@ -32,7 +32,7 @@
<script lang="ts" setup>
import RouterTab from '@/components/RouterTab.vue'
import { usePoolStore } from '@/stores/xen-api/pool.store'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import TabList from '@core/components/tab/TabList.vue'
import { useI18n } from 'vue-i18n'

View File

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

View File

@@ -0,0 +1,33 @@
export const ALLOCATION_BY_SR_TYPE = {
ext: 'thin',
file: 'thin',
hba: 'thick',
iscsi: 'thick',
lvhd: 'thick',
lvhdofcoe: 'thick',
lvhdohba: 'thick',
lvhdoiscsi: 'thick',
lvm: 'thick',
lvmofcoe: 'thick',
lvmohba: 'thick',
lvmoiscsi: 'thick',
nfs: 'thin',
ocfs: 'thick',
ocfsohba: 'thick',
ocfsoiscsi: 'thick',
rawhba: 'thick',
rawiscsi: 'thick',
shm: 'thin',
smb: 'thin',
udev: 'thick',
zfs: 'thin',
} as const
export type SrAllocationStrategy = 'thin' | 'thick' | 'unknown'
export function getAllocationStrategy(srType: string, pbdProvisioning?: string): SrAllocationStrategy | undefined {
if (srType === 'linstor') {
return (pbdProvisioning as SrAllocationStrategy) ?? undefined
}
return ALLOCATION_BY_SR_TYPE[srType as keyof typeof ALLOCATION_BY_SR_TYPE]
}

View File

@@ -0,0 +1,21 @@
import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { type IconName, objectIcon } from '@core/icons'
export function getVdiIcon(vbds: XenApiVbd[]): IconName {
if (vbds.length === 0 || vbds.every(vbd => !vbd.currently_attached)) {
return objectIcon('vdi', 'detached')
}
if (vbds.every(vbd => vbd.currently_attached)) {
return objectIcon('vdi', 'attached')
}
return objectIcon('vdi', 'warning')
}
export function getVbdsForVdi(
vdi: XenApiVdi,
getVbdByOpaqueRef: (ref: XenApiVbd['$ref']) => XenApiVbd | undefined
): XenApiVbd[] {
return vdi.VBDs.map(ref => getVbdByOpaqueRef(ref)).filter((vbd): vbd is XenApiVbd => vbd !== undefined)
}

View File

@@ -42,8 +42,8 @@ import type {
VMSS_TYPE,
VTPM_OPERATION,
VUSB_OPERATION,
} from '@/libs/xen-api/xen-api.enums'
import type { XEN_API_OBJECT_TYPES } from '@/libs/xen-api/xen-api.utils'
} from '@/libs/xen-api/xen-api.enums.ts'
import type { XEN_API_OBJECT_TYPES } from '@/libs/xen-api/xen-api.utils.ts'
import type { OPAQUE_REF_NULL } from '@vates/types'
type TypeMapping = typeof XEN_API_OBJECT_TYPES
@@ -167,7 +167,10 @@ export interface XenApiHost extends XenApiRecord<'host'> {
export interface XenApiSr extends XenApiRecord<'sr'> {
content_type: string
name_description: string
name_label: string
other_config: Record<string, string>
tags: string[]
VDIs: XenApiVdi['$ref'][]
PBDs: XenApiPbd['$ref'][]
physical_size: number
@@ -182,6 +185,7 @@ export interface XenApiSr extends XenApiRecord<'sr'> {
export interface XenApiPbd extends XenApiRecord<'pbd'> {
SR: XenApiSr['$ref']
currently_attached: boolean
device_config: Record<string, string>
host: XenApiHost['$ref']
}

View File

@@ -0,0 +1,139 @@
<template>
<div class="storage-repositories-table">
<UiTitle>
{{ t('storage-repositories') }}
</UiTitle>
<div class="container">
<div class="table-actions">
<UiQuerySearchBar @search="value => (searchQuery = value)" />
</div>
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="sr of paginatedSrs" :key="sr.uuid" :selected="selectedSrId === sr.uuid">
<BodyCells :item="sr" />
</VtsRow>
</tbody>
</VtsTable>
</div>
</div>
</template>
<script setup lang="ts">
import type { XenApiPool, XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { usePbdUtils } from '@/modules/storage-repository/composables/pbd-utils.composable.ts'
import { usePbdStore } from '@/stores/xen-api/pbd.store.ts'
import { useSrStore } from '@/stores/xen-api/sr.store.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 { icon, objectIcon } from '@core/icons'
import { useSrColumns } from '@core/tables/column-sets/sr-columns.ts'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const {
srs: rawSrs,
pool,
busy,
error,
} = defineProps<{
srs: XenApiSr[]
pool: XenApiPool
busy?: boolean
error?: boolean
}>()
const { t } = useI18n()
const { isReady, hasError, isDefaultSr } = useSrStore().subscribe()
const { getPbdsForSr } = usePbdStore().subscribe()
const selectedSrId = useRouteQuery('id')
const searchQuery = ref('')
const filteredSrs = computed(() => {
const searchTerm = searchQuery.value.trim().toLocaleLowerCase()
if (!searchTerm) {
return rawSrs
}
return rawSrs.filter(sr => Object.values(sr).some(value => String(value).toLocaleLowerCase().includes(searchTerm)))
})
const state = useTableState({
busy: () => busy ?? !isReady.value,
error: () => error ?? hasError.value,
empty: () => {
if (rawSrs.length === 0) {
return t('no-storage-repository-detected')
}
if (filteredSrs.value.length === 0) {
return { type: 'no-result' }
}
return false
},
})
const { pageRecords: paginatedSrs, paginationBindings } = usePagination('srs', filteredSrs)
function getPrimaryIcon(sr: XenApiSr) {
if (!isDefaultSr(sr, pool)) {
return undefined
}
return {
icon: icon('status:primary-circle'),
tooltip: t('default-storage-repository'),
}
}
const { HeadCells, BodyCells } = useSrColumns({
body: (sr: XenApiSr) => {
const rightIcon = computed(() => getPrimaryIcon(sr))
const { allPbdsConnectionStatus } = usePbdUtils(() => getPbdsForSr(sr.$ref))
return {
storageRepository: r =>
r({
label: sr.name_label,
icon: objectIcon('sr', allPbdsConnectionStatus.value),
rightIcon: rightIcon.value,
}),
description: r => r(sr.name_description),
storageFormat: r => r(sr.type),
accessMode: r => r(sr.shared ? t('shared') : t('local')),
usedSpace: r => r(sr.physical_utilisation, sr.physical_size),
actions: r => r({ onClick: () => (selectedSrId.value = sr.uuid) }),
}
},
})
</script>
<style scoped lang="postcss">
.storage-repositories-table {
display: flex;
flex-direction: column;
gap: 2.4rem;
.container,
.table-actions {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<VtsSidePanel :has-selection="!!sr" @close="emit('close')">
<template v-if="sr">
<VtsStateHero v-if="!isReady" format="panel" type="busy" size="medium" />
<template v-else>
<StorageRepositoryInfosCard :pool :sr />
<StorageRepositorySpaceCard :sr />
<StorageRepositoryVdisCard :vdis :vdi-snapshots />
<StorageRepositoryHostsCard :hosts />
<StorageRepositoryPbdsCard :pbds />
<StorageRepositoryCustomFieldsCard :custom-fields />
</template>
</template>
</VtsSidePanel>
</template>
<script setup lang="ts">
import type { XenApiHost, XenApiPool, XenApiSr, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import StorageRepositoryCustomFieldsCard from '@/modules/storage-repository/components/list/panel/cards/StorageRepositoryCustomFieldsCard.vue'
import StorageRepositoryHostsCard from '@/modules/storage-repository/components/list/panel/cards/StorageRepositoryHostsCard.vue'
import StorageRepositoryInfosCard from '@/modules/storage-repository/components/list/panel/cards/StorageRepositoryInfosCard.vue'
import StorageRepositoryPbdsCard from '@/modules/storage-repository/components/list/panel/cards/StorageRepositoryPbdsCard.vue'
import StorageRepositorySpaceCard from '@/modules/storage-repository/components/list/panel/cards/StorageRepositorySpaceCard.vue'
import StorageRepositoryVdisCard from '@/modules/storage-repository/components/list/panel/cards/StorageRepositoryVdisCard.vue'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import { usePbdStore } from '@/stores/xen-api/pbd.store.ts'
import { useVdiStore } from '@/stores/xen-api/vdi.store.ts'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
const { sr, pool } = defineProps<{
sr?: XenApiSr
pool: XenApiPool
}>()
const emit = defineEmits<{
close: []
}>()
const { getByOpaqueRef: getVdiByOpaqueRef, isReady: areVdisReady } = useVdiStore().subscribe()
const { getByOpaqueRef: getHostByOpaqueRef, isReady: areHostsReady } = useHostStore().subscribe()
const { getPbdsForSr, isReady: arePbdsReady } = usePbdStore().subscribe()
const isReady = logicAnd(areVdisReady, areHostsReady, arePbdsReady)
const allVdis = computed(() => {
if (sr === undefined) {
return []
}
return sr.VDIs.map(vdiRef => getVdiByOpaqueRef(vdiRef)).filter((vdi): vdi is XenApiVdi => vdi !== undefined)
})
const vdis = computed(() => allVdis.value.filter(vdi => !vdi.is_a_snapshot))
const vdiSnapshots = computed(() => allVdis.value.filter(vdi => vdi.is_a_snapshot))
const pbds = computed(() => {
if (sr === undefined) {
return []
}
return getPbdsForSr(sr.$ref)
})
const hosts = computed(() =>
pbds.value.map(pbd => getHostByOpaqueRef(pbd.host)).filter((host): host is XenApiHost => host !== undefined)
)
const customFields = computed(() => {
if (sr === undefined) {
return {}
}
const prefix = 'XenCenter.CustomFields.'
return Object.entries(sr.other_config).reduce<Record<string, unknown>>((acc, [key, value]) => {
if (key.startsWith(prefix)) {
acc[key.slice(prefix.length)] = value
}
return acc
}, {})
})
</script>

View File

@@ -0,0 +1,36 @@
<template>
<VtsCardRowKeyValue>
<template #key>
{{ t('host') }}
</template>
<template #value>
<UiLink
v-if="host !== undefined"
size="small"
icon="object:host"
:to="{ name: '/host/[uuid]/dashboard', params: { uuid: host.uuid } }"
>
{{ host.name_label }}
</UiLink>
</template>
</VtsCardRowKeyValue>
</template>
<script setup lang="ts">
import type { XenApiPbd } from '@/libs/xen-api/xen-api.types.ts'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { pbd } = defineProps<{
pbd: XenApiPbd
}>()
const { t } = useI18n()
const { getByOpaqueRef: getHostByOpaqueRef } = useHostStore().subscribe()
const host = computed(() => getHostByOpaqueRef(pbd.host))
</script>

View File

@@ -0,0 +1,45 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
{{ t('custom-fields') }}
</UiCardTitle>
<div class="content">
<VtsStateHero
v-if="Object.keys(customFields).length === 0"
type="no-data"
format="card"
horizontal
size="extra-small"
>
{{ t('no-custom-field-detected') }}
</VtsStateHero>
<VtsLabelValueList v-else :fields="customFields" />
</div>
</UiCard>
</template>
<script lang="ts" setup>
import VtsLabelValueList from '@core/components/label-value-list/VtsLabelValueList.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
customFields: Record<string, unknown>
}>()
const { t } = useI18n()
</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,53 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
{{ t('hosts') }}
<UiCounter :value="hosts.length" accent="neutral" size="small" variant="primary" />
</UiCardTitle>
<UiCollapsibleList v-if="hosts.length > 0" tag="ul" :total-items="hosts.length">
<li v-for="host in hosts" :key="host.uuid" v-tooltip class="text-ellipsis">
<UiLink
size="small"
:icon="`object:host:${getHostPowerState(host)}`"
:to="{ name: '/host/[uuid]/dashboard', params: { uuid: host.uuid } }"
>
{{ host.name_label }}
</UiLink>
</li>
</UiCollapsibleList>
<VtsStateHero v-else type="no-data" format="card" horizontal size="extra-small">
{{ t('no-host-attached') }}
</VtsStateHero>
</UiCard>
</template>
<script lang="ts" setup>
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiCollapsibleList from '@core/components/ui/collapsible-list/UiCollapsibleList.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useI18n } from 'vue-i18n'
defineProps<{
hosts: XenApiHost[]
}>()
const { t } = useI18n()
const { isHostRunning } = useHostMetricsStore().subscribe()
function getHostPowerState(host: XenApiHost) {
return isHostRunning(host) ? 'running' : 'halted'
}
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
<div v-if="sr.name_label" class="title">
<VtsIcon :name="`object:sr:${allPbdsConnectionStatus}`" size="medium" />
{{ sr.name_label }}
</div>
</UiCardTitle>
<div class="content">
<VtsCodeSnippet :content="sr.uuid" copy />
<VtsCardRowKeyValue>
<template #key>{{ t('status') }}</template>
<template #value>
<VtsStatus :status="allPbdsConnectionStatus" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
<template #value>{{ sr.name_description }}</template>
<template v-if="sr.name_description" #addons>
<VtsCopyButton :value="sr.name_description" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue align-top>
<template #key>{{ t('tags') }}</template>
<template #value>
<UiTagsList v-if="sr.tags.length > 0">
<VtsTag v-for="tag in sr.tags" :key="tag" :value="tag" />
</UiTagsList>
</template>
<template v-if="sr.tags.length > 0" #addons>
<VtsCopyButton :value="sr.tags.join(', ')" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('storage-format') }}</template>
<template #value>{{ sr.type }}</template>
<template #addons>
<VtsCopyButton :value="sr.type" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('access-mode') }}</template>
<template #value>{{ isSrSharedI18nValue }}</template>
<template #addons>
<VtsCopyButton :value="isSrSharedI18nValue" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('provisioning') }}</template>
<template #value>{{ allocationStrategy }}</template>
<template #addons>
<VtsCopyButton :value="allocationStrategy" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('high-availability') }}</template>
<template #value><VtsStatus :status="isHaSr" /></template>
</VtsCardRowKeyValue>
</div>
</UiCard>
</template>
<script lang="ts" setup>
import type { XenApiPool, XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { usePbdUtils } from '@/modules/storage-repository/composables/pbd-utils.composable.ts'
import { usePbdStore } from '@/stores/xen-api/pbd.store.ts'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCodeSnippet from '@core/components/code-snippet/VtsCodeSnippet.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsIcon from '@core/components/icon/VtsIcon.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 UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { sr, pool } = defineProps<{
sr: XenApiSr
pool: XenApiPool
}>()
const { t } = useI18n()
const { getPbdsForSr } = usePbdStore().subscribe()
const { isHaSr: isHaSrForPool, getAllocationStrategy } = useSrStore().subscribe()
const { allPbdsConnectionStatus } = usePbdUtils(() => getPbdsForSr(sr.$ref))
const isSrSharedI18nValue = computed(() => (sr.shared ? t('shared') : t('local')))
const allocationStrategy = computed(() => getAllocationStrategy(sr) ?? t('unknown'))
const isHaSr = computed(() => isHaSrForPool(sr, pool))
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.title {
display: flex;
align-items: center;
gap: 0.8rem;
}
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
{{ t('pbd-details') }}
</UiCardTitle>
<VtsStateHero v-if="pbds.length === 0" type="no-data" format="card" horizontal size="extra-small">
{{ t('no-pbd-attached') }}
</VtsStateHero>
<template v-else>
<div class="content">
<VtsStatus :status="allPbdsConnectionStatus" />
</div>
<div v-if="areSomePbdsDisconnected" class="content">
<template v-for="(pbd, index) in disconnectedPbds" :key="pbd.uuid">
<VtsDivider v-if="index > 0" class="divider" type="stretch" />
<span class="typo-body-bold-small subtitle">{{ t('disconnected-pbd-number', { n: index + 1 }) }}</span>
<StorageRepositoryPbdHost :pbd />
<VtsCardRowKeyValue>
<template #key>
{{ t('current-attach') }}
</template>
<template #value>
<VtsStatus
:status="pbd.currently_attached ? CONNECTION_STATUS.CONNECTED : CONNECTION_STATUS.DISCONNECTED"
/>
</template>
</VtsCardRowKeyValue>
<UiLogEntryViewer
v-if="Object.keys(pbd.device_config).length > 0"
:content="pbd.device_config"
:label="t('device-config')"
size="small"
accent="info"
/>
</template>
</div>
</template>
</UiCard>
</template>
<script lang="ts" setup>
import type { XenApiPbd } from '@/libs/xen-api/xen-api.types.ts'
import StorageRepositoryPbdHost from '@/modules/storage-repository/components/list/panel/card-items/StorageRepositoryPbdHost.vue'
import { usePbdUtils } from '@/modules/storage-repository/composables/pbd-utils.composable.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 UiLogEntryViewer from '@core/components/ui/log-entry-viewer/UiLogEntryViewer.vue'
import { CONNECTION_STATUS } from '@core/types/connection.ts'
import { useI18n } from 'vue-i18n'
const { pbds } = defineProps<{
pbds: XenApiPbd[]
}>()
const { t } = useI18n()
const { areSomePbdsDisconnected, allPbdsConnectionStatus, disconnectedPbds } = usePbdUtils(() => pbds)
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.subtitle {
margin-block-end: 0.4rem;
}
.divider {
margin-block: 1.6rem;
}
}
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<VtsSpaceCard
:used="sr.physical_utilisation"
:total="sr.physical_size"
:label="sr.name_label"
:total-size-label="t('total-space')"
/>
</template>
<script lang="ts" setup>
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import VtsSpaceCard from '@core/components/space-card/VtsSpaceCard.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
sr: XenApiSr
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
<div class="title">
{{ t('vdis') }}
<UiCounter :value="vdis.length + vdiSnapshots.length" accent="neutral" size="small" variant="primary" />
</div>
</UiCardTitle>
<div v-if="vdis.length > 0 || vdiSnapshots.length > 0" class="content">
<template v-if="vdis.length > 0">
<div class="subsection">
<span class="subtitle typo-body-bold-small">{{ t('vdis') }}</span>
<UiCounter :value="vdis.length" accent="neutral" size="small" variant="primary" />
</div>
<UiCollapsibleList tag="ul" :total-items="vdis.length">
<li v-for="vdi in vdis" :key="vdi.uuid" v-tooltip class="text-ellipsis">
<UiLink size="small" :icon="getVdiIcon(getVbdsForVdi(vdi, getVbdByOpaqueRef))">
{{ vdi.name_label || t('unknown') }}
</UiLink>
</li>
</UiCollapsibleList>
</template>
<VtsDivider v-if="vdis.length > 0 && vdiSnapshots.length > 0" type="stretch" />
<template v-if="vdiSnapshots.length > 0">
<div class="subsection">
<span class="subtitle typo-body-bold-small">{{ t('snapshot-vdis') }}</span>
<UiCounter :value="vdiSnapshots.length" accent="neutral" size="small" variant="primary" />
</div>
<UiCollapsibleList tag="ul" :total-items="vdiSnapshots.length">
<li v-for="vdiSnapshot in vdiSnapshots" :key="vdiSnapshot.uuid" v-tooltip class="text-ellipsis">
<UiLink size="small" icon="object:vdi-snapshot">
{{ vdiSnapshot.name_label || t('unknown') }}
</UiLink>
</li>
</UiCollapsibleList>
</template>
</div>
<VtsStateHero v-else type="no-data" format="card" horizontal size="extra-small">
{{ t('no-vdi-attached') }}
</VtsStateHero>
</UiCard>
</template>
<script lang="ts" setup>
import { getVdiIcon, getVbdsForVdi } from '@/libs/vdi.ts'
import type { XenApiVdi } from '@/libs/xen-api/xen-api.types.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'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiCollapsibleList from '@core/components/ui/collapsible-list/UiCollapsibleList.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useI18n } from 'vue-i18n'
defineProps<{
vdis: XenApiVdi[]
vdiSnapshots: XenApiVdi[]
}>()
const { t } = useI18n()
const { getByOpaqueRef: getVbdByOpaqueRef } = useVbdStore().subscribe()
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.title {
display: flex;
align-items: center;
gap: 0.8rem;
}
.content {
display: flex;
flex-direction: column;
gap: 1.6rem;
.subsection {
display: flex;
align-items: center;
gap: 0.8rem;
.subtitle {
display: flex;
align-items: center;
gap: 0.6rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,36 @@
import type { XenApiPbd } from '@/libs/xen-api/xen-api.types.ts'
import { CONNECTION_STATUS } from '@core/types/connection.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { useArrayEvery, useArrayFilter, useArraySome } from '@vueuse/shared'
import { computed, type MaybeRefOrGetter } from 'vue'
export function usePbdUtils(rawPbds: MaybeRefOrGetter<XenApiPbd[]>) {
const pbds = toComputed(rawPbds)
const predicate = (pbd: XenApiPbd) => !pbd.currently_attached
const disconnectedPbds = useArrayFilter(pbds, predicate)
const areAllPbdsDisconnected = useArrayEvery(pbds, predicate)
const areSomePbdsDisconnected = useArraySome(pbds, predicate)
const allPbdsConnectionStatus = computed(() => {
if (areAllPbdsDisconnected.value) {
return CONNECTION_STATUS.DISCONNECTED
}
if (areSomePbdsDisconnected.value) {
return CONNECTION_STATUS.PARTIALLY_CONNECTED
}
return CONNECTION_STATUS.CONNECTED
})
return {
allPbdsConnectionStatus,
areAllPbdsDisconnected,
areSomePbdsDisconnected,
disconnectedPbds,
}
}

View File

@@ -0,0 +1,79 @@
<template>
<VtsContentSidePanel class="host-storage-view" :class="{ mobile: uiStore.isSmall }">
<UiCard class="container">
<StorageRepositoriesTable v-if="pool" :srs :pool :busy="!isReady" :error="hasError" />
</UiCard>
<StorageRepositorySidePanel v-if="pool" :sr="selectedSr" :pool @close="selectedSr = undefined" />
</VtsContentSidePanel>
</template>
<script lang="ts" setup>
import type { XenApiHost, XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import StorageRepositorySidePanel from '@/modules/storage-repository/components/list/panel/StorageRepositorySidePanel.vue'
import StorageRepositoriesTable from '@/modules/storage-repository/components/list/StorageRepositoriesTable.vue'
import { usePageTitleStore } from '@/stores/page-title.store.ts'
import { useHostStore } from '@/stores/xen-api/host.store.ts'
import { usePbdStore } from '@/stores/xen-api/pbd.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
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.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { sortByNameLabel } from '@core/utils/sort-by-name-label.util.ts'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t } = useI18n()
usePageTitleStore().setTitle(t('storage'))
const route = useRoute<'/host/[uuid]/storage'>()
const { getByUuid: getHostByUuid } = useHostStore().subscribe()
const { getByOpaqueRef: getSrByOpaqueRef, isReady: areSrsReady, hasError } = useSrStore().subscribe()
const { getPbdsForHost, isReady: arePbdsReady } = usePbdStore().subscribe()
const { pool } = usePoolStore().subscribe()
const uiStore = useUiStore()
const isReady = logicAnd(areSrsReady, arePbdsReady)
const host = computed(() => getHostByUuid(route.params.uuid as XenApiHost['uuid']))
const srs = computed(() => {
if (host.value === undefined) {
return []
}
const hostPbds = getPbdsForHost(host.value.$ref)
return hostPbds
.reduce<XenApiSr[]>((acc, pbd) => {
const sr = getSrByOpaqueRef(pbd.SR)
if (sr !== undefined) {
acc.push(sr)
}
return acc
}, [])
.sort(sortByNameLabel)
})
const selectedSr = useRouteQuery<XenApiSr | undefined>('id', {
toData: id => srs.value.find(sr => sr.uuid === id),
toQuery: sr => sr?.uuid ?? '',
})
</script>
<style lang="postcss" scoped>
.host-storage-view {
.container {
height: fit-content;
margin: 0.8rem;
gap: 4rem;
}
}
</style>

View File

@@ -1,11 +1,54 @@
<template>
<PageUnderConstruction />
<VtsContentSidePanel class="pool-storage-view" :class="{ mobile: uiStore.isSmall }">
<UiCard class="container">
<StorageRepositoriesTable v-if="pool" :srs="sortedSrs" :pool :busy="!isReady" :error="hasError" />
</UiCard>
<StorageRepositorySidePanel v-if="pool" :sr="selectedSr" :pool @close="selectedSr = undefined" />
</VtsContentSidePanel>
</template>
<script lang="ts" setup>
import PageUnderConstruction from '@/components/PageUnderConstruction.vue'
import { usePageTitleStore } from '@/stores/page-title.store'
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import StorageRepositorySidePanel from '@/modules/storage-repository/components/list/panel/StorageRepositorySidePanel.vue'
import StorageRepositoriesTable from '@/modules/storage-repository/components/list/StorageRepositoriesTable.vue'
import { usePageTitleStore } from '@/stores/page-title.store.ts'
import { usePbdStore } from '@/stores/xen-api/pbd.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
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.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { sortByNameLabel } from '@core/utils/sort-by-name-label.util.ts'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
usePageTitleStore().setTitle(useI18n().t('storage'))
const { t } = useI18n()
usePageTitleStore().setTitle(t('storage'))
const { pool } = usePoolStore().subscribe()
const { records: srs, isReady: areSrsReady, hasError } = useSrStore().subscribe()
const { isReady: arePbdsReady } = usePbdStore().subscribe()
const uiStore = useUiStore()
const isReady = logicAnd(areSrsReady, arePbdsReady)
const sortedSrs = computed(() => [...srs.value].sort(sortByNameLabel))
const selectedSr = useRouteQuery<XenApiSr | undefined>('id', {
toData: id => sortedSrs.value.find(sr => sr.uuid === id),
toQuery: sr => sr?.uuid ?? '',
})
</script>
<style lang="postcss" scoped>
.pool-storage-view {
.container {
height: fit-content;
margin: 0.8rem;
gap: 4rem;
}
}
</style>

View File

@@ -1,11 +0,0 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from '@/components/PageUnderConstruction.vue'
import { usePageTitleStore } from '@/stores/page-title.store'
import { useI18n } from 'vue-i18n'
usePageTitleStore().setTitle(useI18n().t('storage'))
</script>

View File

@@ -52,6 +52,7 @@ declare module 'vue-router/auto-routes' {
| '/host/[uuid]/console'
| '/host/[uuid]/dashboard'
| '/host/[uuid]/network'
| '/host/[uuid]/storage'
| '/host/[uuid]/system'
| '/host/[uuid]/tasks'
| '/host/[uuid]/vms'
@@ -77,6 +78,13 @@ declare module 'vue-router/auto-routes' {
{ uuid: ParamValue<false> },
| never
>,
'/host/[uuid]/storage': RouteRecordInfo<
'/host/[uuid]/storage',
'/host/:uuid/storage',
{ uuid: ParamValue<true> },
{ uuid: ParamValue<false> },
| never
>,
'/host/[uuid]/system': RouteRecordInfo<
'/host/[uuid]/system',
'/host/:uuid/system',
@@ -928,7 +936,6 @@ declare module 'vue-router/auto-routes' {
| '/vm/[uuid]/dashboard'
| '/vm/[uuid]/network'
| '/vm/[uuid]/stats'
| '/vm/[uuid]/storage'
| '/vm/[uuid]/system'
| '/vm/[uuid]/tasks'
>,
@@ -967,13 +974,6 @@ declare module 'vue-router/auto-routes' {
{ uuid: ParamValue<false> },
| never
>,
'/vm/[uuid]/storage': RouteRecordInfo<
'/vm/[uuid]/storage',
'/vm/:uuid/storage',
{ uuid: ParamValue<true> },
{ uuid: ParamValue<false> },
| never
>,
'/vm/[uuid]/system': RouteRecordInfo<
'/vm/[uuid]/system',
'/vm/:uuid/system',
@@ -1033,6 +1033,7 @@ declare module 'vue-router/auto-routes' {
| '/host/[uuid]/console'
| '/host/[uuid]/dashboard'
| '/host/[uuid]/network'
| '/host/[uuid]/storage'
| '/host/[uuid]/system'
| '/host/[uuid]/tasks'
| '/host/[uuid]/vms'
@@ -1057,6 +1058,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/host/[uuid]/storage.vue': {
routes:
| '/host/[uuid]/storage'
views:
| never
}
'src/pages/host/[uuid]/system.vue': {
routes:
| '/host/[uuid]/system'
@@ -1788,7 +1795,6 @@ declare module 'vue-router/auto-routes' {
| '/vm/[uuid]/dashboard'
| '/vm/[uuid]/network'
| '/vm/[uuid]/stats'
| '/vm/[uuid]/storage'
| '/vm/[uuid]/system'
| '/vm/[uuid]/tasks'
views:
@@ -1824,12 +1830,6 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/vm/[uuid]/storage.vue': {
routes:
| '/vm/[uuid]/storage'
views:
| never
}
'src/pages/vm/[uuid]/system.vue': {
routes:
| '/vm/[uuid]/system'

View File

@@ -1,9 +1,20 @@
import { createXapiStoreConfig } from '@/stores/xen-api/create-xapi-store-config'
import { createSubscribableStoreContext } from '@core/utils/create-subscribable-store-context.util'
import type { XenApiHost, XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { createXapiStoreConfig } from '@/stores/xen-api/create-xapi-store-config.ts'
import { createSubscribableStoreContext } from '@core/utils/create-subscribable-store-context.util.ts'
import { defineStore } from 'pinia'
export const usePbdStore = defineStore('xen-api-pbd', () => {
const config = createXapiStoreConfig('pbd')
const { context: baseContext, ...configRest } = createXapiStoreConfig('pbd')
return createSubscribableStoreContext(config, {})
const getPbdsForHost = (hostRef: XenApiHost['$ref']) => baseContext.records.value.filter(pbd => pbd.host === hostRef)
const getPbdsForSr = (srRef: XenApiSr['$ref']) => baseContext.records.value.filter(pbd => pbd.SR === srRef)
const context = {
...baseContext,
getPbdsForHost,
getPbdsForSr,
}
return createSubscribableStoreContext({ context, ...configRest }, {})
})

View File

@@ -1,17 +1,22 @@
import type { XenApiSr, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { createXapiStoreConfig } from '@/stores/xen-api/create-xapi-store-config'
import { useVdiStore } from '@/stores/xen-api/vdi.store'
import { createSubscribableStoreContext } from '@core/utils/create-subscribable-store-context.util'
import { getAllocationStrategy as getAllocationStrategyFromSrType } from '@/libs/sr.ts'
import type { XenApiPool, XenApiSr, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { createXapiStoreConfig } from '@/stores/xen-api/create-xapi-store-config.ts'
import { usePbdStore } from '@/stores/xen-api/pbd.store.ts'
import { useVdiStore } from '@/stores/xen-api/vdi.store.ts'
import { createSubscribableStoreContext } from '@core/utils/create-subscribable-store-context.util.ts'
import { defineStore } from 'pinia'
import { computed } from 'vue'
export const useSrStore = defineStore('xen-api-sr', () => {
const deps = {
vdiStore: useVdiStore(),
pbdStore: usePbdStore(),
}
const vdiContext = deps.vdiStore.getContext()
const pbdContext = deps.pbdStore.getContext()
const { context: baseContext, ...configRest } = createXapiStoreConfig('sr')
const srs = computed(() => baseContext.records.value)
@@ -31,6 +36,17 @@ export const useSrStore = defineStore('xen-api-sr', () => {
// TODO remove when the select component is ready to use
const getSrName = (ref: XenApiSr['$ref']) => baseContext.getByOpaqueRef(ref)?.name_label
const isDefaultSr = (sr: XenApiSr, pool: XenApiPool) =>
pool.default_SR !== 'OpaqueRef:NULL' && pool.default_SR === sr.$ref
const isHaSr = (sr: XenApiSr, pool: XenApiPool) =>
pool.ha_statefiles.some(vdiRef => vdiContext.getByOpaqueRef(vdiRef)?.SR === sr.$ref)
const getAllocationStrategy = (sr: XenApiSr) => {
const firstPbd = pbdContext.getByOpaqueRef(sr.PBDs[0])
return getAllocationStrategyFromSrType(sr.type, firstPbd?.device_config.provisioning)
}
const vdiIsosBySrName = computed(() => {
const groupedVdis: Record<string, XenApiVdi[]> = {}
@@ -51,6 +67,9 @@ export const useSrStore = defineStore('xen-api-sr', () => {
const context = {
...baseContext,
getAllocationStrategy,
isDefaultSr,
isHaSr,
vdiIsosBySrName,
}

View File

@@ -79,15 +79,7 @@ const panelSignature = computed(() => getSrPbdsSignature(sr, scope))
const pbds = computed(() => (sr !== undefined ? pbdsBySr.value.get(sr.id) : undefined) ?? [])
const hosts = computed(() =>
pbds.value.reduce<FrontXoHost[]>((acc, pbd) => {
const host = getHostById(pbd.host)
if (host !== undefined) {
acc.push(host)
}
return acc
}, [])
pbds.value.map(pbd => getHostById(pbd.host)).filter((host): host is FrontXoHost => host !== undefined)
)
const customFields = computed(() => {

View File

@@ -42,9 +42,9 @@
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('provisioning') }}</template>
<template #value>{{ provisioning }}</template>
<template #value>{{ allocationStrategy }}</template>
<template #addons>
<VtsCopyButton :value="provisioning" />
<VtsCopyButton :value="allocationStrategy" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
@@ -92,7 +92,7 @@ const { srConnectionStatus, srStatusIcon } = useXoSrUtils(
const isSrSharedI18nValue = computed(() => (sr.shared ? t('shared') : t('local')))
const provisioning = computed(() => {
const allocationStrategy = computed(() => {
return sr.allocationStrategy ?? t('unknown')
})

View File

@@ -8,43 +8,49 @@
</UiCardTitle>
<div v-if="vdis.length > 0 || vdiSnapshots.length > 0" class="content">
<div v-if="vdis.length > 0" class="subsection">
<span class="subtitle typo-body-bold-small">{{ t('vdis') }}</span>
<UiCounter :value="vdis.length" accent="neutral" size="small" variant="primary" />
</div>
<UiCollapsibleList tag="ul" :total-items="vdis.length">
<li v-for="vdi in vdis" :key="vdi.id" v-tooltip class="text-ellipsis">
<UiLink
:to="{ name: '/vdi/[id]/general', params: { id: vdi.id }, query: { from: VDI_PAGE_CONTEXT.SR } }"
size="small"
:icon="getVdiIcon(getVbdsByIds(vdi.$VBDs))"
>
{{ vdi.name_label || t('unknown') }}
</UiLink>
</li>
</UiCollapsibleList>
<template v-if="vdis.length > 0">
<div class="subsection">
<span class="subtitle typo-body-bold-small">{{ t('vdis') }}</span>
<UiCounter :value="vdis.length" accent="neutral" size="small" variant="primary" />
</div>
<UiCollapsibleList tag="ul" :total-items="vdis.length">
<li v-for="vdi in vdis" :key="vdi.id" v-tooltip class="text-ellipsis">
<UiLink
:to="{ name: '/vdi/[id]/general', params: { id: vdi.id }, query: { from: VDI_PAGE_CONTEXT.SR } }"
size="small"
:icon="getVdiIcon(getVbdsByIds(vdi.$VBDs))"
>
{{ vdi.name_label || t('unknown') }}
</UiLink>
</li>
</UiCollapsibleList>
</template>
<VtsDivider v-if="vdis.length > 0 && vdiSnapshots.length > 0" type="stretch" />
<div v-if="vdiSnapshots.length > 0" class="subsection">
<span class="subtitle typo-body-bold-small">{{ t('snapshot-vdis') }}</span>
<UiCounter :value="vdiSnapshots.length" accent="neutral" size="small" variant="primary" />
</div>
<UiCollapsibleList tag="ul" :total-items="vdiSnapshots.length">
<li v-for="vdiSnapshot in vdiSnapshots" :key="vdiSnapshot.id" v-tooltip class="text-ellipsis">
<UiLink
:to="{
name: '/vdi/[id]/general',
params: { id: vdiSnapshot.id },
query: { from: VDI_PAGE_CONTEXT.VDI_SNAPSHOT },
}"
size="small"
icon="object:vdi-snapshot"
>
{{ vdiSnapshot.name_label || t('unknown') }}
</UiLink>
</li>
</UiCollapsibleList>
<template v-if="vdiSnapshots.length > 0">
<div class="subsection">
<span class="subtitle typo-body-bold-small">{{ t('snapshot-vdis') }}</span>
<UiCounter :value="vdiSnapshots.length" accent="neutral" size="small" variant="primary" />
</div>
<UiCollapsibleList tag="ul" :total-items="vdiSnapshots.length">
<li v-for="vdiSnapshot in vdiSnapshots" :key="vdiSnapshot.id" v-tooltip class="text-ellipsis">
<UiLink
:to="{
name: '/vdi/[id]/general',
params: { id: vdiSnapshot.id },
query: { from: VDI_PAGE_CONTEXT.VDI_SNAPSHOT },
}"
size="small"
icon="object:vdi-snapshot"
>
{{ vdiSnapshot.name_label || t('unknown') }}
</UiLink>
</li>
</UiCollapsibleList>
</template>
</div>
<VtsStateHero v-else type="no-data" format="card" horizontal size="extra-small">