feat(xo6): implement vm snapshot tab page (#9521)

This commit is contained in:
Elise-F
2026-03-26 10:03:19 +01:00
committed by GitHub
parent e28e768c72
commit 57414dafce
28 changed files with 910 additions and 40 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ type BaseXoVm = BaseXapiXo & {
notes?: string
os_version: Record<string, string> | null
other: Record<string, string>
parent?: XoVm['id']
parent?: XoVmSnapshot['id']
power_state: VM_POWER_STATE
pvDriversDetected?: boolean
pvDriversUpToDate?: boolean
@@ -1,9 +1,13 @@
<template>
<UiTableCell>
<UiLink size="medium" :icon :to :href :target class="link">
<slot />
<VtsIcon v-if="rightIcon" v-tooltip="rightIcon.tooltip ?? false" :name="rightIcon.icon" size="medium" />
</UiLink>
<div class="vts-link-cell">
<UiLink size="medium" :icon :to :href :target class="link">
<slot />
<VtsIcon v-if="rightIcon" v-tooltip="rightIcon.tooltip ?? false" :name="rightIcon.icon" size="medium" />
</UiLink>
<span v-if="slots.suffix" class="interpunct" />
<slot name="suffix" />
</div>
</UiTableCell>
</template>
@@ -24,9 +28,24 @@ export type VtsLinkCellProps = LinkOptions & {
}
defineProps<VtsLinkCellProps>()
const slots = defineSlots<{
default(): any
suffix?(): any
}>()
</script>
<style lang="postcss" scoped>
.vts-link-cell {
display: flex;
align-items: center;
gap: 0.8rem;
}
.interpunct::before {
content: '•';
}
.link {
line-height: 1.5;
}
@@ -6,6 +6,7 @@ import {
faArrowLeft,
faBarsProgress,
faBoxesStacked,
faCamera,
faCircle,
faCity,
faClock,
@@ -118,6 +119,21 @@ const schedule = defineIcon([
},
])
const camera = defineIcon([
{
icon: faCircle,
color: 'var(--color-neutral-background-primary)',
translate: [7, 5.5],
size: 13,
},
{
icon: faCamera,
color: 'var(--color-neutral-txt-primary)',
translate: [7, 5.5],
size: 10,
},
])
export const objectIcons = defineIconPack({
instance: constructIcon(faSatellite),
pool: constructIcon(faCity),
@@ -159,6 +175,12 @@ export const objectIcons = defineIconPack({
'vm:suspended': [constructIcon(faDesktop), ...constructCircleStatus('suspended-circle')],
'vm:warning': [constructIcon(faDesktop), ...constructCircleStatus('warning-circle')],
'vm:halted': [constructIcon(faDesktop), ...constructCircleStatus('halted-circle')],
'vm-snapshot': [
constructIcon(faDesktop),
{
icon: camera,
},
],
sr: constructIcon(faDatabase),
'sr:unknown': [
{
@@ -238,6 +238,7 @@
"crash-dump-storage-repository": "Crash dump storage repository",
"created-by": "Created by",
"created-on": "Created on",
"creation-date": "Creation date",
"cron-pattern": "Cron pattern",
"current": "Current",
"current-attach": "Current attach",
@@ -333,6 +334,7 @@
"hosts-status": "Hosts status",
"hyper-threading": "Hyper threading (SMT)",
"id": "ID",
"included": "Included",
"in-last-three-runs": "In their last 3 runs",
"in-progress": "In progress",
"info": "Info",
@@ -428,7 +430,9 @@
"last-n-backup-archives": "Last backup archive | Last {n} backup archives",
"last-n-backup-runs": "Last backup run | Last {n} backup runs",
"last-n-runs": "Last run | Last {n} runs",
"last-revert": "Last revert",
"last-run-number": "Last run #{n}",
"last-snapshot": "Last snapshot",
"last-week": "Last week",
"learn-more": "Learn more",
"lets-go!": "Let's go!",
@@ -453,11 +457,13 @@
"management-agent-not-detected": "Management agent not detected",
"management-agent-version": "Management agent version",
"management-ip": "Management IP",
"manual": "Manual",
"manufacturer-info": "Manufacturer info",
"master": "Primary host",
"maximum-cpu-limit": "Maximum CPU limit",
"maximum-dynamic-memory": "Maximum dynamic memory",
"maximum-static-memory": "Maximum static memory",
"memory": "Memory",
"memory-usage": "Memory usage",
"merge-backups-synchronously": "Merge backups synchronously",
"message": "Message",
@@ -553,6 +559,7 @@
"no-selected-vm-can-be-exported": "No selected VM can be exported",
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
"no-server-detected": "No servers detected",
"no-snapshot-detected": "No snapshots detected",
"no-storage-repository-detected": "No storage repositories detected",
"no-task": "No tasks",
"no-task-detected": "No tasks detected",
@@ -564,6 +571,7 @@
"none": "None",
"normal": "Normal",
"not-found": "Not found",
"not-included": "Not included",
"not-resident-on": "Not resident on",
"not-running": "Not running",
"not-yet-available": "Information is not yet available",
@@ -740,6 +748,7 @@
"skipped": "Skipped",
"smart-mode": "Smart mode",
"snapshot": "Snapshot",
"snapshot-created-on": "Snapshot created on",
"snapshot-mode": "Snapshot mode",
"snapshots": "Snapshots",
"sockets-with-cores-per-socket": "{nSockets} sockets × {nCores} cores/socket",
@@ -841,11 +850,13 @@
"total-used:": "Total used:",
"transfer-size": "Transfer size",
"translation-tool": "Translation tool",
"trigger": "Trigger",
"unable-to-connect-to": "Unable to connect to {ip}",
"unable-to-connect-to-the-pool": "Unable to connect to the pool",
"unable-to-connect-to-xo-server": "Unable to connect to XO server.",
"unknown": "Unknown",
"unlocked": "Unlocked",
"unnamed": "Unnamed",
"unreachable-hosts": "Unreachable hosts",
"unreachable-hosts-reload-page": "Done, reload the page",
"untitled": "Untitled",
@@ -854,6 +865,7 @@
"used": "Used",
"used-for-backup": "Used for backup",
"used-space": "Used space",
"used-space-on-sr": "Used space on SR",
"user-config": "User config",
"username": "Username",
"uuid": "UUID",
@@ -238,6 +238,7 @@
"crash-dump-storage-repository": "Dépôt de stockage des log de crash",
"created-by": "Créé par",
"created-on": "Créé le",
"creation-date": "Date de création",
"cron-pattern": "Pattern cron",
"current": "Actuel",
"current-attach": "Attachement actuel",
@@ -333,6 +334,7 @@
"hosts-status": "Statut des hôtes",
"hyper-threading": "Hyper-threading (SMT)",
"id": "ID",
"included": "Inclus",
"in-last-three-runs": "Dans leurs trois derniers runs",
"in-progress": "En cours",
"info": "Info",
@@ -428,7 +430,9 @@
"last-n-backup-archives": "Dernier run d'archivage | {n} derniers archivages",
"last-n-backup-runs": "Dernier run de sauvegarde | {n} dernières sauvegardes",
"last-n-runs": "Dernier run | {n} derniers runs",
"last-revert": "Dernière restauration",
"last-run-number": "Dernière exécution #{n}",
"last-snapshot": "Dernier instantané",
"last-week": "Semaine dernière",
"learn-more": "En savoir plus",
"lets-go!": "Allons-y !",
@@ -453,11 +457,13 @@
"management-agent-not-detected": "Agent de gestion non détecté",
"management-agent-version": "Version de l'agent de gestion",
"management-ip": "IP principale",
"manual": "Manuel",
"manufacturer-info": "Informations sur le fabricant",
"master": "Hôte primaire",
"maximum-cpu-limit": "Nombre maximal de CPU",
"maximum-dynamic-memory": "Mémoire dynamique maximale",
"maximum-static-memory": "Mémoire statique maximale",
"memory": "Mémoire",
"memory-usage": "Utilisation de la mémoire",
"merge-backups-synchronously": "Fusionner les sauvegardes de manière synchrone",
"message": "Message",
@@ -553,6 +559,7 @@
"no-selected-vm-can-be-exported": "Aucune VM sélectionnée ne peut être exportée",
"no-selected-vm-can-be-migrated": "Aucune VM sélectionnée ne peut être migrée",
"no-server-detected": "Aucun serveur détecté",
"no-snapshot-detected": "Aucun instantané détecté",
"no-storage-repository-detected": "Aucun dépôt de stockage détecté",
"no-task": "Aucune tâche",
"no-task-detected": "Aucune tâche détectée",
@@ -564,6 +571,7 @@
"none": "Aucun",
"normal": "Normal",
"not-found": "Non trouvé",
"not-included": "Non inclus",
"not-resident-on": "Non résident sur",
"not-running": "Pas en cours d'exécution",
"not-yet-available": "Information pas encore disponible",
@@ -740,6 +748,7 @@
"skipped": "Ignoré",
"smart-mode": "Mode intelligent",
"snapshot": "Instantané",
"snapshot-created-on": "Instantané créé le",
"snapshot-mode": "Mode de sauvegarde",
"snapshots": "Instantanés",
"sockets-with-cores-per-socket": "{nSockets} sockets × {nCores} cœurs/socket",
@@ -841,11 +850,13 @@
"total-used:": "Total utilisé :",
"transfer-size": "Taille transférée",
"translation-tool": "Outil de traduction",
"trigger": "Déclencheur",
"unable-to-connect-to": "Impossible de se connecter à {ip}",
"unable-to-connect-to-the-pool": "Impossible de se connecter au Pool",
"unable-to-connect-to-xo-server": "Impossible de se connecter au serveur XO.",
"unknown": "Inconnu",
"unlocked": "Déverrouillé",
"unnamed": "Sans nom",
"unreachable-hosts": "Hôtes inaccessibles",
"unreachable-hosts-reload-page": "C'est fait. Rafraîchir la page",
"untitled": "Sans titre",
@@ -854,6 +865,7 @@
"used": "Utilisé",
"used-for-backup": "Utilisé pour la sauvegarde",
"used-space": "Espace utilisé",
"used-space-on-sr": "Espace utilisé sur le SR",
"user-config": "Configuration utilisateur",
"username": "Nom d'utilisateur",
"uuid": "UUID",
@@ -0,0 +1,22 @@
import VtsLinkCell, { type VtsLinkCellProps } from '@core/components/table/cells/VtsLinkCell.vue'
import VtsTextCell from '@core/components/table/cells/VtsTextCell.vue'
import { defineColumn } from '@core/packages/table'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export const useLinkOrTextColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerIcon, config?.headerLabel),
renderBody: (link: { label: string; suffix?: string } & VtsLinkCellProps) => {
const { label, suffix, ...linkCellProps } = link
if (!linkCellProps.href && !linkCellProps.to) {
return h(VtsTextCell, () => label)
}
return h(VtsLinkCell, linkCellProps, {
default: () => label,
suffix: suffix ? () => h('span', suffix) : undefined,
})
},
}))
@@ -0,0 +1,19 @@
import { defineColumns } from '@core/packages/table'
import { useDateColumn } from '@core/tables/column-definitions/date-column.ts'
import { useLinkColumn } from '@core/tables/column-definitions/link-column.ts'
import { useLinkOrTextColumn } from '@core/tables/column-definitions/link-or-text-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'
export const useSnapshotColumns = defineColumns(() => {
const { t } = useI18n()
return {
name: useLinkColumn({ headerLabel: () => t('name') }),
description: useTruncatedTextColumn({ headerLabel: () => t('description') }),
creationDate: useDateColumn({ headerLabel: () => t('creation-date'), dateStyle: 'short', timeStyle: 'medium' }),
trigger: useLinkOrTextColumn({ headerLabel: () => t('trigger'), headerIcon: 'fa:square-caret-down' }),
selectItem: useSelectItemColumn(),
}
})
@@ -0,0 +1,145 @@
<template>
<div class="snapshots-table">
<UiTitle>
{{ t('snapshots') }}
<template #action>
<UiButton
:busy="isRunning"
variant="primary"
accent="brand"
size="medium"
left-icon="fa:plus"
@click="snapshotJob()"
>
{{ t('new') }}
</UiButton>
</template>
</UiTitle>
<div class="container">
<div class="table-actions">
<UiQuerySearchBar @search="(value: string) => (searchQuery = value)" />
</div>
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow
v-for="snapshot of paginatedSnapshots"
:key="snapshot.id"
:selected="selectedSnapshotId === snapshot.id"
>
<BodyCells :item="snapshot" />
</VtsRow>
</tbody>
</VtsTable>
</div>
</div>
</template>
<script setup lang="ts">
import {
type FrontXoVmSnapshot,
useXoVmSnapshotCollection,
} from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import { useSnapshotTrigger } from '@/modules/snapshot/composables/xo-snapshot-trigger.composable.ts'
import { useXo5VmSnapshotRoute } from '@/modules/snapshot/composables/xo-vm-snapshot-route-xo5.composable.ts'
import { useXoVmSnapshotJob } from '@/modules/vm/jobs/xo-vm-snapshot.job.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiButton from '@core/components/ui/button/UiButton.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 { useSnapshotColumns } from '@core/tables/column-sets/snapshot-columns.ts'
import { useSorted } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { snapshots: rawSnapshots, vm } = defineProps<{
snapshots: FrontXoVmSnapshot[]
vm: FrontXoVm
}>()
const { t } = useI18n()
const { areSnapshotsReady, hasSnapshotFetchError } = useXoVmSnapshotCollection()
const { getSnapshotTrigger } = useSnapshotTrigger()
const { buildXo5VmSnapshotRoute } = useXo5VmSnapshotRoute()
const { run: snapshotJob, isRunning } = useXoVmSnapshotJob(() => [vm])
const selectedSnapshotId = useRouteQuery('id')
const searchQuery = ref('')
const filteredSnapshots = computed(() => {
const searchTerm = searchQuery.value.trim().toLocaleLowerCase()
if (!searchTerm) {
return rawSnapshots
}
return rawSnapshots.filter(snapshot =>
Object.values(snapshot).some(value => String(value).toLocaleLowerCase().includes(searchTerm))
)
})
const sortedSnapshots = useSorted(
filteredSnapshots,
(snapshot1, snapshot2) => snapshot2.snapshot_time - snapshot1.snapshot_time
)
const { pageRecords: paginatedSnapshots, paginationBindings } = usePagination('snapshots', sortedSnapshots)
const state = useTableState({
busy: !areSnapshotsReady,
error: hasSnapshotFetchError,
empty: () =>
rawSnapshots.length === 0
? t('no-snapshot-detected')
: filteredSnapshots.value.length === 0
? { type: 'no-result' }
: false,
})
const { HeadCells, BodyCells } = useSnapshotColumns({
body: (snapshot: FrontXoVmSnapshot) => {
return {
name: r =>
r({
label: snapshot.name_label,
icon: 'object:vm-snapshot',
href: buildXo5VmSnapshotRoute(vm.id, snapshot.id),
}),
description: r => r(snapshot.name_description),
creationDate: r => r(snapshot.snapshot_time * 1000),
trigger: r => r(getSnapshotTrigger(snapshot)),
selectItem: r => r(() => (selectedSnapshotId.value = snapshot.id)),
}
},
})
</script>
<style scoped lang="postcss">
.snapshots-table {
display: flex;
flex-direction: column;
gap: 2.4rem;
.table-actions,
.container {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
}
</style>
@@ -0,0 +1,54 @@
<template>
<UiPanel :class="{ 'mobile-drawer': uiStore.isSmall }">
<template #header>
<div :class="{ 'action-buttons-container': uiStore.isSmall }">
<UiButtonIcon
v-tooltip="t('action:close')"
size="small"
variant="tertiary"
accent="brand"
:icon="uiStore.isSmall ? 'fa:angle-left' : 'fa:close'"
@click="emit('close')"
/>
</div>
</template>
<template #default>
<SnapshotInfoCard :snapshot />
<SnapshotVdiCard :snapshot />
</template>
</UiPanel>
</template>
<script setup lang="ts">
import SnapshotInfoCard from '@/modules/snapshot/components/list/panel/cards/SnapshotInfoCard.vue'
import SnapshotVdiCard from '@/modules/snapshot/components/list/panel/cards/SnapshotVdiCard.vue'
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiPanel from '@core/components/ui/panel/UiPanel.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useI18n } from 'vue-i18n'
defineProps<{ snapshot: FrontXoVmSnapshot }>()
const emit = defineEmits<{
close: []
}>()
const uiStore = useUiStore()
const { t } = useI18n()
</script>
<style scoped lang="postcss">
.mobile-drawer {
position: fixed;
inset: 0;
.action-buttons-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
}
</style>
@@ -0,0 +1,31 @@
<template>
<UiLink :icon="vbdsSnapshotStatus" size="medium" :href="xo5VmSnapshotVdiHref">{{ vdi.name_label }}</UiLink>
</template>
<script setup lang="ts">
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import type { FrontXoVmSnapshotVdi } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-vdi-collection.ts'
import { useVbdsStatus, type VbdAttachmentStatus } from '@/modules/vbd/composables/use-vbds-status.composable.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import type { IconName } from '@core/icons'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useMapper } from '@core/packages/mapper'
import { computed } from 'vue'
const { snapshot, vdi } = defineProps<{ snapshot: FrontXoVmSnapshot; vdi: FrontXoVmSnapshotVdi }>()
const { buildXo5Route } = useXoRoutes()
const xo5VmSnapshotVdiHref = computed(() => buildXo5Route(`/vms/${snapshot.id}/disks`))
const vbdsStatus = useVbdsStatus(() => vdi.$VBDs)
const vbdsSnapshotStatus = useMapper<VbdAttachmentStatus, IconName>(
() => vbdsStatus.value,
{
allAttached: 'object:vdi:attached',
someAttached: 'object:vdi:disabled',
noneAttached: 'object:vdi:detached',
},
'someAttached'
)
</script>
@@ -0,0 +1,25 @@
<template>
<VtsCardRowKeyValue>
<template #key>
{{ t('used-space-on-sr') }}
</template>
<template #value>{{ formattedSize }}</template>
<template #addons>
<VtsCopyButton :value="formattedSize" />
</template>
</VtsCardRowKeyValue>
</template>
<script setup lang="ts">
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import { formatSize } from '@core/utils/size.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { usage } = defineProps<{ usage: number }>()
const { t } = useI18n()
const formattedSize = computed(() => formatSize(usage, 2))
</script>
@@ -0,0 +1,105 @@
<template>
<UiCard class="card-container">
<div class="content">
<UiLink icon="object:vm" size="medium" :href="buildXo5VmSnapshotRoute(snapshot.$snapshot_of, snapshot.id)">
{{ snapshot.name_label }}
</UiLink>
<VtsCodeSnippet :content="snapshot.id" copy />
</div>
<div class="content">
<!-- DESCRIPTION -->
<VtsCardRowKeyValue truncate align-top>
<template #key>
{{ t('description') }}
</template>
<template #value>{{ snapshot.name_description }}</template>
<template v-if="snapshot.name_description" #addons>
<VtsCopyButton :value="snapshot.name_description" />
</template>
</VtsCardRowKeyValue>
<!-- CREATION DATE -->
<VtsCardRowKeyValue>
<template #key>
{{ t('creation-date') }}
</template>
<template #value>
{{ formattedDate }}
</template>
<template #addons>
<VtsCopyButton :value="formattedDate" />
</template>
</VtsCardRowKeyValue>
<!-- TRIGGER -->
<VtsCardRowKeyValue>
<template #key>
{{ t('trigger') }}
</template>
<template #value>
<UiLink v-if="snapshot.other?.['xo:backup:schedule']" size="small" :icon="trigger.icon" :href="trigger.href">
{{ trigger.label }}
</UiLink>
<span v-else>
{{ trigger.label }}
</span>
</template>
<template #addons>
<VtsCopyButton :value="trigger.label" />
</template>
</VtsCardRowKeyValue>
<!-- MEMORY -->
<VtsCardRowKeyValue>
<template #key>
{{ t('memory') }}
</template>
<template #value>
<UiInfo :accent="memoryAccent" size="medium">
{{ t(memoryLabel) }}
</UiInfo>
</template>
</VtsCardRowKeyValue>
</div>
</UiCard>
</template>
<script setup lang="ts">
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import { useSnapshotTrigger } from '@/modules/snapshot/composables/xo-snapshot-trigger.composable.ts'
import { useXo5VmSnapshotRoute } from '@/modules/snapshot/composables/xo-vm-snapshot-route-xo5.composable.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 UiCard from '@core/components/ui/card/UiCard.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { VM_POWER_STATE } from '@vates/types'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { snapshot } = defineProps<{ snapshot: FrontXoVmSnapshot }>()
const { t, d } = useI18n()
const { getSnapshotTrigger } = useSnapshotTrigger()
const { buildXo5VmSnapshotRoute } = useXo5VmSnapshotRoute()
const formattedDate = computed(() => d(snapshot.snapshot_time * 1000, { dateStyle: 'short', timeStyle: 'medium' }))
const memoryAccent = computed(() => (snapshot.power_state === VM_POWER_STATE.SUSPENDED ? 'success' : 'muted'))
const memoryLabel = computed(() => (snapshot.power_state === VM_POWER_STATE.SUSPENDED ? 'included' : 'not-included'))
const trigger = computed(() => getSnapshotTrigger(snapshot))
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>
@@ -0,0 +1,65 @@
<template>
<UiCard v-if="vmSnapshotVdis.length > 0" class="card-container">
<UiCardTitle>
{{ t('vdis') }}
<UiCounter :value="vmSnapshotVdis.length" accent="neutral" size="small" variant="primary" />
</UiCardTitle>
<div class="content">
<div v-for="(vmSnapshotVdi, index) in vmSnapshotVdis" :key="vmSnapshotVdi.id" class="content">
<SnapshotVdiLinkCardItem :vdi="vmSnapshotVdi" :snapshot />
<!-- DESCRIPTION -->
<VtsCardRowKeyValue truncate>
<template #key>
{{ t('description') }}
</template>
<template #value>{{ vmSnapshotVdi.name_description }}</template>
<template v-if="vmSnapshotVdi.name_description" #addons>
<VtsCopyButton :value="vmSnapshotVdi.name_description" />
</template>
</VtsCardRowKeyValue>
<!-- USED SPACE -->
<SnapshotVdiUsageCardItem :usage="vmSnapshotVdi.usage" />
<!-- FORMAT -->
<VdiFormatCardItem :format="vmSnapshotVdi.image_format" />
<VtsDivider v-if="index < vmSnapshotVdis.length - 1" class="divider" type="stretch" />
</div>
</div>
</UiCard>
</template>
<script setup lang="ts">
import SnapshotVdiLinkCardItem from '@/modules/snapshot/components/list/panel/card-items/SnapshotVdiLinkCardItem.vue'
import SnapshotVdiUsageCardItem from '@/modules/snapshot/components/list/panel/card-items/SnapshotVdiUsageCardItem.vue'
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import { useXoVmSnapshotVdiCollection } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-vdi-collection.ts'
import VdiFormatCardItem from '@/modules/vdi/components/list/panel/card-items/VdiFormatCardItem.vue'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import { useI18n } from 'vue-i18n'
const { snapshot } = defineProps<{ snapshot: FrontXoVmSnapshot }>()
const { t } = useI18n()
const { vmSnapshotVdis } = useXoVmSnapshotVdiCollection({}, () => snapshot.id)
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.divider {
margin-block: 1.6rem;
}
}
}
</style>
@@ -0,0 +1,32 @@
import { useWatchCollection } from '@/shared/composables/watch-collection.composable.ts'
import { useXoCollectionState } from '@/shared/composables/xo-collection-state/use-xo-collection-state.ts'
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
import { defineRemoteResource } from '@core/packages/remote-resource/define-remote-resource.ts'
import type { XoVmSnapshot } from '@vates/types'
export type FrontXoVmSnapshot = Pick<XoVmSnapshot, (typeof vmSnapshotFields)[number]>
const vmSnapshotFields = [
'$snapshot_of',
'name_label',
'name_description',
'id',
'memory',
'snapshot_time',
'parent',
'power_state',
'other',
'creation',
'$VBDs',
] as const satisfies readonly (keyof XoVmSnapshot)[]
export const useXoVmSnapshotCollection = defineRemoteResource({
url: `${BASE_URL}/vm-snapshots?fields=${vmSnapshotFields.join(',')}`,
initWatchCollection: () => useWatchCollection({ resource: 'VM-snapshot' }),
initialData: () => [] as FrontXoVmSnapshot[],
state: (snapshots, context) =>
useXoCollectionState(snapshots, {
context,
baseName: 'snapshot',
}),
})
@@ -0,0 +1,31 @@
import { useWatchCollection } from '@/shared/composables/watch-collection.composable.ts'
import { useXoCollectionState } from '@/shared/composables/xo-collection-state/use-xo-collection-state.ts'
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
import { defineRemoteResource } from '@core/packages/remote-resource/define-remote-resource.ts'
import type { XoVdi } from '@vates/types'
export type FrontXoVmSnapshotVdi = Pick<XoVdi, (typeof vmSnapshotVdiFields)[number]>
const vmSnapshotVdiFields = [
'id',
'name_label',
'$VBDs',
'image_format',
'name_description',
'usage',
] as const satisfies readonly (keyof XoVdi)[]
export const useXoVmSnapshotVdiCollection = defineRemoteResource({
url: (snapshotId: string) => `${BASE_URL}/vm-snapshots/${snapshotId}/vdis?fields=${vmSnapshotVdiFields.join(',')}`,
initWatchCollection: () =>
useWatchCollection({
collectionId: 'vmSnapshotVdi',
resource: 'VDI',
}),
initialData: () => [] as FrontXoVmSnapshotVdi[],
state: (vmSnapshotVdis, context) =>
useXoCollectionState(vmSnapshotVdis, {
context,
baseName: 'vmSnapshotVdi',
}),
})
@@ -0,0 +1,40 @@
import { useXoBackupJobCollection } from '@/modules/backup/remote-resources/use-xo-backup-job-collection.ts'
import {
type FrontXoSchedule,
useXoScheduleCollection,
} from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { icon } from '@core/icons'
import { useI18n } from 'vue-i18n'
export function useSnapshotTrigger() {
const { t } = useI18n()
const { getScheduleById } = useXoScheduleCollection()
const { buildXo5Route } = useXoRoutes()
const { getBackupJobById } = useXoBackupJobCollection()
function getSnapshotTrigger(snapshot: FrontXoVmSnapshot) {
const scheduleId = snapshot.other?.['xo:backup:schedule'] as FrontXoSchedule['id']
if (!scheduleId) {
return { label: t('manual') }
}
const schedule = getScheduleById(scheduleId)
const href = buildXo5Route(`/backup/${schedule?.jobId}/edit`)
const backupJob = getBackupJobById(schedule?.jobId)
return {
label: schedule?.name || t('unnamed'),
href,
icon: icon('object:backup-schedule'),
suffix: backupJob?.name,
}
}
return { getSnapshotTrigger }
}
@@ -0,0 +1,12 @@
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
export function useXo5VmSnapshotRoute() {
const { buildXo5Route } = useXoRoutes()
const buildXo5VmSnapshotRoute = (vmId: FrontXoVm['id'], snapshotId: FrontXoVmSnapshot['id']) =>
buildXo5Route(`/vms/${vmId}/snapshots?s=1_0_asc-${snapshotId}`)
return { buildXo5VmSnapshotRoute }
}
@@ -0,0 +1,28 @@
import { type FrontXoVbd, useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
export type VbdAttachmentStatus = 'allAttached' | 'someAttached' | 'noneAttached'
export function useVbdsStatus(vbdIds: MaybeRefOrGetter<FrontXoVbd['id'][]>): ComputedRef<VbdAttachmentStatus> {
const { getVbdsByIds } = useXoVbdCollection()
return computed(() => {
const vbds = getVbdsByIds(toValue(vbdIds))
if (vbds.length === 0) {
return 'noneAttached'
}
const areAttached = vbds.map(vbd => vbd.attached)
if (areAttached.every(Boolean)) {
return 'allAttached'
}
if (areAttached.some(Boolean)) {
return 'someAttached'
}
return 'noneAttached'
})
}
@@ -4,6 +4,8 @@ import { BASE_URL } from '@/shared/utils/fetch.util.ts'
import { defineRemoteResource } from '@core/packages/remote-resource/define-remote-resource.ts'
import type { XoVbd } from '@vates/types'
export type FrontXoVbd = Pick<XoVbd, (typeof vbdFields)[number]>
const vbdFields = [
'id',
'VDI',
@@ -18,7 +20,7 @@ const vbdFields = [
export const useXoVbdCollection = defineRemoteResource({
url: `${BASE_URL}/vbds?fields=${vbdFields.join(',')}`,
initWatchCollection: () => useWatchCollection({ resource: 'VBD', fields: vbdFields }),
initialData: () => [] as Pick<XoVbd, (typeof vbdFields)[number]>[],
initialData: () => [] as FrontXoVbd[],
state: (vbds, context) =>
useXoCollectionState(vbds, {
context,
@@ -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/xo-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>
@@ -4,17 +4,7 @@
{{ t('configuration') }}
</UiCardTitle>
<div class="content">
<VtsCardRowKeyValue>
<template #key>
{{ t('format') }}
</template>
<template #value>
{{ format }}
</template>
<template #addons>
<VtsCopyButton :value="format" />
</template>
</VtsCardRowKeyValue>
<VdiFormatCardItem :format="vdi.image_format" />
<VtsCardRowKeyValue>
<template #key>
{{ t('storage') }}
@@ -47,8 +37,8 @@
<script setup lang="ts">
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import VdiFormatCardItem from '@/modules/vdi/components/list/panel/card-items/VdiFormatCardItem.vue'
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import { getVdiFormat } from '@/modules/vdi/utils/xo-vdi.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
@@ -69,8 +59,6 @@ const { buildXo5Route } = useXoRoutes()
const { useGetSrById } = useXoSrCollection()
const format = computed(() => getVdiFormat(vdi.image_format))
const vdiSr = useGetSrById(() => vdi.$SR)
const srHref = computed(() => (vdiSr.value ? buildXo5Route(`/srs/${vdiSr.value.id}/general`) : undefined))
@@ -44,7 +44,7 @@
</template>
<script lang="ts" setup>
import { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import { useVbdsStatus } from '@/modules/vbd/composables/use-vbds-status.composable.ts'
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import { useXoVmVbdsUtils } from '@/modules/vm/composables/xo-vm-vbd-utils.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
@@ -58,6 +58,7 @@ import UiCard from '@core/components/ui/card/UiCard.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useMapper } from '@core/packages/mapper'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -70,28 +71,19 @@ const { t } = useI18n()
const { buildXo5Route } = useXoRoutes()
const { getVbdsByIds } = useXoVbdCollection()
const vdiHref = computed(() => buildXo5Route(`/vms/${vm.id}/disks/s=1_6_asc-${vdi.id}`))
const vbdsStatus = computed(() => {
const vdiVbds = getVbdsByIds(vdi.$VBDs)
if (vdiVbds.length === 0) {
return CONNECTION_STATUS.DISCONNECTED
}
const vbdAttachmentStatus = useVbdsStatus(() => vdi.$VBDs)
const areVdiVbdsAttached = vdiVbds.map(vbd => vbd.attached)
if (areVdiVbdsAttached.every(Boolean)) {
return CONNECTION_STATUS.CONNECTED
}
if (areVdiVbdsAttached.some(Boolean)) {
return CONNECTION_STATUS.PARTIALLY_CONNECTED
}
return CONNECTION_STATUS.DISCONNECTED
})
const vbdsStatus = useMapper(
() => vbdAttachmentStatus.value,
{
allAttached: CONNECTION_STATUS.CONNECTED,
someAttached: CONNECTION_STATUS.PARTIALLY_CONNECTED,
noneAttached: CONNECTION_STATUS.DISCONNECTED,
},
'noneAttached'
)
const { notCdDriveVbds } = useXoVmVbdsUtils(() => vm)
@@ -76,6 +76,11 @@
{{ t('vdis') }}
</TabItem>
</RouterLink>
<RouterLink v-slot="{ isActive, href }" :to="{ name: `/vm/[id]/snapshots`, params: { id: vm.id } }" custom>
<TabItem :active="isActive" :href tag="a">
{{ t('snapshots') }}
</TabItem>
</RouterLink>
<RouterLink v-slot="{ isActive, href }" :to="{ name: '/vm/[id]/tasks', params: { id: vm.id } }" custom>
<TabItem :active="isActive" :href tag="a">
{{ t('tasks') }}
@@ -0,0 +1,68 @@
<template>
<UiCard class="vm-snapshot-card">
<UiCardTitle>{{ title }}</UiCardTitle>
<div class="snapshot-row-container">
<VtsQuickInfoRow :label="t('snapshot')" class="snapshot-row">
<template #value>
<UiLink
v-if="snapshot?.name_label"
icon="object:vm-snapshot"
size="medium"
:href="buildXo5VmSnapshotRoute(snapshot?.$snapshot_of, snapshot?.id)"
>
{{ snapshot.name_label }}
</UiLink>
</template>
</VtsQuickInfoRow>
<VtsCopyButton v-if="snapshot?.name_label" :value="snapshot.name_label" class="copy-button" />
</div>
<VtsQuickInfoRow :label="t('snapshot-created-on')">
<template #value>
<span v-if="formattedDate">{{ formattedDate }}</span>
</template>
</VtsQuickInfoRow>
</UiCard>
</template>
<script setup lang="ts">
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import { useXo5VmSnapshotRoute } from '@/modules/snapshot/composables/xo-vm-snapshot-route-xo5.composable.ts'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsQuickInfoRow from '@core/components/quick-info-row/VtsQuickInfoRow.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 { snapshot } = defineProps<{
snapshot: FrontXoVmSnapshot | undefined
title: string
}>()
const { t, d } = useI18n()
const formattedDate = computed(() =>
snapshot ? d(snapshot.snapshot_time * 1000, { dateStyle: 'short', timeStyle: 'medium' }) : undefined
)
const { buildXo5VmSnapshotRoute } = useXo5VmSnapshotRoute()
</script>
<style lang="postcss" scoped>
.vm-snapshot-card {
.snapshot-row-container {
position: relative;
.snapshot-row {
padding-inline-end: 2.5rem;
}
.copy-button {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
}
}
}
</style>
@@ -56,6 +56,7 @@ const vmFields = [
'$VBDs',
'snapshots',
'boot',
'parent',
] as const satisfies readonly (keyof XoVm)[]
export const useXoVmCollection = defineRemoteResource({
@@ -0,0 +1,113 @@
<template>
<div class="snapshots" :class="{ mobile: uiStore.isSmall }">
<div>
<div class="row">
<VmSnapshotCard class="last-snapshot" :snapshot="lastSnapshot" :title="t('last-snapshot')" />
<VmSnapshotCard class="last-revert" :snapshot="lastRevertSnapshot" :title="t('last-revert')" />
</div>
<UiCard class="container">
<SnapshotsTable :snapshots :vm />
</UiCard>
</div>
<SnapshotSidePanel v-if="selectedSnapshot" :snapshot="selectedSnapshot" @close="selectedSnapshot = undefined" />
<UiPanel v-else-if="!uiStore.isSmall">
<VtsStateHero format="panel" type="no-selection" size="medium">
{{ t('select-to-see-details') }}
</VtsStateHero>
</UiPanel>
</div>
</template>
<script setup lang="ts">
import SnapshotSidePanel from '@/modules/snapshot/components/list/panel/SnapshotSidePanel.vue'
import {
type FrontXoVmSnapshot,
useXoVmSnapshotCollection,
} from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import SnapshotsTable from '@/modules/snapshot/components/SnapshotsTable.vue'
import VmSnapshotCard from '@/modules/vm/components/snapshot/cards/VmSnapshotCard.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiPanel from '@core/components/ui/panel/UiPanel.vue'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useArrayFilter } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { vm } = defineProps<{
vm: FrontXoVm
}>()
const { snapshots: snapshotsCollection, getSnapshotById } = useXoVmSnapshotCollection()
const uiStore = useUiStore()
const { t } = useI18n()
const snapshots = useArrayFilter(snapshotsCollection, snapshot => snapshot.$snapshot_of === vm.id)
const selectedSnapshot = useRouteQuery<FrontXoVmSnapshot | undefined>('id', {
toData: id => getSnapshotById(id as FrontXoVmSnapshot['id']),
toQuery: snapshot => snapshot?.id ?? '',
})
const lastSnapshot = computed(() => {
if (snapshots.value.length === 0) {
return undefined
}
return snapshots.value.reduce((latest, current) => {
return current.snapshot_time > latest.snapshot_time ? current : latest
})
})
const lastRevertSnapshot = computed(() => {
if (snapshots.value.length === 0 || !vm.parent) {
return undefined
}
return snapshots.value.find(snapshot => snapshot.id === vm.parent)
})
</script>
<style scoped lang="postcss">
.snapshots {
display: grid;
grid-template-columns: minmax(0, 1fr) 40rem;
.container {
height: fit-content;
margin: 0.8rem;
gap: 4rem;
}
.row {
display: grid;
gap: 0.8rem;
margin: 0.8rem;
grid-template-columns: repeat(2, minmax(20rem, 1fr));
grid-template-areas: 'last-snapshot last-revert';
}
.last-snapshot {
grid-area: last-snapshot;
}
.last-revert {
grid-area: last-revert;
}
&.mobile {
display: flex;
flex-direction: column;
gap: 0.8rem;
.row {
display: flex;
flex-direction: column;
}
}
}
</style>
+1
View File
@@ -59,6 +59,7 @@ declare module 'vue-router/auto-routes' {
'/vm/[id]/console': RouteRecordInfo<'/vm/[id]/console', '/vm/:id/console', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/dashboard': RouteRecordInfo<'/vm/[id]/dashboard', '/vm/:id/dashboard', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/networks': RouteRecordInfo<'/vm/[id]/networks', '/vm/:id/networks', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/snapshots': RouteRecordInfo<'/vm/[id]/snapshots', '/vm/:id/snapshots', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/system': RouteRecordInfo<'/vm/[id]/system', '/vm/:id/system', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/tasks': RouteRecordInfo<'/vm/[id]/tasks', '/vm/:id/tasks', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/vdis': RouteRecordInfo<'/vm/[id]/vdis', '/vm/:id/vdis', { id: ParamValue<true> }, { id: ParamValue<false> }>,
+1
View File
@@ -28,6 +28,7 @@
- [REST API] Expose `POST /rest/v0/pools/:id/actions/create_bonded_network` and `POST /rest/v0/pools/:id/actions/create_internal_network` (PR [#9611](https://github.com/vatesfr/xen-orchestra/pull/9611))
- [REST API] Added `nbd` param in network creation endpoints (PR [#9611](https://github.com/vatesfr/xen-orchestra/pull/9611))
- [VM] Add backup replication card on dashboard (PR [#9545](https://github.com/vatesfr/xen-orchestra/pull/9545))
- [VM/Snapshot] Add VM snapshot tab page (PR [#9521] (https://github.com/vatesfr/xen-orchestra/pull/9521))
### Bug fixes