feat(web): add the review step of the V2V migration wizard

This commit is contained in:
Olivier Floch
2026-10-30 10:43:26 +01:00
parent 574e427b0a
commit 104cdfbcb1
45 changed files with 2057 additions and 256 deletions

View File

@@ -21,6 +21,7 @@
"action:attach": "Attach",
"action:attach-vdi": "@:attach-vdi",
"action:back": "Back",
"action:back-to-vms": "Back to VMs",
"action:boot-vm": "Boot VM",
"action:change-state": "Change state",
"action:clear": "Clear",
@@ -123,6 +124,7 @@
"action:import-export": "Import/Export",
"action:install-app": "Install {name}",
"action:install-guest-tools": "Install guest tools",
"action:launch-n-vms": "Launch 1 VM | Launch {n} VMs",
"action:load": "Load",
"action:load-now": "Load now",
"action:log-out": "Log out",
@@ -159,6 +161,7 @@
"action:see-all": "See all",
"action:see-less": "See less",
"action:see-n-more": "See {n} more",
"action:see-task": "See the task",
"action:select-all": "Select all",
"action:select-all-n-objects": "select {n} object | select all {n} objects",
"action:select-n-objects-of": "{from} - {to} of {total} objects",
@@ -415,6 +418,7 @@
"destination-network": "Destination network",
"destination-network-required": "Destination network is required.",
"destination-pool": "Destination pool",
"destination-pool-named": "Destination pool {name}",
"destination-sr": "Destination SR",
"destination-vm": "Destination VM",
"destination-sr-required": "Destination SR is required.",
@@ -942,6 +946,7 @@
"n-internal-networks": "1 internal network | {n} internal networks",
"n-migrated": "1 migrated | {n} migrated",
"n-missing": "{n} missing",
"n-need-confirmation": "1 needs confirmation | {n} need confirmation",
"n-networks": "1 network | {n} networks",
"n-nics": "1 NIC | {n} NICs",
"n-not-migrated": "1 not migrated | {n} not migrated",
@@ -958,6 +963,7 @@
"n-vdis": "1 VDI | {n} VDIs",
"n-vifs": "1 VIF | {n} VIFs",
"n-vms": "1 VM | {n} VMs",
"n-vms-in-list": "1 VM in the list | {n} VMs in the list",
"n-warnings": "1 warning | {n} warnings",
"name": "Name",
"name-required": "Name is required.",
@@ -1281,6 +1287,7 @@
"retry": "Retry",
"review": "Review",
"review-and-confirm": "Review & confirm",
"review-and-launch": "Review and launch",
"roles": "Roles",
"roles-applied": "Roles applied",
"root-by-default": "\"root\" by default.",
@@ -1529,6 +1536,7 @@
"uuid": "UUID",
"v2v": "V2V",
"v2v:action:add-to-migration-list": "Add to migration list",
"v2v:already-fully-migrated": "Already fully migrated",
"v2v:already-migrated-confirm-at-launch": "Already migrated, confirmation at launch",
"v2v:check:mapping-incomplete": "Mapping incomplete",
"v2v:check:n-snapshots": "1 snapshot on the source VM | {n} snapshots on the source VM",
@@ -1538,17 +1546,28 @@
"v2v:clean-up-preview": "Clean up (preview)",
"v2v:clean-up-vm": "Automatically clean up the VM",
"v2v:connect-new-vmware-source": "Connect a new VMware source",
"v2v:continues-on-pool-sr": "continues on pool {pool}, SR {srs}",
"v2v:create-second-copy": "Create a second copy of {name}",
"v2v:credentials-never-stored": "Credentials are never stored by XO: they stay in this browser and are sent with each request that needs VMware. Migration status is shared by everyone who connects to the same vCenter.",
"v2v:cutover-done-on": "Cutover done on {date}.",
"v2v:default-options-info": "Apply to every VM unless overridden below",
"v2v:delta-from-last-sync": "Delta from last sync",
"v2v:destination-pool-info": "A migration goes from one source to one pool. Each source and pool pair has its own list.",
"v2v:existing-list-info": "This browser already holds a migration list for {migration} ({vms}). Continuing reopens it, with the current migration state of every VM.",
"v2v:filter-vms-info": "filter by datastore, status or name with the query builder",
"v2v:global-defaults-info": "Applied to every VM, disk and network adapter. Each VM can override them in step 4 (Mapping). The SR and network are prefilled with the pool default SR and the management network of the pool master.",
"v2v:launch-group:excluded": "Excluded",
"v2v:launch-group:new": "New",
"v2v:launch-group:resumed": "Resumed",
"v2v:launch-problems": "The migration cannot be started",
"v2v:launch-success": "The migration is running: its task tells its progress.",
"v2v:list-pair-info": "One source and one destination per list: choosing another source or pool opens its own list.",
"v2v:locked-since-first-sync": "Locked since the first sync",
"v2v:mac-kept": "MAC kept",
"v2v:mapping-locked": "mapping locked",
"v2v:migrated-vm-details": "Existing VM: {vm} on pool {pool}, SR {srs}. A second VM will be created on pool {destinationPool}.",
"v2v:n-excluded": "1 excluded | {n} excluded",
"v2v:n-vms-launched": "1 VM launched | {n} VMs launched",
"v2v:name-from-migration-list": "{name} from the migration list",
"v2v:next-run-reminder": "The longer since the last sync, the larger the final delta. Run a delta sync shortly before the cutover to keep the downtime short.",
"v2v:no-bak-info": "Excluded from backups: {tag} (removed at cutover)",
@@ -1578,6 +1597,7 @@
"v2v:unsupported-encrypted-disk": "Unsupported: encrypted disk",
"v2v:unsupported-independent-disk": "Unsupported: independent disk",
"v2v:unsupported-rdm-disk": "Unsupported: RDM disk",
"v2v:vm-start-problem": "{vm}: {message}",
"value-by-default": "{value} by default",
"value-from": "Value from",
"vcpu": "vCPU",

View File

@@ -21,6 +21,7 @@
"action:attach": "Attacher",
"action:attach-vdi": "@:attach-vdi",
"action:back": "Retour",
"action:back-to-vms": "Retour aux VMs",
"action:boot-vm": "Démarrer la VM",
"action:change-state": "Changer l'état",
"action:clear": "Vider",
@@ -123,6 +124,7 @@
"action:import-export": "Importer/Exporter",
"action:install-app": "Installer {name}",
"action:install-guest-tools": "Installer les guest tools",
"action:launch-n-vms": "Lancer 0 VM | Lancer 1 VM | Lancer {n} VMs",
"action:load": "Charger",
"action:load-now": "Charger maintenant",
"action:log-out": "Se déconnecter",
@@ -159,6 +161,7 @@
"action:see-all": "Voir tout",
"action:see-less": "Voir moins",
"action:see-n-more": "Voir {n} de plus",
"action:see-task": "Voir la tâche",
"action:select-all": "Tout sélectionner",
"action:select-all-n-objects": "sélectionner {n} objet | sélectionner les {n} objets",
"action:select-n-objects-of": "{from} - {to} de {total} objets",
@@ -415,6 +418,7 @@
"destination-network": "Réseau de destination",
"destination-network-required": "Le réseau de destination est requis.",
"destination-pool": "Pool de destination",
"destination-pool-named": "Pool de destination {name}",
"destination-sr": "SR de destination",
"destination-vm": "VM de destination",
"destination-sr-required": "Le SR de destination est requis.",
@@ -942,6 +946,7 @@
"n-internal-networks": "1 réseau interne | {n} réseaux internes",
"n-migrated": "0 migrée | 1 migrée | {n} migrées",
"n-missing": "{n} manquant | {n} manquants",
"n-need-confirmation": "1 à confirmer | {n} à confirmer",
"n-networks": "0 réseau | 1 réseau | {n} réseaux",
"n-nics": "0 carte réseau | 1 carte réseau | {n} cartes réseau",
"n-not-migrated": "0 non migrée | 1 non migrée | {n} non migrées",
@@ -958,6 +963,7 @@
"n-vdis": "0 VDI | 1 VDI | {n} VDIs",
"n-vifs": "0 VIF | 1 VIF | {n} VIFs",
"n-vms": "0 VM | 1 VM | {n} VMs",
"n-vms-in-list": "0 VM dans la liste | 1 VM dans la liste | {n} VMs dans la liste",
"n-warnings": "0 avertissement | 1 avertissement | {n} avertissements",
"name": "Nom",
"name-required": "Le nom est requis.",
@@ -1281,6 +1287,7 @@
"retry": "Réessayer",
"review": "Revue",
"review-and-confirm": "Vérifier et confirmer",
"review-and-launch": "Revue et lancement",
"roles": "Rôles",
"roles-applied": "Rôles appliqués",
"root-by-default": "\"root\" par défaut.",
@@ -1529,6 +1536,7 @@
"uuid": "UUID",
"v2v": "V2V",
"v2v:action:add-to-migration-list": "Ajouter à la liste de migration",
"v2v:already-fully-migrated": "Déjà entièrement migrées",
"v2v:already-migrated-confirm-at-launch": "Déjà migrée, confirmation au lancement",
"v2v:check:mapping-incomplete": "Correspondance incomplète",
"v2v:check:n-snapshots": "0 instantané sur la VM source | 1 instantané sur la VM source | {n} instantanés sur la VM source",
@@ -1538,17 +1546,28 @@
"v2v:clean-up-preview": "Nettoyage (aperçu)",
"v2v:clean-up-vm": "Nettoyer automatiquement la VM",
"v2v:connect-new-vmware-source": "Connecter une nouvelle source VMware",
"v2v:continues-on-pool-sr": "continue sur le pool {pool}, SR {srs}",
"v2v:create-second-copy": "Créer une seconde copie de {name}",
"v2v:credentials-never-stored": "Les identifiants ne sont jamais stockés par XO : ils restent dans ce navigateur et sont envoyés avec chaque requête qui nécessite VMware. L'état des migrations est partagé par tous ceux qui se connectent au même vCenter.",
"v2v:cutover-done-on": "Bascule effectuée le {date}.",
"v2v:default-options-info": "S’appliquent à chaque VM, sauf si elles sont remplacées ci-dessous",
"v2v:delta-from-last-sync": "Delta depuis la dernière synchronisation",
"v2v:destination-pool-info": "Une migration va d’une source vers un pool. Chaque couple source et pool a sa propre liste.",
"v2v:existing-list-info": "Ce navigateur contient déjà une liste de migration pour {migration} ({vms}). Continuer la rouvre, avec l’état de migration actuel de chaque VM.",
"v2v:filter-vms-info": "filtrer par banque de données, statut ou nom avec le constructeur de requête",
"v2v:global-defaults-info": "Appliquées à chaque VM, disque et carte réseau. Chaque VM peut les remplacer à l’étape 4 (Correspondance). Le SR et le réseau sont préremplis avec le SR par défaut du pool et le réseau de management du maître du pool.",
"v2v:launch-group:excluded": "Exclues",
"v2v:launch-group:new": "Nouvelles",
"v2v:launch-group:resumed": "Reprises",
"v2v:launch-problems": "La migration ne peut pas être lancée",
"v2v:launch-success": "La migration est en cours : sa tâche indique sa progression.",
"v2v:list-pair-info": "Une source et une destination par liste : choisir une autre source ou un autre pool ouvre sa propre liste.",
"v2v:locked-since-first-sync": "Verrouillée depuis la première synchronisation",
"v2v:mac-kept": "MAC conservée",
"v2v:mapping-locked": "correspondance verrouillée",
"v2v:migrated-vm-details": "VM existante : {vm} sur le pool {pool}, SR {srs}. Une seconde VM sera créée sur le pool {destinationPool}.",
"v2v:n-excluded": "1 exclue | {n} exclues",
"v2v:n-vms-launched": "0 VM lancée | 1 VM lancée | {n} VMs lancées",
"v2v:name-from-migration-list": "{name} de la liste de migration",
"v2v:next-run-reminder": "Plus la dernière synchronisation est ancienne, plus le delta final est important. Lancez une synchronisation delta peu avant la bascule pour réduire l’interruption.",
"v2v:no-bak-info": "Exclue des sauvegardes : {tag} (retiré à la bascule)",
@@ -1578,6 +1597,7 @@
"v2v:unsupported-encrypted-disk": "Non prise en charge : disque chiffré",
"v2v:unsupported-independent-disk": "Non prise en charge : disque indépendant",
"v2v:unsupported-rdm-disk": "Non prise en charge : disque RDM",
"v2v:vm-start-problem": "{vm} : {message}",
"value-by-default": "{value} par défaut",
"value-from": "Valeur issue de",
"vcpu": "vCPU",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,67 @@
import PoolConnectionSuccess from '@/modules/pool/components/connection/PoolConnectionSuccess.vue'
import type {
FrontXoServer,
useXoServerCollection,
} from '@/modules/server/remote-resources/use-xo-server-collection.ts'
import { createServer } from '@/test/create-server.ts'
import { getButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { mount } from '@vue/test-utils'
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue'
const servers = ref<FrontXoServer[]>([])
vi.mock(import('@/modules/server/remote-resources/use-xo-server-collection.ts'), () => ({
useXoServerCollection: (() => ({
useGetServerById: (id: MaybeRefOrGetter<FrontXoServer['id']>) =>
computed(() => servers.value.find(server => server.id === toValue(id))),
})) as unknown as typeof useXoServerCollection,
}))
const SERVER = createServer({ host: '10.0.0.1' })
function mountSuccess() {
return mount(PoolConnectionSuccess, { props: { serverId: SERVER.id }, global: createGlobalTestConfig() })
}
beforeEach(() => {
servers.value = [SERVER]
})
it('tells the IP address the pool is connected to', () => {
expect(mountSuccess().get('.title').text()).toBe(t('connected-to-ip', { ip: '10.0.0.1' }))
})
it('tells the connection succeeded', () => {
const alert = mountSuccess().getComponent(UiAlert)
expect(alert.props('accent')).toBe('success')
expect(alert.text()).toBe(t('pool-connection-success'))
})
it('links to the dashboard of the connected pool', () => {
const link = mountSuccess().getComponent(UiLink)
expect(link.text()).toBe(t('action:visit-pool-dashboard'))
expect(link.props('to')).toEqual({ name: '/pool/[id]/dashboard', params: { id: SERVER.poolId } })
})
it('offers to connect another pool', async () => {
const wrapper = mountSuccess()
await getButton(wrapper, t('action:connect-another-pool')).trigger('click')
expect(wrapper.emitted('connectAnotherPool')).toHaveLength(1)
})
it('has neither title nor link while the server is unknown', () => {
servers.value = []
const wrapper = mountSuccess()
expect(wrapper.find('.title').exists()).toBe(false)
expect(wrapper.findComponent(UiLink).exists()).toBe(false)
})

View File

@@ -1,32 +1,26 @@
<template>
<VtsStateHero class="connection-success" type="all-good" format="card" size="medium">
<div class="container">
<div v-if="server" class="typo-h1 title">{{ t('connected-to-ip', { ip: server.host }) }}</div>
<div class="content" :class="{ mobile: uiStore.isSmall, desktop: !uiStore.isLarge }">
<UiAlert accent="success">
{{ t('pool-connection-success') }}
</UiAlert>
<div class="action-buttons" :class="{ mobile: uiStore.isSmall }">
<UiLink
v-if="server && server.poolId"
:to="{ name: '/pool/[id]/dashboard', params: { id: server.poolId } }"
size="medium"
>
{{ t('action:visit-pool-dashboard') }}
</UiLink>
<UiButton
variant="secondary"
accent="brand"
size="medium"
left-icon="fa:plus"
@click="emit('connectAnotherPool')"
>
{{ t('action:connect-another-pool') }}
</UiButton>
</div>
</div>
</div>
</VtsStateHero>
<VtsSuccessStateHero>
<template v-if="server" #title>{{ t('connected-to-ip', { ip: server.host }) }}</template>
{{ t('pool-connection-success') }}
<template #actions>
<UiLink
v-if="server && server.poolId"
:to="{ name: '/pool/[id]/dashboard', params: { id: server.poolId } }"
size="medium"
>
{{ t('action:visit-pool-dashboard') }}
</UiLink>
<UiButton
variant="secondary"
accent="brand"
size="medium"
left-icon="fa:plus"
@click="emit('connectAnotherPool')"
>
{{ t('action:connect-another-pool') }}
</UiButton>
</template>
</VtsSuccessStateHero>
</template>
<script setup lang="ts">
@@ -34,11 +28,9 @@ import {
type FrontXoServer,
useXoServerCollection,
} from '@/modules/server/remote-resources/use-xo-server-collection.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import VtsSuccessStateHero from '@core/components/success-state-hero/VtsSuccessStateHero.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useI18n } from 'vue-i18n'
const { serverId } = defineProps<{
@@ -51,49 +43,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const { useGetServerById } = useXoServerCollection()
const uiStore = useUiStore()
const server = useGetServerById(() => serverId)
</script>
<style lang="postcss" scoped>
.connection-success {
.container {
display: flex;
flex-direction: column;
gap: 4rem;
}
.title {
color: var(--color-neutral-txt-primary);
}
.content {
color: var(--color-neutral-txt-primary);
display: flex;
flex-direction: column;
gap: 4rem;
margin: 0 auto;
width: 65%;
&.desktop {
width: 80%;
}
&.mobile {
width: 100%;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 2.4rem;
&.mobile {
flex-direction: column;
align-items: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
import V2vLaunchGroup from '@/modules/v2v/components/review/V2vLaunchGroup.vue'
import { findCardTitleLabel } from '@/test/find-card-title.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import { mount } from '@vue/test-utils'
const HEADING = { label: 'Excluded', accent: 'danger' } as const
function mountGroup(count: number) {
return mount(V2vLaunchGroup, {
props: { heading: HEADING, count },
slots: { default: '<p class="vms">mail-01</p>' },
global: createGlobalTestConfig(),
})
}
it('titles the group with its label', () => {
expect(findCardTitleLabel(mountGroup(2))).toContain(HEADING.label)
})
it('counts its VMs with the accent of the group', () => {
expect(mountGroup(2).getComponent(UiCounter).props()).toMatchObject({ value: 2, accent: 'danger' })
})
it('shows its VMs', () => {
expect(mountGroup(2).get('.vms').text()).toBe('mail-01')
})
it('is hidden without VM', () => {
expect(mountGroup(0).findComponent(UiCard).exists()).toBe(false)
})

View File

@@ -0,0 +1,25 @@
<template>
<UiCard v-if="count > 0">
<UiCardTitle>
{{ heading.label }}
<UiCounter :value="count" :accent="heading.accent" size="medium" variant="primary" />
</UiCardTitle>
<slot />
</UiCard>
</template>
<script lang="ts" setup>
import type { V2vAccentedLabel } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiCounter, { type CounterAccent } from '@core/components/ui/counter/UiCounter.vue'
defineProps<{
heading: V2vAccentedLabel<CounterAccent>
count: number
}>()
defineSlots<{
default(): any
}>()
</script>

View File

@@ -0,0 +1,14 @@
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { mount } from '@vue/test-utils'
function mountVm() {
return mount(V2vLaunchGroupVm, {
slots: { name: 'web-01', default: '<span class="details">Delta from last sync</span>' },
global: createGlobalTestConfig(),
})
}
it('names the VM, then tells its details', () => {
expect(mountVm().text()).toBe('web-01Delta from last sync')
})

View File

@@ -0,0 +1,21 @@
<template>
<div class="v2v-launch-group-vm typo-body-regular-small">
<span class="typo-body-bold-small"><slot name="name" /></span>
<slot />
</div>
</template>
<script lang="ts" setup>
defineSlots<{
name(): any
default(): any
}>()
</script>
<style lang="postcss" scoped>
.v2v-launch-group-vm {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
</style>

View File

@@ -0,0 +1,76 @@
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
import V2vMigratedVmItem from '@/modules/v2v/components/review/V2vMigratedVmItem.vue'
import type { V2vMigratedVm } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import { createV2vMigratedVm } from '@/test/create-v2v-launch.ts'
import { getCheckbox } from '@/test/get-checkbox.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { mount } from '@vue/test-utils'
const DESTINATION_POOL_NAME = 'diskor'
function mountItem(vm: V2vMigratedVm = createV2vMigratedVm()) {
return mount(V2vMigratedVmItem, {
props: { vm, destinationPoolName: DESTINATION_POOL_NAME },
global: createGlobalTestConfig(),
})
}
function getDetails(vm: V2vMigratedVm) {
return t('v2v:migrated-vm-details', {
vm: vm.importVm.name,
pool: vm.poolName,
srs: vm.srs,
destinationPool: DESTINATION_POOL_NAME,
})
}
function getSecondCopyLabel(vm: V2vMigratedVm) {
return t('v2v:create-second-copy', { name: vm.name })
}
// The VM name, then what the sentence tells of it
function findVmText(wrapper: ReturnType<typeof mountItem>) {
return wrapper.getComponent(V2vLaunchGroupVm).text()
}
it('names the VM, then tells when it was cut over, where it is and that a second VM will be created', () => {
const vm = createV2vMigratedVm()
expect(findVmText(mountItem(vm))).toBe(`${vm.name}${vm.cutover}${getDetails(vm)}`)
})
it('tells where the VM is without its cutover date once unknown', () => {
const vm = createV2vMigratedVm({ cutover: undefined })
expect(findVmText(mountItem(vm))).toBe(`${vm.name}${getDetails(vm)}`)
})
it('links to the dashboard of the existing VM', () => {
const vm = createV2vMigratedVm()
expect(mountItem(vm).getComponent(UiLink).props('to')).toEqual({
name: '/vm/[id]/dashboard',
params: { id: vm.importVm.id },
})
})
it.each([false, true])('offers a second copy of the VM, confirmed: %s', isConfirmed => {
const vm = createV2vMigratedVm({ isConfirmed })
expect(mountItem(vm).getComponent(FormCheckbox).props()).toMatchObject({
label: getSecondCopyLabel(vm),
modelValue: isConfirmed,
})
})
it.each([true, false])('confirms the second copy once set to %s', async isConfirmed => {
const vm = createV2vMigratedVm({ isConfirmed: !isConfirmed })
const wrapper = mountItem(vm)
await getCheckbox(wrapper, getSecondCopyLabel(vm)).setValue(isConfirmed)
expect(wrapper.emitted('confirm')).toEqual([[isConfirmed]])
})

View File

@@ -0,0 +1,50 @@
<template>
<div class="v2v-migrated-vm-item">
<V2vLaunchGroupVm>
<template #name>{{ vm.name }}</template>
<span v-if="vm.cutover !== undefined">{{ vm.cutover }}</span>
<I18nT keypath="v2v:migrated-vm-details" scope="global" tag="span">
<template #vm>
<UiLink size="small" display-inline :to="{ name: '/vm/[id]/dashboard', params: { id: vm.importVm.id } }">
{{ vm.importVm.name }}
</UiLink>
</template>
<template #pool>{{ vm.poolName }}</template>
<template #srs>{{ vm.srs }}</template>
<template #destinationPool>{{ destinationPoolName }}</template>
</I18nT>
</V2vLaunchGroupVm>
<FormCheckbox
:label="t('v2v:create-second-copy', { name: vm.name })"
:model-value="vm.isConfirmed"
@update:model-value="value => emit('confirm', value)"
/>
</div>
</template>
<script lang="ts" setup>
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
import type { V2vMigratedVm } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { I18nT, useI18n } from 'vue-i18n'
defineProps<{
vm: V2vMigratedVm
destinationPoolName: string
}>()
const emit = defineEmits<{
confirm: [isConfirmed: boolean]
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-migrated-vm-item {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
</style>

View File

@@ -0,0 +1,289 @@
import { getTaskRoute } from '@/modules/task/utils/xo-task.util.ts'
import V2vLaunchGroup from '@/modules/v2v/components/review/V2vLaunchGroup.vue'
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
import V2vLaunchOptionsCard from '@/modules/v2v/components/review/V2vLaunchOptionsCard.vue'
import V2vMigratedVmItem from '@/modules/v2v/components/review/V2vMigratedVmItem.vue'
import V2vReviewStep from '@/modules/v2v/components/review/V2vReviewStep.vue'
import V2vVmLaunchOptionsTable from '@/modules/v2v/components/review/V2vVmLaunchOptionsTable.vue'
import { createV2vMigrationList } from '@/test/create-v2v-migration-list.ts'
import { findCardTitleLabel } from '@/test/find-card-title.ts'
import { findParts } from '@/test/find-parts.ts'
import { getButton, getUiButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { vmDeviceCollectionsMock } from '@/test/mock-xo-vm-device-collections.ts'
import { mountOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vReviewStep } from '@/test/mount-v2v-review-step.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { stubFetchResponse } from '@/test/stub-fetch-response.ts'
import { V2V_GONE_VM_ID } from '@/test/v2v-mapping-step-fixtures.ts'
import { V2V_DB_VM, V2V_LISTED_VM, V2V_WEB_VM } from '@/test/v2v-vms-fixtures.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import type { XoTask } from '@vates/types'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'),
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
vi.mock(
import('@/modules/task/remote-resources/use-xo-task-collection.ts'),
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
vi.mock(
import('@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'),
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
vi.mock(
import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/network/remote-resources/use-xo-network-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'),
() => import('@/test/mock-xo-vm-device-collections.ts')
)
vi.mock(
import('@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'),
() => import('@/test/mock-xo-vm-device-collections.ts')
)
vi.mock(import('@core/composables/modals/use-delete-modal.ts'), () => import('@/test/mock-delete-modal.ts'))
// What each group and line says belongs to useV2vReviewStep and the labels: here only the wiring matters
async function mountStep(options?: Parameters<typeof mountV2vReviewStep>[1]) {
const { mounted: wrapper, router } = await mountV2vReviewStep(
setup =>
mountOnV2vWizard(
router =>
mount(V2vReviewStep, {
global: createGlobalTestConfig({
router,
stubs: { V2vLaunchOptionsCard: true, V2vVmLaunchOptionsTable: true },
}),
}),
setup
),
options
)
return { wrapper, router }
}
// A group by its heading
function getGroup(wrapper: VueWrapper, headingKey: string) {
const group = wrapper.findAllComponents(V2vLaunchGroup).find(found => found.props('heading').label === t(headingKey))
if (group === undefined) {
throw new Error(`No group "${headingKey}"`)
}
return group
}
const LAUNCH_2_VMS = t('action:launch-n-vms', { n: 2 })
async function launch(wrapper: VueWrapper) {
await getButton(wrapper, LAUNCH_2_VMS).trigger('click')
await flushPromises()
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
vmDeviceCollectionsMock.reset()
})
it('shows the step busy while the VMs load', async () => {
const { wrapper } = await mountStep()
v2vVmRowsCollectionsMock.areVmsReady.value = false
await flushPromises()
expect(wrapper.getComponent(VtsStateHero).props('type')).toBe('busy')
expect(wrapper.findComponent(V2vLaunchOptionsCard).exists()).toBe(false)
})
it('heads the review with its summary', async () => {
const { wrapper } = await mountStep()
expect(findCardTitleLabel(wrapper)).toBe(t('review-and-launch'))
expect(findParts(wrapper.getComponent(UiCardTitle), '.inline-parts')).toHaveLength(4)
})
it('shows the default options, then the options of each launchable VM', async () => {
const { wrapper } = await mountStep()
expect(wrapper.getComponent(V2vLaunchOptionsCard).props('stopSourceCheckboxBindings')).toMatchObject({
modelValue: true,
})
expect(wrapper.getComponent(V2vVmLaunchOptionsTable).props()).toMatchObject({
rows: [{ id: V2V_WEB_VM.id }, { id: V2V_DB_VM.id }],
defaultStopSource: true,
})
})
it('overrides stopping the source of a VM from its row', async () => {
const { wrapper } = await mountStep()
const table = wrapper.getComponent(V2vVmLaunchOptionsTable)
table.vm.$emit('changeStopSource', V2V_DB_VM.id, false)
await flushPromises()
expect(table.props('rows')[1].overrides).toEqual({ stopSource: false })
})
it('shows no per-VM options without launchable VM', async () => {
const list = createV2vMigrationList({ poolId: V2V_LIST_POOL.id, vms: [{ id: V2V_GONE_VM_ID }] })
const { wrapper } = await mountStep({ list })
expect(wrapper.findComponent(V2vVmLaunchOptionsTable).exists()).toBe(false)
})
it('links an excluded VM to its mapping, with its reason', async () => {
const { wrapper } = await mountStep()
const excluded = getGroup(wrapper, 'v2v:launch-group:excluded').getComponent(V2vLaunchGroupVm)
expect(excluded.getComponent(UiLink).props('to')).toEqual({
query: { pool: V2V_LIST_POOL.id, step: 'mapping', vm: V2V_GONE_VM_ID },
})
expect(excluded.text()).toBe(`${V2V_GONE_VM_ID}${t('v2v:not-found-in-source')}`)
})
it('names a resumed VM with where it continues', async () => {
const { wrapper } = await mountStep()
const resumed = getGroup(wrapper, 'v2v:launch-group:resumed').getComponent(V2vLaunchGroupVm)
expect(resumed.text()).toMatch(new RegExp(`^${V2V_DB_VM.name}`))
expect(findParts(resumed, '.inline-parts')).toHaveLength(3)
})
it('names the new VMs', async () => {
const { wrapper } = await mountStep()
expect(getGroup(wrapper, 'v2v:launch-group:new').text()).toContain(V2V_WEB_VM.name)
})
it('launches an already migrated VM once its second copy is confirmed', async () => {
const { wrapper } = await mountStep()
const item = wrapper.getComponent(V2vMigratedVmItem)
expect(item.props()).toMatchObject({ vm: { id: V2V_LISTED_VM.id }, destinationPoolName: V2V_LIST_POOL.name_label })
item.vm.$emit('confirm', true)
await flushPromises()
expect(getUiButton(wrapper, t('action:launch-n-vms', { n: 3 })).props('disabled')).toBe(false)
})
it('no longer launches an already migrated VM once its second copy is unconfirmed', async () => {
const { wrapper } = await mountStep()
const item = wrapper.getComponent(V2vMigratedVmItem)
item.vm.$emit('confirm', true)
item.vm.$emit('confirm', false)
await flushPromises()
expect(getUiButton(wrapper, LAUNCH_2_VMS).props('disabled')).toBe(false)
})
it('cannot launch without launchable VM', async () => {
const list = createV2vMigrationList({ poolId: V2V_LIST_POOL.id, vms: [{ id: V2V_GONE_VM_ID }] })
const { wrapper } = await mountStep({ list })
expect(getUiButton(wrapper, t('action:launch-n-vms', { n: 0 })).props('disabled')).toBe(true)
})
it('is busy while the launch runs', async () => {
const { wrapper } = await mountStep()
// The default `fetch` stub never settles
await launch(wrapper)
expect(getUiButton(wrapper, LAUNCH_2_VMS).props('busy')).toBe(true)
})
it('tells the problems the back end refused the launch for', async () => {
silenceConsoleError()
stubFetchResponse(422, { error: 'the migration cannot be started', data: { errors: [{ message: 'tags' }] } })
const { wrapper } = await mountStep()
await launch(wrapper)
const alert = wrapper.getComponent(UiAlert)
expect(alert.props('accent')).toBe('danger')
expect(alert.text()).toBe(`${t('v2v:launch-problems')} tags`)
})
it('tells any other failure as an error', async () => {
silenceConsoleError()
stubFetchResponse(500, { error: 'unreachable' })
const { wrapper } = await mountStep()
await launch(wrapper)
expect(wrapper.getComponent(UiAlert).text()).toBe(`${t('error')} unreachable`)
})
describe('once launched', () => {
async function mountLaunched() {
stubFetchResponse(202, { taskId: 'task-1' })
const step = await mountStep()
await launch(step.wrapper)
return step
}
it('tells the VMs are launched', async () => {
const { wrapper } = await mountLaunched()
const hero = wrapper.getComponent(VtsStateHero)
expect(hero.props('type')).toBe('all-good')
expect(hero.get('.title').text()).toBe(t('v2v:n-vms-launched', { n: 2 }))
expect(hero.getComponent(UiAlert).text()).toBe(t('v2v:launch-success'))
})
it('links to the task of the launch', async () => {
const link = (await mountLaunched()).wrapper.getComponent(UiLink)
expect(link.text()).toBe(t('action:see-task'))
expect(link.props('to')).toEqual(getTaskRoute('task-1' as XoTask['id']))
})
it('goes back to the VMs step', async () => {
const { wrapper, router } = await mountLaunched()
await getButton(wrapper, t('action:back-to-vms')).trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.step).toBe('vms')
})
})
it('goes back to the mapping step', async () => {
const { wrapper, router } = await mountStep()
await getButton(wrapper, t('action:back')).trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.step).toBe('mapping')
})

View File

@@ -0,0 +1,128 @@
<template>
<VtsStateHero v-if="isBusy || destinationPoolName === undefined" format="page" type="busy" size="medium" />
<VtsSuccessStateHero v-else-if="launched !== undefined">
<template #title>{{ t('v2v:n-vms-launched', { n: launched.count }) }}</template>
{{ t('v2v:launch-success') }}
<template #actions>
<UiLink :to="launched.taskRoute" size="medium">{{ t('action:see-task') }}</UiLink>
<UiButton variant="secondary" accent="brand" size="medium" @click="backToVms()">
{{ t('action:back-to-vms') }}
</UiButton>
</template>
</VtsSuccessStateHero>
<div v-else class="v2v-review-step">
<UiCardTitle>
{{ t('review-and-launch') }}
<template #description>
<InlineParts>
<span v-for="part of summaryParts" :key="part">{{ part }}</span>
</InlineParts>
</template>
</UiCardTitle>
<V2vLaunchOptionsCard :stop-source-checkbox-bindings :stop-on-error-checkbox-bindings :concurrency-input-bindings />
<V2vVmLaunchOptionsTable
v-if="vmLaunchOptionsRows.length > 0"
:rows="vmLaunchOptionsRows"
:default-stop-source="formData.stopSource"
@change-stop-source="(id, value) => changeStopSource(id, value)"
/>
<V2vLaunchGroup :heading="groupHeadings.excluded" :count="excludedVms.length">
<V2vLaunchGroupVm v-for="vm of excludedVms" :key="vm.id">
<template #name>
<UiLink size="small" :to="vm.route">{{ vm.name }}</UiLink>
</template>
<span>{{ vm.reason }}</span>
</V2vLaunchGroupVm>
</V2vLaunchGroup>
<V2vLaunchGroup :heading="groupHeadings.migrated" :count="migratedVms.length">
<V2vMigratedVmItem
v-for="vm of migratedVms"
:key="vm.id"
:vm
:destination-pool-name
@confirm="isConfirmed => confirmSecondCopy(vm.id, isConfirmed)"
/>
</V2vLaunchGroup>
<V2vLaunchGroup :heading="groupHeadings.resumed" :count="resumedVms.length">
<V2vLaunchGroupVm v-for="vm of resumedVms" :key="vm.id">
<template #name>{{ vm.name }}</template>
<InlineParts>
<span v-for="part of vm.parts" :key="part">{{ part }}</span>
</InlineParts>
</V2vLaunchGroupVm>
</V2vLaunchGroup>
<V2vLaunchGroup :heading="groupHeadings.new" :count="newVmsCount">
<span class="typo-body-regular-small">{{ newVmsSummary }}</span>
</V2vLaunchGroup>
<UiAlert v-if="launchError !== undefined" accent="danger">
{{ launchError.title }}
<template #description>
<p v-for="message of launchError.messages" :key="message">{{ message }}</p>
</template>
</UiAlert>
<V2vWizardStepActions @back="back()">
<UiButton
accent="brand"
variant="primary"
size="medium"
:disabled="!canLaunch"
:busy="isLaunching"
@click="launch()"
>
{{ t('action:launch-n-vms', { n: launchCount }) }}
</UiButton>
</V2vWizardStepActions>
</div>
</template>
<script lang="ts" setup>
import V2vLaunchGroup from '@/modules/v2v/components/review/V2vLaunchGroup.vue'
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
import V2vLaunchOptionsCard from '@/modules/v2v/components/review/V2vLaunchOptionsCard.vue'
import V2vMigratedVmItem from '@/modules/v2v/components/review/V2vMigratedVmItem.vue'
import V2vVmLaunchOptionsTable from '@/modules/v2v/components/review/V2vVmLaunchOptionsTable.vue'
import V2vWizardStepActions from '@/modules/v2v/components/wizard/V2vWizardStepActions.vue'
import { useV2vReviewStep } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import VtsSuccessStateHero from '@core/components/success-state-hero/VtsSuccessStateHero.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const {
isBusy,
summaryParts,
destinationPoolName,
form: { formData, stopSourceCheckboxBindings, stopOnErrorCheckboxBindings, concurrencyInputBindings },
vmLaunchOptionsRows,
groupHeadings,
excludedVms,
migratedVms,
resumedVms,
newVmsCount,
newVmsSummary,
launchCount,
canLaunch,
isLaunching,
launchError,
launched,
confirmSecondCopy,
changeStopSource,
launch,
back,
backToVms,
} = useV2vReviewStep()
</script>
<style lang="postcss" scoped>
.v2v-review-step {
display: flex;
flex-direction: column;
gap: 1.6rem;
}
</style>

View File

@@ -1,5 +1,6 @@
import V2vDestinationStep from '@/modules/v2v/components/destination/V2vDestinationStep.vue'
import V2vMappingStep from '@/modules/v2v/components/mapping/V2vMappingStep.vue'
import V2vReviewStep from '@/modules/v2v/components/review/V2vReviewStep.vue'
import V2vSourceStep from '@/modules/v2v/components/source/V2vSourceStep.vue'
import V2vVmsStep from '@/modules/v2v/components/vms/V2vVmsStep.vue'
import V2vMigrationWizard from '@/modules/v2v/components/wizard/V2vMigrationWizard.vue'
@@ -10,7 +11,6 @@ import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { mountOnV2vWizard, type V2vWizardSetup } from '@/test/mount-on-v2v-wizard.ts'
import { seedV2vListPool } from '@/test/seed-v2v-list-pool.ts'
import { V2V_EMPTY_LIST, V2V_LIST_POOL, V2V_LIST_WITH_VMS, V2V_WIZARD_SOURCE } from '@/test/v2v-wizard-fixtures.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { mount } from '@vue/test-utils'
vi.mock(
@@ -28,7 +28,13 @@ async function mountWizard(setup: V2vWizardSetup = {}) {
global: createGlobalTestConfig({
router,
provide,
stubs: { V2vSourceStep: true, V2vDestinationStep: true, V2vVmsStep: true, V2vMappingStep: true },
stubs: {
V2vSourceStep: true,
V2vDestinationStep: true,
V2vVmsStep: true,
V2vMappingStep: true,
V2vReviewStep: true,
},
}),
}),
setup
@@ -58,10 +64,11 @@ it.each([
['destination', { step: 'destination', source: V2V_WIZARD_SOURCE }, V2vDestinationStep],
['VMs', { step: 'vms', source: V2V_WIZARD_SOURCE, list: V2V_EMPTY_LIST }, V2vVmsStep],
['mapping', { step: 'mapping', source: V2V_WIZARD_SOURCE, list: V2V_LIST_WITH_VMS }, V2vMappingStep],
['review', { step: 'review', source: V2V_WIZARD_SOURCE, list: V2V_LIST_WITH_VMS }, V2vReviewStep],
])('shows the %s step alone', async (_, setup: V2vWizardSetup, stepComponent) => {
const wrapper = await mountWizard(setup)
const shownSteps = [V2vSourceStep, V2vDestinationStep, V2vVmsStep, V2vMappingStep].filter(step =>
const shownSteps = [V2vSourceStep, V2vDestinationStep, V2vVmsStep, V2vMappingStep, V2vReviewStep].filter(step =>
wrapper.findComponent(step).exists()
)
@@ -75,9 +82,3 @@ it.each([
])('lays the %s step out without the padding of the other steps', async (_, setup: V2vWizardSetup) => {
expect((await mountWizard(setup)).find('.content').exists()).toBe(false)
})
it('tells the review step is coming soon', async () => {
const wrapper = await mountWizard({ step: 'review', source: V2V_WIZARD_SOURCE, list: V2V_LIST_WITH_VMS })
expect(wrapper.getComponent(VtsStateHero).text()).toContain(t('coming-soon!'))
})

View File

@@ -7,9 +7,7 @@
<div v-else class="content">
<V2vSourceStep v-if="currentStep === 'source'" />
<V2vDestinationStep v-else-if="currentStep === 'destination'" />
<VtsStateHero v-else format="page" type="under-construction" size="medium">
{{ t('coming-soon!') }}
</VtsStateHero>
<V2vReviewStep v-else />
</div>
</div>
</template>
@@ -17,14 +15,11 @@
<script lang="ts" setup>
import V2vDestinationStep from '@/modules/v2v/components/destination/V2vDestinationStep.vue'
import V2vMappingStep from '@/modules/v2v/components/mapping/V2vMappingStep.vue'
import V2vReviewStep from '@/modules/v2v/components/review/V2vReviewStep.vue'
import V2vSourceStep from '@/modules/v2v/components/source/V2vSourceStep.vue'
import V2vVmsStep from '@/modules/v2v/components/vms/V2vVmsStep.vue'
import V2vWizardHeader from '@/modules/v2v/components/wizard/V2vWizardHeader.vue'
import { useV2vWizard } from '@/modules/v2v/composables/use-v2v-wizard.composable.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { currentStep, summary } = useV2vWizard()
</script>

View File

@@ -27,14 +27,6 @@ export type V2vDestinationVm = {
powerLabel: string
}
// A device the import has not paired has no target to name
function getTargetName<TId extends string>(
id: TId | undefined,
getName: (id: TId) => string | undefined
): string | undefined {
return id === undefined ? undefined : getV2vTargetName(id, getName)
}
// A resumed VM keeps the mapping of its first sync, read back from the devices of its import VM
export function useV2vResumedVm(rawVm: MaybeRefOrGetter<XoV2vVm>, rawImportVm: MaybeRefOrGetter<FrontXoVm>) {
const { inventory } = useV2vConnection()
@@ -82,7 +74,7 @@ export function useV2vResumedVm(rawVm: MaybeRefOrGetter<XoV2vVm>, rawImportVm: M
disk.label,
...getDiskDetailParts(getV2vDatastoreName(inventory.value, disk.datastoreId), disk.capacity),
],
destination: getTargetName(srId, getSrName),
destination: getV2vTargetName(srId, getSrName),
})
)
)
@@ -92,7 +84,7 @@ export function useV2vResumedVm(rawVm: MaybeRefOrGetter<XoV2vVm>, rawImportVm: M
({ nic, networkId, isMacKept }): V2vActiveMappingRow => ({
key: nic.key,
sourceParts: [nic.label, ...getNicDetailParts(findV2vPortGroup(inventory.value, nic.portGroupId), isMacKept)],
destination: getTargetName(networkId, getNetworkName),
destination: getV2vTargetName(networkId, getNetworkName),
})
)
)

View File

@@ -0,0 +1,330 @@
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { getTaskRoute } from '@/modules/task/utils/xo-task.util.ts'
import { useV2vMigrationListRows } from '@/modules/v2v/composables/use-v2v-migration-list-rows.composable.ts'
import { useV2vSelectedVms } from '@/modules/v2v/composables/use-v2v-selected-vms.composable.ts'
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import { useV2vWizardNavigation } from '@/modules/v2v/composables/use-v2v-wizard-navigation.composable.ts'
import { useV2vLaunchForm } from '@/modules/v2v/form/use-v2v-launch-form.ts'
import { useXoV2vStartJob } from '@/modules/v2v/jobs/xo-v2v-start.job.ts'
import { useV2vConnectionStore } from '@/modules/v2v/stores/v2v-connection.store.ts'
import type {
V2vLaunchGroupType,
V2vStartProblem,
V2vStartVm,
V2vVmLaunchOptionsRow,
V2vVmLaunchOverrides,
} from '@/modules/v2v/types/v2v-launch.type.ts'
import type { V2vVmRow } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import { getV2vActiveMapping } from '@/modules/v2v/utils/v2v-active-mapping.util.ts'
import { getV2vLaunchPlan } from '@/modules/v2v/utils/v2v-launch-plan.util.ts'
import { getV2vTargetName, setV2vOverride } from '@/modules/v2v/utils/v2v-mapping.util.ts'
import { buildV2vStartPayload, getV2vStartProblems } from '@/modules/v2v/utils/v2v-start-payload.util.ts'
import { getV2vNextTransfer } from '@/modules/v2v/utils/v2v-vm-status.util.ts'
import { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import { useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { useApiErrorMessage } from '@/shared/composables/api-error-message.composable.ts'
import type { XoV2vStartPayload, XoV2vVm } from '@vates/types'
import { compact, uniq } from 'lodash-es'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
export type V2vExcludedVm = {
id: XoV2vVm['id']
name: string
route: RouteLocationRaw
reason: string
}
export type V2vMigratedVm = {
id: XoV2vVm['id']
name: string
importVm: { id: FrontXoVm['id']; name: string }
poolName: string
srs: string
cutover?: string
isConfirmed: boolean
}
export type V2vResumedVm = {
id: XoV2vVm['id']
name: string
parts: string[]
}
// A refused launch tells every problem at once, so the user fixes them all before launching again
export type V2vLaunchError = { title: string; messages: string[] }
export type V2vLaunched = {
count: number
taskRoute: RouteLocationRaw
}
// Grouped by the checks of the selected VMs panel: the review never launches a VM the panel shows blocked
export function useV2vReviewStep() {
const { t } = useI18n()
const { getErrorMessage } = useApiErrorMessage()
const connectionStore = useV2vConnectionStore()
const { goToStep, getStepLocation } = useV2vWizardNavigation()
const labels = useV2vVmLabels()
const listRows = useV2vMigrationListRows()
const { poolId, migrationList, rows } = listRows
const { vmStates, isBusy } = useV2vSelectedVms(listRows)
const { getPoolById } = useXoPoolCollection()
const { getSrById } = useXoSrCollection()
const { getVbdsByIds } = useXoVbdCollection()
const { vdis } = useXoVdiCollection()
const form = useV2vLaunchForm()
const confirmedIds = reactive(new Set<XoV2vVm['id']>())
const overridesById = reactive(new Map<XoV2vVm['id'], V2vVmLaunchOverrides>())
const plan = computed(() => getV2vLaunchPlan(vmStates.value, confirmedIds))
const rowsById = computed(() => new Map(rows.value.map(row => [row.id, row])))
const destinationPool = computed(() => getPoolById(poolId.value))
const destinationPoolName = computed(() => getV2vTargetName(poolId.value, id => getPoolById(id)?.name_label))
// Where the disks of an import are, read from the import itself: one SR may hold several of them
function getImportSrNames(row: V2vVmRow, importVm: FrontXoVm): string[] {
// NICs do not tell an SR: the VIFs of the import are not read
const { disks } = getV2vActiveMapping(row.vm, vdis.value, getVbdsByIds(importVm.$VBDs), [])
return uniq(compact(disks.map(({ srId }) => getV2vTargetName(srId, id => getSrById(id)?.name_label))))
}
// Each group VM with the import its row holds and where it is: only a VM with an import is migrated or resumed
function withImports<TItem extends { id: XoV2vVm['id'] }>(items: TItem[]) {
return items.flatMap(item => {
const row = rowsById.value.get(item.id)
const importVm = row?.importVm
if (row === undefined || importVm === undefined) {
return []
}
return [
{
item,
row,
importVm,
poolName: getV2vTargetName(importVm.$pool, id => getPoolById(id)?.name_label),
srNames: getImportSrNames(row, importVm),
},
]
})
}
const unconfirmedCount = computed(() => plan.value.migrated.filter(({ id }) => !confirmedIds.has(id)).length)
const summaryParts = computed(() =>
labels.getReviewSummaryParts(destinationPoolName.value, vmStates.value.length, {
excluded: plan.value.excluded.length,
unconfirmed: unconfirmedCount.value,
})
)
const groupHeadings = computed(
() =>
({
excluded: labels.getLaunchGroupHeading('excluded'),
migrated: labels.getLaunchGroupHeading('migrated'),
resumed: labels.getLaunchGroupHeading('resumed'),
new: labels.getLaunchGroupHeading('new'),
}) satisfies Record<V2vLaunchGroupType, unknown>
)
const excludedVms = computed(() =>
plan.value.excluded.map(
({ id, name, checkInfo }): V2vExcludedVm => ({
id,
name,
route: getStepLocation('mapping', { vm: id }),
reason: checkInfo.label,
})
)
)
const migratedVms = computed(() =>
withImports(plan.value.migrated).map(
({ item: { id, name }, row, importVm, poolName, srNames }): V2vMigratedVm => ({
id,
name,
importVm: { id: importVm.id, name: importVm.name_label },
poolName,
srs: srNames.join(', '),
cutover: labels.getCutoverLabel(row.cutoverAt),
isConfirmed: confirmedIds.has(id),
})
)
)
const resumedVms = computed(() =>
withImports(plan.value.resumed).map(
({ item: { id, name }, row, importVm, poolName, srNames }): V2vResumedVm => ({
id,
name,
parts: labels.getResumedVmParts(getV2vNextTransfer(row.vm, importVm), poolName, srNames),
})
)
)
// The SR names of a VM are `undefined` while one of them loads: the summary then leaves them all out
const newVmsSrNames = computed(() => {
const srNames = plan.value.new.map(({ mapping }) => (mapping?.type === 'mapped' ? mapping.srNames : undefined))
return srNames.every(names => names !== undefined) ? uniq(srNames.flat()) : undefined
})
const newVmsCount = computed(() => plan.value.new.length)
const newVmsSummary = computed(() =>
labels.getNewVmsSummary(
plan.value.new.map(({ name }) => name),
newVmsSrNames.value
)
)
const vmLaunchOptionsRows = computed(() =>
plan.value.launchable.map(({ id, name }): V2vVmLaunchOptionsRow => ({ id, name, overrides: overridesById.get(id) }))
)
const launchCount = computed(() => plan.value.launchable.length)
const startVms = computed(() =>
plan.value.launchable.flatMap(({ id, kind }): V2vStartVm[] => {
const listVm = migrationList.list.value?.vms.find(vm => vm.id === id)
if (listVm === undefined) {
return []
}
const { disks, nics, template } = listVm
return [{ id, kind, disks, nics, template, overrides: overridesById.get(id) }]
})
)
const payload = ref<XoV2vStartPayload>()
const { run, isRunning: isLaunching } = useXoV2vStartJob(destinationPool, payload)
const launchError = ref<V2vLaunchError>()
const launched = ref<V2vLaunched>()
const canLaunch = computed(() => launchCount.value > 0)
function confirmSecondCopy(id: XoV2vVm['id'], isConfirmed: boolean) {
if (isConfirmed) {
confirmedIds.add(id)
} else {
confirmedIds.delete(id)
}
}
function changeStopSource(id: XoV2vVm['id'], value: boolean | undefined) {
const overrides = setV2vOverride(overridesById.get(id), 'stopSource', value, form.formData.stopSource)
if (overrides === undefined) {
overridesById.delete(id)
} else {
overridesById.set(id, overrides)
}
}
// A problem names its VM as the list does: a VM gone from the source by its id
function getProblemLabel({ vmId, message }: V2vStartProblem) {
const vmName = vmId === undefined ? undefined : (rowsById.value.get(vmId)?.name ?? vmId)
return labels.getStartProblemLabel(message, vmName)
}
function getLaunchError(error: unknown): V2vLaunchError {
const problems = getV2vStartProblems(error)
if (problems !== undefined) {
return { title: t('v2v:launch-problems'), messages: problems.map(getProblemLabel) }
}
return { title: t('error'), messages: [getErrorMessage(error)] }
}
async function launch() {
launchError.value = undefined
const connection = connectionStore.connection
const list = migrationList.list.value
// Without a connection or a list, the wizard does not show the step: the check narrows their types
if (connection === undefined || list === undefined) {
return
}
const isValid = await form.validate()
const { concurrency } = form.formData
// A valid form has a concurrency: the check narrows its type
if (!isValid || concurrency === undefined) {
return
}
const count = launchCount.value
payload.value = buildV2vStartPayload({
connection,
defaults: list.defaults,
settings: { ...form.formData, concurrency },
vms: startVms.value,
})
try {
const taskId = await run()
launched.value = { count, taskRoute: getTaskRoute(taskId) }
} catch (error) {
launchError.value = getLaunchError(error)
}
}
return {
isBusy,
summaryParts,
destinationPoolName,
form,
vmLaunchOptionsRows,
groupHeadings,
excludedVms,
migratedVms,
resumedVms,
newVmsCount,
newVmsSummary,
launchCount,
canLaunch,
isLaunching,
launchError,
launched,
confirmSecondCopy,
changeStopSource,
launch,
back: () => goToStep('mapping'),
backToVms: () => goToStep('vms'),
}
}

View File

@@ -0,0 +1,426 @@
import { getTaskRoute } from '@/modules/task/utils/xo-task.util.ts'
import { useV2vReviewStep } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import type { V2vStartVm } from '@/modules/v2v/types/v2v-launch.type.ts'
import { buildV2vStartPayload } from '@/modules/v2v/utils/v2v-start-payload.util.ts'
import { DISKOR_POOL_NAME } from '@/test/create-pool.ts'
import { createV2vConnection } from '@/test/create-v2v-connection.ts'
import { createV2vLaunchSettings, createV2vStartVm } from '@/test/create-v2v-launch.ts'
import { createV2vMigrationList } from '@/test/create-v2v-migration-list.ts'
import { hasRequested } from '@/test/fetch-requests.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { vmDeviceCollectionsMock } from '@/test/mock-xo-vm-device-collections.ts'
import { mountComposableOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vReviewStep } from '@/test/mount-v2v-review-step.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { stubFetchResponse } from '@/test/stub-fetch-response.ts'
import { V2V_DEFAULT_SR, V2V_DEFAULT_TEMPLATE, V2V_SSD_SR } from '@/test/v2v-mapping-fixtures.ts'
import { V2V_DB_SYNC_SNAPSHOT, V2V_GONE_VM_ID } from '@/test/v2v-mapping-step-fixtures.ts'
import {
V2V_CUTOVER_SNAPSHOT,
V2V_MIGRATED_IMPORT_VM,
V2V_MIGRATED_VM_POOL,
V2V_MIGRATED_VM_SR,
V2V_REVIEW_STEP_LIST,
} from '@/test/v2v-review-step-fixtures.ts'
import { V2V_DB_VM, V2V_LISTED_VM, V2V_WEB_VM } from '@/test/v2v-vms-fixtures.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import type { XoTask } from '@vates/types'
import { flushPromises } from '@vue/test-utils'
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'),
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
vi.mock(
import('@/modules/task/remote-resources/use-xo-task-collection.ts'),
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
vi.mock(
import('@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'),
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
vi.mock(
import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/network/remote-resources/use-xo-network-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'),
() => import('@/test/mock-xo-vm-device-collections.ts')
)
vi.mock(
import('@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'),
() => import('@/test/mock-xo-vm-device-collections.ts')
)
vi.mock(import('@core/composables/modals/use-delete-modal.ts'), () => import('@/test/mock-delete-modal.ts'))
const START_ENDPOINT = `pools/${V2V_LIST_POOL.id}/actions/import_vms_from_vmware`
const NEW_VM = createV2vStartVm({ id: V2V_WEB_VM.id })
const RESUMED_VM = createV2vStartVm({ id: V2V_DB_VM.id, kind: 'resume' })
function mountReviewStep(options?: Parameters<typeof mountV2vReviewStep>[1]) {
return mountV2vReviewStep(
setup => mountComposableOnV2vWizard(() => ({ ...useV2vReviewStep(), labels: useV2vVmLabels() }), setup),
options
)
}
type ReviewStep = Awaited<ReturnType<typeof mountReviewStep>>['result']
// The launch of the new and the resumed VMs of the list, on the settings a launch starts with
function buildPayload(vms: V2vStartVm[] = [NEW_VM, RESUMED_VM]) {
return buildV2vStartPayload({
connection: createV2vConnection(),
defaults: V2V_REVIEW_STEP_LIST.defaults,
settings: createV2vLaunchSettings(),
vms,
})
}
async function launch(result: ReviewStep) {
await result.launch()
await flushPromises()
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
vmDeviceCollectionsMock.reset()
})
describe('isBusy', () => {
it('is not busy once the rows and the choices of the pool are ready', async () => {
const { result } = await mountReviewStep()
expect(result.isBusy).toBe(false)
})
it('is busy while the rows load', async () => {
const { result } = await mountReviewStep()
v2vVmRowsCollectionsMock.areVmsReady.value = false
expect(result.isBusy).toBe(true)
})
})
describe('summaryParts', () => {
it('names the destination pool, counts the VMs of the list, the excluded ones and those to confirm', async () => {
const { result } = await mountReviewStep()
expect(result.summaryParts).toEqual(
result.labels.getReviewSummaryParts(DISKOR_POOL_NAME, 4, { excluded: 1, unconfirmed: 1 })
)
})
it('no longer counts an already migrated VM once confirmed', async () => {
const { result } = await mountReviewStep()
result.confirmSecondCopy(V2V_LISTED_VM.id, true)
expect(result.summaryParts).toEqual(
result.labels.getReviewSummaryParts(DISKOR_POOL_NAME, 4, { excluded: 1, unconfirmed: 0 })
)
})
})
it('heads each group with its label and accent', async () => {
const { result } = await mountReviewStep()
expect(result.groupHeadings).toEqual({
excluded: result.labels.getLaunchGroupHeading('excluded'),
migrated: result.labels.getLaunchGroupHeading('migrated'),
resumed: result.labels.getLaunchGroupHeading('resumed'),
new: result.labels.getLaunchGroupHeading('new'),
})
})
describe('excludedVms', () => {
it('excludes a blocked VM with the reason the panel gives, linking to its mapping', async () => {
const { result } = await mountReviewStep()
expect(result.excludedVms).toEqual([
{
id: V2V_GONE_VM_ID,
name: V2V_GONE_VM_ID,
route: { query: { pool: V2V_LIST_POOL.id, step: 'mapping', vm: V2V_GONE_VM_ID } },
reason: result.labels.getCheckInfo({ type: 'not-found', level: 'blocking' }).label,
},
])
})
})
describe('migratedVms', () => {
it('tells the existing VM, its pool, its SRs and its cutover date, unconfirmed', async () => {
const { result } = await mountReviewStep()
expect(result.migratedVms).toEqual([
{
id: V2V_LISTED_VM.id,
name: V2V_LISTED_VM.name,
importVm: { id: V2V_MIGRATED_IMPORT_VM.id, name: V2V_MIGRATED_IMPORT_VM.name_label },
poolName: V2V_MIGRATED_VM_POOL.name_label,
srs: V2V_MIGRATED_VM_SR.name_label,
cutover: result.labels.getCutoverLabel(V2V_CUTOVER_SNAPSHOT.snapshot_time * 1000),
isConfirmed: false,
},
])
})
it('tells no cutover date once its snapshot is gone', async () => {
const { result } = await mountReviewStep()
v2vVmRowsCollectionsMock.snapshots.value = [V2V_DB_SYNC_SNAPSHOT]
expect(result.migratedVms[0].cutover).toBeUndefined()
})
it('names a pool and an SR still loading by their ids', async () => {
const { result } = await mountReviewStep()
xoCollectionsMock.pools.value = [V2V_LIST_POOL]
xoCollectionsMock.srs.value = [V2V_DEFAULT_SR, V2V_SSD_SR]
expect(result.migratedVms[0]).toMatchObject({ poolName: V2V_MIGRATED_VM_POOL.id, srs: V2V_MIGRATED_VM_SR.id })
})
it('is confirmed once the user asks for a second copy, and unconfirmed again', async () => {
const { result } = await mountReviewStep()
result.confirmSecondCopy(V2V_LISTED_VM.id, true)
expect(result.migratedVms[0].isConfirmed).toBe(true)
result.confirmSecondCopy(V2V_LISTED_VM.id, false)
expect(result.migratedVms[0].isConfirmed).toBe(false)
})
})
it('resumes a warm VM on the pool and the SRs of its import, with its next transfer', async () => {
const { result } = await mountReviewStep()
expect(result.resumedVms).toEqual([
{
id: V2V_DB_VM.id,
name: V2V_DB_VM.name,
parts: result.labels.getResumedVmParts('full', DISKOR_POOL_NAME, [V2V_SSD_SR.name_label]),
},
])
})
it('counts the new VMs and names them with the SRs of their mapping', async () => {
const { result } = await mountReviewStep()
expect(result.newVmsCount).toBe(1)
expect(result.newVmsSummary).toBe(result.labels.getNewVmsSummary([V2V_WEB_VM.name], [V2V_DEFAULT_SR.name_label]))
})
describe('vmLaunchOptionsRows', () => {
it('lists the launchable VMs in list order, on the launch settings', async () => {
const { result } = await mountReviewStep()
expect(result.vmLaunchOptionsRows).toEqual([
{ id: V2V_WEB_VM.id, name: V2V_WEB_VM.name, overrides: undefined },
{ id: V2V_DB_VM.id, name: V2V_DB_VM.name, overrides: undefined },
])
expect(result.launchCount).toBe(2)
})
it('adds an already migrated VM once confirmed', async () => {
const { result } = await mountReviewStep()
result.confirmSecondCopy(V2V_LISTED_VM.id, true)
expect(result.vmLaunchOptionsRows.map(row => row.id)).toEqual([V2V_WEB_VM.id, V2V_DB_VM.id, V2V_LISTED_VM.id])
expect(result.launchCount).toBe(3)
})
it('overrides stopping the source of a VM', async () => {
const { result } = await mountReviewStep()
result.changeStopSource(V2V_DB_VM.id, false)
expect(result.vmLaunchOptionsRows[1].overrides).toEqual({ stopSource: false })
})
it('clears the override of a VM set back to the launch settings', async () => {
const { result } = await mountReviewStep()
result.changeStopSource(V2V_DB_VM.id, false)
result.changeStopSource(V2V_DB_VM.id, true)
expect(result.vmLaunchOptionsRows[1].overrides).toBeUndefined()
})
})
describe('canLaunch', () => {
it('can launch the launchable VMs', async () => {
const { result } = await mountReviewStep()
expect(result.canLaunch).toBe(true)
})
it('cannot launch without launchable VM', async () => {
const list = createV2vMigrationList({ poolId: V2V_LIST_POOL.id, vms: [{ id: V2V_GONE_VM_ID }] })
const { result } = await mountReviewStep({ list })
expect(result.launchCount).toBe(0)
expect(result.canLaunch).toBe(false)
})
it('cannot launch without a connected source, nor any VM to show', async () => {
const { result } = await mountV2vReviewStep(setup =>
mountComposableOnV2vWizard(() => useV2vReviewStep(), { ...setup, source: undefined })
)
expect(result.canLaunch).toBe(false)
expect(result.vmLaunchOptionsRows).toEqual([])
})
})
describe('launch', () => {
it('starts the launchable VMs on the destination pool, then tells their task', async () => {
stubFetchResponse(202, { taskId: 'task-1' })
const { result } = await mountReviewStep()
await launch(result)
expect(hasRequested('POST', START_ENDPOINT, buildPayload())).toBe(true)
expect(result.launched).toEqual({ count: 2, taskRoute: getTaskRoute('task-1' as XoTask['id']) })
})
it('sends the mapping a new VM overrides on the list, and the overrides of the launch', async () => {
stubFetchResponse(202, { taskId: 'task-1' })
const list = createV2vMigrationList({
...V2V_REVIEW_STEP_LIST,
vms: [{ id: V2V_WEB_VM.id, template: V2V_DEFAULT_TEMPLATE.id }, { id: V2V_DB_VM.id }],
})
const { result } = await mountReviewStep({ list })
result.changeStopSource(V2V_DB_VM.id, false)
await launch(result)
const payload = buildPayload([
{ ...NEW_VM, template: V2V_DEFAULT_TEMPLATE.id },
{ ...RESUMED_VM, overrides: { stopSource: false } },
])
expect(hasRequested('POST', START_ENDPOINT, payload)).toBe(true)
})
it('sends a confirmed already migrated VM as a new one', async () => {
stubFetchResponse(202, { taskId: 'task-1' })
const { result } = await mountReviewStep()
result.confirmSecondCopy(V2V_LISTED_VM.id, true)
await launch(result)
const payload = buildPayload([NEW_VM, RESUMED_VM, createV2vStartVm({ id: V2V_LISTED_VM.id })])
expect(hasRequested('POST', START_ENDPOINT, payload)).toBe(true)
})
it('does not launch while the launch options are invalid', async () => {
const fetchMock = stubFetchResponse(202, { taskId: 'task-1' })
const { result } = await mountReviewStep()
result.form.formData.concurrency = 0
await launch(result)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.launched).toBeUndefined()
})
it('is launching while the request runs', async () => {
const { result } = await mountReviewStep()
// The default `fetch` stub never settles: awaiting the launch would hang the test
void result.launch()
await flushPromises()
expect(result.isLaunching).toBe(true)
})
it('tells each problem the back end refused the launch for, by VM name', async () => {
silenceConsoleError()
stubFetchResponse(422, {
error: 'the migration cannot be started',
data: {
errors: [
{ vm: V2V_WEB_VM.id, message: 'RDM disk' },
{ vm: 'vm-unknown', message: 'unknown VM' },
{ message: 'tags are not supported' },
],
},
})
const { result } = await mountReviewStep()
await launch(result)
expect(result.launchError).toEqual({
title: result.t('v2v:launch-problems'),
messages: [
result.labels.getStartProblemLabel('RDM disk', V2V_WEB_VM.name),
result.labels.getStartProblemLabel('unknown VM', 'vm-unknown'),
'tags are not supported',
],
})
expect(result.launched).toBeUndefined()
})
it('tells any other failure as an error', async () => {
silenceConsoleError()
stubFetchResponse(500, { error: 'unreachable' })
const { result } = await mountReviewStep()
await launch(result)
expect(result.launchError).toEqual({ title: result.t('error'), messages: ['unreachable'] })
})
it('forgets the error of the previous launch', async () => {
silenceConsoleError()
stubFetchResponse(500, { error: 'unreachable' })
const { result } = await mountReviewStep()
await launch(result)
stubFetchResponse(202, { taskId: 'task-1' })
await launch(result)
expect(result.launchError).toBeUndefined()
})
})
describe('navigation', () => {
it('goes back to the mapping step', async () => {
const { result, router } = await mountReviewStep()
await result.back()
expect(router.currentRoute.value.query.step).toBe('mapping')
})
it('goes back to the VMs step', async () => {
const { result, router } = await mountReviewStep()
await result.backToVms()
expect(router.currentRoute.value.query.step).toBe('vms')
})
})

View File

@@ -5,7 +5,11 @@ import { useV2vSourceAndDestination } from '@/modules/v2v/composables/use-v2v-so
import { useV2vTargetNameGetters } from '@/modules/v2v/composables/use-v2v-target-name-getters.composable.ts'
import { useV2vVmLabels, type V2vAccentedLabel } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import { useV2vWizardNavigation } from '@/modules/v2v/composables/use-v2v-wizard-navigation.composable.ts'
import type { V2vSelectedVmCheck, V2vSelectedVmKind } from '@/modules/v2v/types/v2v-selected-vm.type.ts'
import type {
V2vMappingSummary,
V2vSelectedVmCheck,
V2vSelectedVmKind,
} from '@/modules/v2v/types/v2v-selected-vm.type.ts'
import type { V2vVmRow } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import { getV2vVmMapping } from '@/modules/v2v/utils/v2v-mapping.util.ts'
import {
@@ -55,6 +59,7 @@ export type V2vSelectedVmsPanelProps = {
export type V2vListedVm = {
kind: V2vSelectedVmKind
check: V2vSelectedVmCheck
mapping: V2vMappingSummary | undefined
item: V2vSelectedVm
}
@@ -108,16 +113,18 @@ export function useV2vSelectedVms({ migrationList, rows, areRowsReady, poolId, s
const row = getRow(id)
const mapping = row === undefined ? undefined : getV2vVmMapping(row.vm, listVm, list.defaults, offers.value)
const { kind, check } = getV2vSelectedVmState(row, toValue(poolId), mapping?.state)
const mappingSummary = getV2vMappingSummary(mapping, kind, targetNameGetters)
return {
kind,
check,
mapping: mappingSummary,
item: {
id,
name: getName(id),
// A VM gone from the source is neither new nor resumed: its check alone tells it
kind: check.type === 'not-found' ? undefined : getKindTag(kind),
mappingParts: getMappingParts(getV2vMappingSummary(mapping, kind, targetNameGetters)),
mappingParts: getMappingParts(mappingSummary),
check: getCheckInfo(check, getCheckPoolName(check)),
mappingRoute: getStepLocation('mapping', { vm: id }),
isSelected: id === toValue(selectedVmId),
@@ -128,6 +135,18 @@ export function useV2vSelectedVms({ migrationList, rows, areRowsReady, poolId, s
const items = computed(() => vms.value.map(vm => vm.item))
// What the launch plan reads of each VM, rather than what the panel shows of it
const vmStates = computed(() =>
vms.value.map(({ kind, check, mapping, item }) => ({
id: item.id,
name: item.name,
kind,
check,
checkInfo: item.check,
mapping,
}))
)
const count = computed(() => vms.value.length)
const isEmpty = computed(() => count.value === 0)
@@ -187,6 +206,7 @@ export function useV2vSelectedVms({ migrationList, rows, areRowsReady, poolId, s
}
return {
vmStates,
getRow,
items,
vms,

View File

@@ -66,6 +66,19 @@ const WEB_LIST = createV2vMigrationList({ vms: createV2vMigrationListVmsOf([V2V_
// The web VM is new, the DB one resumed
const WEB_AND_DB_LIST = createV2vMigrationList({ vms: createV2vMigrationListVmsOf([V2V_WEB_VM.id, V2V_DB_VM.id]) })
const READY_CHECK = { type: 'ready', level: 'ready' }
// The mapping summaries of the VMs of `WEB_AND_DB_LIST`: a new one on the list defaults, a resumed one
const WEB_MAPPING = {
type: 'mapped',
diskCount: 1,
srNames: [V2V_DEFAULT_SR.name_label],
nicCount: 0,
networkNames: [],
}
const DB_MAPPING = { type: 'locked' }
// The DB VM is warm synced on the pool of the list, the listed one migrated
const IMPORT_VMS = [
createV2vImportVm(
@@ -323,12 +336,12 @@ describe('isBusy', () => {
})
describe('vms', () => {
it('tells the kind and the check of each VM of the list, with its item, in the list order', async () => {
it('tells the kind, the check and the mapping summary of each VM of the list, with its item, in the list order', async () => {
const { result } = await mountSelectedVms({ list: WEB_AND_DB_LIST })
expect(result.vms).toEqual([
{ kind: 'new', check: { type: 'ready', level: 'ready' }, item: result.items[0] },
{ kind: 'resume', check: { type: 'ready', level: 'ready' }, item: result.items[1] },
{ kind: 'new', check: READY_CHECK, mapping: WEB_MAPPING, item: result.items[0] },
{ kind: 'resume', check: READY_CHECK, mapping: DB_MAPPING, item: result.items[1] },
])
})
@@ -339,6 +352,31 @@ describe('vms', () => {
})
})
describe('vmStates', () => {
it('tells what the launch plan reads of each VM of the list, in the list order', async () => {
const { result } = await mountSelectedVms({ list: WEB_AND_DB_LIST })
expect(result.vmStates).toEqual([
{
id: V2V_WEB_VM.id,
name: V2V_WEB_VM.name,
kind: 'new',
check: READY_CHECK,
checkInfo: result.items[0].check,
mapping: WEB_MAPPING,
},
{
id: V2V_DB_VM.id,
name: V2V_DB_VM.name,
kind: 'resume',
check: READY_CHECK,
checkInfo: result.items[1].check,
mapping: DB_MAPPING,
},
])
})
})
describe('readinessTags', () => {
it('counts the VMs of each readiness level', async () => {
const { result } = await mountSelectedVms({

View File

@@ -8,16 +8,14 @@ import type {
V2vRememberedConnection,
V2vRememberedConnectionKey,
} from '@/modules/v2v/types/v2v-remembered-connection.type.ts'
import {
getV2vConnectionErrorMessage,
isV2vInvalidCredentialsError,
} from '@/modules/v2v/utils/v2v-connection-error.util.ts'
import { isV2vInvalidCredentialsError } from '@/modules/v2v/utils/v2v-connection-error.util.ts'
import {
createRememberedConnection,
findRememberedConnection,
getRememberedConnectionKey,
} from '@/modules/v2v/utils/v2v-remembered-connection.util.ts'
import { getV2vSourceId } from '@/modules/v2v/utils/v2v-source.util.ts'
import { useApiErrorMessage } from '@/shared/composables/api-error-message.composable.ts'
import { useDeleteModal } from '@core/composables/modals/use-delete-modal.ts'
import type { XoV2vConnection } from '@vates/types'
import { reactive, ref } from 'vue'
@@ -26,6 +24,8 @@ import { useI18n } from 'vue-i18n'
export function useV2vSourceStep() {
const { t } = useI18n()
const { getErrorMessage } = useApiErrorMessage()
const { goToStep } = useV2vWizardNavigation()
const form = useV2vConnectionForm()
@@ -92,7 +92,7 @@ export function useV2vSourceStep() {
return
}
connectionError.value = getV2vConnectionErrorMessage(error) ?? t('error')
connectionError.value = getErrorMessage(error)
}
}

View File

@@ -168,16 +168,6 @@ describe('submit', () => {
expect(router.currentRoute.value.query.step).toBe('source')
})
it('reports a generic error when the failure carries no message', async () => {
silenceConsoleError()
stubFetchResponse(500, {})
const { result } = await mountSourceStep()
await submitConnection(result)
expect(result.connectionError).toBe(result.t('error'))
})
it('is connecting while the inventory loads', async () => {
const { result } = await mountSourceStep()
fillV2vConnectionForm(result.form)

View File

@@ -1,3 +1,4 @@
import type { V2vLaunchGroupType } from '@/modules/v2v/types/v2v-launch.type.ts'
import type { V2vTargetOrigin } from '@/modules/v2v/types/v2v-mapping.type.ts'
import type {
V2vMappingSummary,
@@ -16,14 +17,21 @@ import { V2V_READINESS_LEVELS } from '@/modules/v2v/utils/v2v-selected-vm.util.t
import type { V2vVmPresetId } from '@/modules/v2v/utils/v2v-vm-presets.util.ts'
import { V2V_VM_STATUSES } from '@/modules/v2v/utils/v2v-vm-status.util.ts'
import { mapCountedKeys } from '@/shared/utils/count.util.ts'
import type { CounterAccent } from '@core/components/ui/counter/UiCounter.vue'
import type { InfoAccent } from '@core/components/ui/info/UiInfo.vue'
import type { TagAccent } from '@core/components/ui/tag/UiTag.vue'
import { formatSize } from '@core/utils/size.util.ts'
import { formatDateTime } from '@core/utils/time.util.ts'
import { V2V_VM_POWER_STATE, type XoV2vDisk, type XoV2vPortGroup } from '@vates/types'
import { sumBy } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
// A list, not only a type: `mapCountedKeys` needs the keys in the order the review summary tells them
export const V2V_REVIEW_COUNTED = ['excluded', 'unconfirmed'] as const
export type V2vReviewCounts = Record<(typeof V2V_REVIEW_COUNTED)[number], number>
export type V2vVmStatusSummary = {
total: number
counts: Record<V2vVmStatus, number>
@@ -51,6 +59,13 @@ const KIND_ACCENTS: Record<V2vSelectedVmKind, TagAccent> = {
resume: 'info',
}
const LAUNCH_GROUP_ACCENTS: Record<V2vLaunchGroupType, CounterAccent> = {
excluded: 'danger',
migrated: 'warning',
resumed: 'brand',
new: 'success',
}
// Shared by the readiness tags and the check of each VM, so a level reads the same in both
const READINESS_ACCENTS: Record<V2vReadinessLevel, TagAccent & InfoAccent> = {
ready: 'success',
@@ -204,6 +219,16 @@ export function useV2vVmLabels() {
full: t('full-transfer'),
}))
// The panel and the review word a resumed VM alike: its transfer, then where it goes, then its kept mapping
const resumedTransferLabels = computed<Record<V2vNextTransfer, string>>(() => ({
delta: t('v2v:delta-from-last-sync'),
full: nextTransferLabels.value.full,
}))
function getResumedParts(transfer: V2vNextTransfer, ...details: string[]): string[] {
return [resumedTransferLabels.value[transfer], ...details, t('v2v:mapping-locked')]
}
function withTargets(countLabel: string, targets: string[] | undefined): string {
return targets === undefined
? countLabel
@@ -216,7 +241,7 @@ export function useV2vVmLabels() {
}
if (summary.type === 'locked') {
return [t('v2v:delta-from-last-sync'), t('v2v:mapping-locked')]
return getResumedParts('delta')
}
const { diskCount, srNames, nicCount, networkNames } = summary
@@ -249,6 +274,51 @@ export function useV2vVmLabels() {
return value === undefined ? t('default-value', { value: getOnOffLabel(defaultValue) }) : getOnOffLabel(value)
}
const launchGroupLabels = computed<Record<V2vLaunchGroupType, string>>(() => ({
excluded: t('v2v:launch-group:excluded'),
migrated: t('v2v:already-fully-migrated'),
resumed: t('v2v:launch-group:resumed'),
new: t('v2v:launch-group:new'),
}))
function getLaunchGroupHeading(type: V2vLaunchGroupType): V2vAccentedLabel<CounterAccent> {
return { label: launchGroupLabels.value[type], accent: LAUNCH_GROUP_ACCENTS[type] }
}
const reviewCountLabelGetters: Record<keyof V2vReviewCounts, (count: number) => string> = {
excluded: count => t('v2v:n-excluded', { n: count }),
unconfirmed: count => t('n-need-confirmation', { n: count }),
}
// No pool in the route: the summary names none rather than an empty one
function getReviewSummaryParts(poolName: string | undefined, vmCount: number, counts: V2vReviewCounts): string[] {
const poolParts = poolName === undefined ? [] : [t('destination-pool-named', { name: poolName })]
return [
...poolParts,
t('n-vms-in-list', { n: vmCount }),
...mapCountedKeys(V2V_REVIEW_COUNTED, counts, (key, count) => reviewCountLabelGetters[key](count)),
]
}
// A resumed VM goes on where its last sync left it: on the pool and the SRs of its import
function getResumedVmParts(transfer: V2vNextTransfer, poolName: string, srNames: string[]): string[] {
return getResumedParts(transfer, t('v2v:continues-on-pool-sr', { pool: poolName, srs: srNames.join(', ') }))
}
function getCutoverLabel(cutoverAt: number | undefined): string | undefined {
return cutoverAt === undefined ? undefined : t('v2v:cutover-done-on', { date: formatDateTime(cutoverAt) })
}
function getNewVmsSummary(names: string[], srNames: string[] | undefined): string {
return withTargets(names.join(', '), srNames)
}
// A problem of the whole launch has no VM to name
function getStartProblemLabel(message: string, vmName: string | undefined): string {
return vmName === undefined ? message : t('v2v:vm-start-problem', { vm: vmName, message })
}
return {
statusLabels,
powerLabels,
@@ -269,5 +339,11 @@ export function useV2vVmLabels() {
getMappingParts,
getReadinessTags,
getLaunchOptionLabel,
getLaunchGroupHeading,
getReviewSummaryParts,
getResumedVmParts,
getCutoverLabel,
getNewVmsSummary,
getStartProblemLabel,
}
}

View File

@@ -4,6 +4,7 @@ import type { V2vUnsupportedReason, V2vVmStatus } from '@/modules/v2v/types/v2v-
import { DISKOR_POOL_NAME, OTHER_POOL_ID } from '@/test/create-pool.ts'
import { createV2vDisk, createV2vPortGroup } from '@/test/create-v2v-inventory.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { formatDateTime } from '@core/utils/time.util.ts'
import { V2V_VM_POWER_STATE } from '@vates/types'
function mountLabels() {
@@ -385,6 +386,96 @@ describe('getNicDetailParts', () => {
})
})
describe('getLaunchGroupHeading', () => {
it.each([
['excluded', 'v2v:launch-group:excluded', 'danger'],
['migrated', 'v2v:already-fully-migrated', 'warning'],
['resumed', 'v2v:launch-group:resumed', 'brand'],
['new', 'v2v:launch-group:new', 'success'],
] as const)('heads the %s group with its label and accent', (type, labelKey, accent) => {
const labels = mountLabels()
expect(labels.getLaunchGroupHeading(type)).toEqual({ label: labels.t(labelKey), accent })
})
})
describe('getReviewSummaryParts', () => {
it('names the destination pool, counts the VMs of the list, then the excluded ones and those to confirm', () => {
const labels = mountLabels()
expect(labels.getReviewSummaryParts(DISKOR_POOL_NAME, 7, { excluded: 2, unconfirmed: 1 })).toEqual([
labels.t('destination-pool-named', { name: DISKOR_POOL_NAME }),
labels.t('n-vms-in-list', { n: 7 }),
labels.t('v2v:n-excluded', { n: 2 }),
labels.t('n-need-confirmation', { n: 1 }),
])
})
it('leaves out the counts at zero and a pool still unknown', () => {
const labels = mountLabels()
expect(labels.getReviewSummaryParts(undefined, 3, { excluded: 0, unconfirmed: 0 })).toEqual([
labels.t('n-vms-in-list', { n: 3 }),
])
})
})
describe('getResumedVmParts', () => {
it.each([
['delta', 'v2v:delta-from-last-sync'],
['full', 'full-transfer'],
] as const)('tells the %s transfer, where the VM continues, and that its mapping is kept', (transfer, labelKey) => {
const labels = mountLabels()
expect(labels.getResumedVmParts(transfer, DISKOR_POOL_NAME, ['sr-nvme-01', 'sr-ssd-01'])).toEqual([
labels.t(labelKey),
labels.t('v2v:continues-on-pool-sr', { pool: DISKOR_POOL_NAME, srs: 'sr-nvme-01, sr-ssd-01' }),
labels.t('v2v:mapping-locked'),
])
})
})
describe('getCutoverLabel', () => {
it('tells when the VM was cut over', () => {
const labels = mountLabels()
const cutoverAt = Date.UTC(2026, 9, 2)
expect(labels.getCutoverLabel(cutoverAt)).toBe(labels.t('v2v:cutover-done-on', { date: formatDateTime(cutoverAt) }))
})
it('tells nothing while the cutover date is unknown', () => {
expect(mountLabels().getCutoverLabel(undefined)).toBeUndefined()
})
})
describe('getNewVmsSummary', () => {
it('names the VMs, then the SRs they go to', () => {
const labels = mountLabels()
expect(labels.getNewVmsSummary(['app-03', 'build-02'], ['sr-nvme-01', 'sr-hdd-02'])).toBe(
labels.t('source-to-destination', { source: 'app-03, build-02', destination: 'sr-nvme-01, sr-hdd-02' })
)
})
it('only names the VMs while their SRs are unknown', () => {
expect(mountLabels().getNewVmsSummary(['app-03', 'build-02'], undefined)).toBe('app-03, build-02')
})
})
describe('getStartProblemLabel', () => {
it('names the VM of the problem', () => {
const labels = mountLabels()
expect(labels.getStartProblemLabel('RDM disk', 'web-01')).toBe(
labels.t('v2v:vm-start-problem', { vm: 'web-01', message: 'RDM disk' })
)
})
it('only tells a problem of the whole launch', () => {
expect(mountLabels().getStartProblemLabel('tags are not supported', undefined)).toBe('tags are not supported')
})
})
describe('getLaunchOptionLabel', () => {
it.each([
[true, 'option-on'],

View File

@@ -2,6 +2,7 @@ import { useXoV2vInventoryJob } from '@/modules/v2v/jobs/xo-v2v-inventory.job.ts
import { ApiError } from '@/shared/error/api.error.ts'
import { createV2vConnection } from '@/test/create-v2v-connection.ts'
import { createV2vInventory } from '@/test/create-v2v-inventory.ts'
import { hasRequested } from '@/test/fetch-requests.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { stubFetchResponse } from '@/test/stub-fetch-response.ts'
import { stubV2vInvalidCredentialsResponse } from '@/test/stub-v2v-invalid-credentials-response.ts'
@@ -14,17 +15,11 @@ function mountInventoryJob(connection?: XoV2vConnection) {
describe('run', () => {
it('posts the connection to the inventory route and resolves the inventory', async () => {
const inventory = createV2vInventory()
const fetchMock = stubFetchResponse(200, inventory)
stubFetchResponse(200, inventory)
const connection = createV2vConnection()
await expect(mountInventoryJob(connection).run()).resolves.toEqual(inventory)
expect(fetchMock).toHaveBeenCalledWith(
'/rest/v0/v2v/inventory',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ platform: V2V_PLATFORM.VMWARE, connection }),
})
)
expect(hasRequested('POST', 'v2v/inventory', { platform: V2V_PLATFORM.VMWARE, connection })).toBe(true)
})
it('rejects with the API error the server answered', async () => {

View File

@@ -4,6 +4,7 @@ import { buildV2vStartPayload } from '@/modules/v2v/utils/v2v-start-payload.util
import { ApiError } from '@/shared/error/api.error.ts'
import { createPool } from '@/test/create-pool.ts'
import { createV2vStartPayloadInput } from '@/test/create-v2v-launch.ts'
import { hasRequested } from '@/test/fetch-requests.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { stubFetchResponse } from '@/test/stub-fetch-response.ts'
import type { XoV2vStartPayload } from '@vates/types'
@@ -22,10 +23,7 @@ describe('run', () => {
await expect(mountStartJob(POOL, PAYLOAD).run()).resolves.toBe('task-1')
expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock).toHaveBeenCalledWith(
`/rest/v0/pools/${POOL.id}/actions/import_vms_from_vmware`,
expect.objectContaining({ method: 'POST', body: JSON.stringify(PAYLOAD) })
)
expect(hasRequested('POST', `pools/${POOL.id}/actions/import_vms_from_vmware`, PAYLOAD)).toBe(true)
})
it('rejects with the API error the server answered', async () => {

View File

@@ -20,6 +20,7 @@ export type V2vVmRow = {
supported: boolean
unsupportedReason?: V2vUnsupportedReason
lastSyncAt?: number
cutoverAt?: number
isInList: boolean
importVm?: FrontXoVm
vm: XoV2vVm

View File

@@ -1,4 +1,3 @@
import { ApiError } from '@/shared/error/api.error.ts'
import { getUnprocessableEntityData } from '@/shared/utils/api-error.util.ts'
import { hasObjectProperty } from '@core/utils/object.util.ts'
import { V2V_ERROR_CODE } from '@vates/types'
@@ -8,14 +7,3 @@ export function isV2vInvalidCredentialsError(error: unknown): boolean {
return hasObjectProperty(errorData, 'code') && errorData.code === V2V_ERROR_CODE.INVALID_CREDENTIALS
}
export function getV2vConnectionErrorMessage(error: unknown): string | undefined {
const serverError =
error instanceof ApiError && hasObjectProperty(error.cause, 'error') ? error.cause.error : undefined
if (typeof serverError === 'string' && serverError !== '') {
return serverError
}
return error instanceof Error && error.message !== '' ? error.message : undefined
}

View File

@@ -1,8 +1,4 @@
import {
getV2vConnectionErrorMessage,
isV2vInvalidCredentialsError,
} from '@/modules/v2v/utils/v2v-connection-error.util.ts'
import { ApiError } from '@/shared/error/api.error.ts'
import { isV2vInvalidCredentialsError } from '@/modules/v2v/utils/v2v-connection-error.util.ts'
import { createApiError } from '@/test/create-api-error.ts'
import { V2V_ERROR_CODE } from '@vates/types'
@@ -23,21 +19,3 @@ describe('isV2vInvalidCredentialsError', () => {
expect(isV2vInvalidCredentialsError(createApiError(422, cause))).toBe(false)
})
})
describe('getV2vConnectionErrorMessage', () => {
it('reads the error the server answered', () => {
expect(getV2vConnectionErrorMessage(createApiError(500, { error: 'host unreachable' }))).toBe('host unreachable')
})
it('falls back to the error message', () => {
expect(getV2vConnectionErrorMessage(createApiError(502))).toBe('Request failed')
expect(getV2vConnectionErrorMessage(new Error('network down'))).toBe('network down')
})
it.each([new ApiError('', { status: 500 }), new Error(''), 'string', undefined])(
'has no message to show without any (%s)',
error => {
expect(getV2vConnectionErrorMessage(error)).toBeUndefined()
}
)
})

View File

@@ -1,12 +0,0 @@
import type { V2vLaunchSettings, V2vVmLaunchOverrides } from '@/modules/v2v/types/v2v-launch.type.ts'
import { getV2vOverride } from '@/modules/v2v/utils/v2v-mapping.util.ts'
// Stop source is the only option a VM overrides: setting it gives all the overrides of the VM
export function setV2vVmStopSource(
value: boolean | undefined,
settings: Pick<V2vLaunchSettings, 'stopSource'>
): V2vVmLaunchOverrides | undefined {
const stopSource = getV2vOverride(value, settings.stopSource)
return stopSource === undefined ? undefined : { stopSource }
}

View File

@@ -1,18 +0,0 @@
import { setV2vVmStopSource } from '@/modules/v2v/utils/v2v-launch-options.util.ts'
import { createV2vLaunchSettings } from '@/test/create-v2v-launch.ts'
describe('setV2vVmStopSource', () => {
const settings = createV2vLaunchSettings({ stopSource: true })
it('overrides the launch settings with another value', () => {
expect(setV2vVmStopSource(false, settings)).toEqual({ stopSource: false })
})
it.each([true, false])('clears the override at the value (%s) of the launch settings', stopSource => {
expect(setV2vVmStopSource(stopSource, createV2vLaunchSettings({ stopSource }))).toBeUndefined()
})
it('clears the override when reset', () => {
expect(setV2vVmStopSource(undefined, settings)).toBeUndefined()
})
})

View File

@@ -52,7 +52,7 @@ export function getV2vOverride<TValue>(value: TValue | undefined, defaultValue:
}
export function setV2vOverride<TKey extends string, TValue>(
overrides: Record<TKey, TValue> | undefined,
overrides: Partial<Record<TKey, TValue>> | undefined,
key: TKey,
value: TValue | undefined,
defaultValue: TValue
@@ -66,9 +66,19 @@ export function setV2vOverride<TKey extends string, TValue>(
return isEmpty(nextOverrides) ? undefined : nextOverrides
}
type V2vTargetNameGetter<TId extends string> = (id: TId) => string | undefined
// A target gone from its collection or from the pool offers, or still loading, is named by its id rather than not at all
export function getV2vTargetName<TId extends string>(id: TId, getName: (id: TId) => string | undefined): string {
return getName(id) ?? id
export function getV2vTargetName<TId extends string>(id: TId, getName: V2vTargetNameGetter<TId>): string
export function getV2vTargetName<TId extends string>(
id: TId | undefined,
getName: V2vTargetNameGetter<TId>
): string | undefined
export function getV2vTargetName<TId extends string>(
id: TId | undefined,
getName: V2vTargetNameGetter<TId>
): string | undefined {
return id === undefined ? undefined : (getName(id) ?? id)
}
export function getV2vTargetOptionLabel<TId extends string>(options: V2vTargetOption<TId>[], id: TId): string {

View File

@@ -177,6 +177,10 @@ describe('getV2vTargetName', () => {
it('names a target without a name found, gone or still loading, by its id', () => {
expect(getV2vTargetName(V2V_SSD_SR.id, () => undefined)).toBe(V2V_SSD_SR.id)
})
it('has no name without target', () => {
expect(getV2vTargetName(undefined, () => V2V_SSD_SR.name_label)).toBeUndefined()
})
})
describe('getV2vTargetOptionLabel', () => {

View File

@@ -1,6 +1,7 @@
import type { V2vVmRow, V2vVmStatus, V2vVmsTableView } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import { getV2vUnsupportedReason } from '@/modules/v2v/utils/v2v-vm-checks.util.ts'
import {
getV2vCutoverAt,
getV2vLastSyncAt,
getV2vVmStatus,
V2V_VM_STATUSES,
@@ -53,6 +54,7 @@ export function buildV2vVmRows({
supported: unsupportedReason === undefined,
unsupportedReason,
lastSyncAt: getV2vLastSyncAt(importSnapshots),
cutoverAt: getV2vCutoverAt(importSnapshots),
isInList: listVmIds.has(vm.id),
importVm,
vm,

View File

@@ -10,7 +10,11 @@ import {
} from '@/modules/v2v/utils/v2v-vm-rows.util.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { ONE_GB } from '@/shared/constants.ts'
import { createV2vImportVm, createV2vPartialImportSnapshot } from '@/test/create-v2v-import-vm.ts'
import {
createV2vCompleteImportSnapshot,
createV2vImportVm,
createV2vPartialImportSnapshot,
} from '@/test/create-v2v-import-vm.ts'
import { createV2vInventory, createV2vVm, createV2vVmWithDisks } from '@/test/create-v2v-inventory.ts'
import { createV2vVmRow, createV2vVmRowsInput } from '@/test/create-v2v-vm-row.ts'
import { V2V_DATASTORE_A, V2V_DATASTORE_B } from '@/test/v2v-vms-fixtures.ts'
@@ -103,6 +107,17 @@ describe('buildV2vVmRows', () => {
expect(rows[0]).toMatchObject({ migration: 'cold', lastSyncAt: undefined, importVm })
})
it('gives a cold import the cutover date of its own complete import snapshot', () => {
const importVm = createV2vImportVm({ isWarm: false, sourceVmId: VM_2.id })
const rows = buildRows(TWO_VMS, {
importVmsBySourceVmId: new Map([[VM_2.id, importVm]]),
snapshots: [createV2vCompleteImportSnapshot({ snapshot_time: 300 }), OTHER_VM_SNAPSHOT],
})
expect(rows.map(row => row.cutoverAt)).toEqual([undefined, 300_000])
})
it('marks a VM running while a task imports it', () => {
const rows = buildRows(TWO_VMS, { runningSourceVmIds: new Set([VM_2.id]) })

View File

@@ -0,0 +1,12 @@
import { getApiErrorMessage } from '@/shared/utils/api-error.util.ts'
import { useI18n } from 'vue-i18n'
export function useApiErrorMessage() {
const { t } = useI18n()
function getErrorMessage(error: unknown): string {
return getApiErrorMessage(error) ?? t('error')
}
return { getErrorMessage }
}

View File

@@ -0,0 +1,19 @@
import { useApiErrorMessage } from '@/shared/composables/api-error-message.composable.ts'
import { createApiError } from '@/test/create-api-error.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
function mountApiErrorMessage() {
return mountComposableWithI18n(() => useApiErrorMessage())
}
describe('getErrorMessage', () => {
it('tells the message the server explains the failure with', () => {
expect(mountApiErrorMessage().getErrorMessage(createApiError(500, { error: 'unreachable' }))).toBe('unreachable')
})
it('tells a generic error when the failure carries no message', () => {
const result = mountApiErrorMessage()
expect(result.getErrorMessage(new Error(''))).toBe(result.t('error'))
})
})

View File

@@ -8,3 +8,15 @@ export function getUnprocessableEntityData(error: unknown): unknown {
return isUnprocessableApiError && hasObjectProperty(error.cause, 'data') ? error.cause.data : undefined
}
// The server explains a failure better than the request message does. `undefined` lets each caller pick its fallback
export function getApiErrorMessage(error: unknown): string | undefined {
const serverError =
error instanceof ApiError && hasObjectProperty(error.cause, 'error') ? error.cause.error : undefined
if (typeof serverError === 'string' && serverError !== '') {
return serverError
}
return error instanceof Error && error.message !== '' ? error.message : undefined
}

View File

@@ -1,4 +1,5 @@
import { getUnprocessableEntityData } from '@/shared/utils/api-error.util.ts'
import { ApiError } from '@/shared/error/api.error.ts'
import { getApiErrorMessage, getUnprocessableEntityData } from '@/shared/utils/api-error.util.ts'
import { createApiError } from '@/test/create-api-error.ts'
describe('getUnprocessableEntityData', () => {
@@ -18,3 +19,21 @@ describe('getUnprocessableEntityData', () => {
expect(getUnprocessableEntityData(error)).toBeUndefined()
})
})
describe('getApiErrorMessage', () => {
it('reads the error the server answered', () => {
expect(getApiErrorMessage(createApiError(500, { error: 'host unreachable' }))).toBe('host unreachable')
})
it('falls back to the error message', () => {
expect(getApiErrorMessage(createApiError(502))).toBe('Request failed')
expect(getApiErrorMessage(new Error('network down'))).toBe('network down')
})
it.each([new ApiError('', { status: 500 }), new Error(''), 'string', undefined])(
'has no message to show without any (%s)',
error => {
expect(getApiErrorMessage(error)).toBeUndefined()
}
)
})

View File

@@ -1,9 +1,20 @@
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { FrontXoVbd } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { V2V_TEST_SOURCE } from '@/test/create-v2v-inventory.ts'
import { createVbd } from '@/test/create-vbd.ts'
import { createVdi } from '@/test/create-vdi.ts'
import { createVmSnapshot } from '@/test/create-vm-snapshot.ts'
import { createVm } from '@/test/create-vm.ts'
import { V2V_SNAPSHOT_NAME, V2V_START_BLOCKED_REASON, V2V_VM_OTHER_CONFIG, VM_OPERATIONS } from '@vates/types'
import {
V2V_SNAPSHOT_NAME,
V2V_START_BLOCKED_REASON,
V2V_VDI_OTHER_CONFIG,
V2V_VM_OTHER_CONFIG,
VM_OPERATIONS,
} from '@vates/types'
const IMPORT_VM_ID = 'xo-vm-1' as FrontXoVm['id']
@@ -70,3 +81,15 @@ export function createV2vPartialImportSnapshot(overrides: Partial<FrontXoVmSnaps
export function createV2vCompleteImportSnapshot(overrides: Partial<FrontXoVmSnapshot> = {}): FrontXoVmSnapshot {
return createV2vPartialImportSnapshot({ name_label: V2V_SNAPSHOT_NAME.COMPLETE_IMPORT, ...overrides })
}
// The VDI an import made on an SR from the last disk of a source chain, named `vdi-<name>`, and the VBD `vbd-<name>`
// plugging it into the import VM
export function createV2vImportedDisk(name: string, srId: FrontXoSr['id'], diskPath: string) {
const vdi = createVdi({
id: `vdi-${name}` as FrontXoVdi['id'],
$SR: srId,
other_config: { [V2V_VDI_OTHER_CONFIG.DISK_PATH]: diskPath },
})
return { vdi, vbd: createVbd({ id: `vbd-${name}` as FrontXoVbd['id'], VDI: vdi.id }) }
}

View File

@@ -1,3 +1,4 @@
import type { V2vMigratedVm } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
import type {
V2vLaunchPlanItem,
V2vLaunchSettings,
@@ -5,6 +6,7 @@ import type {
V2vVmLaunchOptionsRow,
} from '@/modules/v2v/types/v2v-launch.type.ts'
import type { V2vStartPayloadInput } from '@/modules/v2v/utils/v2v-start-payload.util.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createV2vConnection } from '@/test/create-v2v-connection.ts'
import { createV2vDestinationDefaults } from '@/test/create-v2v-migration-list.ts'
import { V2V_SELECTED_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
@@ -40,3 +42,17 @@ export function createV2vStartPayloadInput(overrides: Partial<V2vStartPayloadInp
...overrides,
}
}
// A VM cut over to another pool, its second copy not confirmed
export function createV2vMigratedVm(overrides: Partial<V2vMigratedVm> = {}): V2vMigratedVm {
return {
id: 'vm-1' as XoV2vVm['id'],
name: 'web-01',
importVm: { id: 'xo-vm-1' as FrontXoVm['id'], name: 'web-01 (imported)' },
poolName: 'forcair',
srs: 'sr-ssd-03',
cutover: 'Cutover done on 10/2/2026.',
isConfirmed: false,
...overrides,
}
}

View File

@@ -1,8 +1,11 @@
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
// Whether the stubbed `fetch` (see `setup.ts`, `stub-fetch-response.ts`) sent this request to the REST API
export function hasRequested(method: string, endpoint: string) {
return vi
.mocked(fetch)
.mock.calls.some(([url, options]) => url === `${BASE_URL}/${endpoint}` && options?.method === method)
// Whether the stubbed `fetch` (see `setup.ts`, `stub-fetch-response.ts`) sent this request to the REST API, with this
// JSON body when one is given
export function hasRequested(method: string, endpoint: string, body?: unknown) {
return vi.mocked(fetch).mock.calls.some(([url, options]) => {
const isSameRequest = url === `${BASE_URL}/${endpoint}` && options?.method === method
return isSameRequest && (body === undefined || options?.body === JSON.stringify(body))
})
}

View File

@@ -0,0 +1,20 @@
import type { V2vMigrationList } from '@/modules/v2v/types/v2v-migration-list.type.ts'
import type { V2vWizardSetup } from '@/test/mount-on-v2v-wizard.ts'
import { V2V_MAPPING_STEP_INVENTORY } from '@/test/v2v-mapping-step-fixtures.ts'
import { seedV2vReviewStepCollections, V2V_REVIEW_STEP_LIST } from '@/test/v2v-review-step-fixtures.ts'
import { flushPromises } from '@vue/test-utils'
// The review step runs on the collections of its fixtures, its route and a connected source; `mountOnWizard` mounts the
// step component or its composable on that setup
export async function mountV2vReviewStep<TMounted>(
mountOnWizard: (setup: V2vWizardSetup) => Promise<TMounted>,
{ list = V2V_REVIEW_STEP_LIST }: { list?: V2vMigrationList } = {}
) {
seedV2vReviewStepCollections()
const mounted = await mountOnWizard({ step: 'review', list, source: { inventory: V2V_MAPPING_STEP_INVENTORY } })
await flushPromises()
return mounted
}

View File

@@ -9,13 +9,11 @@ import type {
} from '@/modules/v2v/types/v2v-mapping.type.ts'
import type { V2vMigrationListVm } from '@/modules/v2v/types/v2v-migration-list.type.ts'
import type { V2vPoolDestinationChoices } from '@/modules/v2v/utils/v2v-destination.util.ts'
import type { FrontXoVbd } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import type { FrontXoVmTemplate } from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import { createNetwork } from '@/test/create-network.ts'
import { createSr } from '@/test/create-sr.ts'
import { createV2vImportVm } from '@/test/create-v2v-import-vm.ts'
import { createV2vImportedDisk, createV2vImportVm } from '@/test/create-v2v-import-vm.ts'
import {
createV2vDisk,
createV2vHost,
@@ -25,13 +23,11 @@ import {
createV2vVm,
} from '@/test/create-v2v-inventory.ts'
import { createV2vDestinationDefaults } from '@/test/create-v2v-migration-list.ts'
import { createVbd } from '@/test/create-vbd.ts'
import { createVdi } from '@/test/create-vdi.ts'
import { createVif } from '@/test/create-vif.ts'
import { createVmTemplate } from '@/test/create-vm-template.ts'
import { t } from '@/test/i18n.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import { V2V_VDI_OTHER_CONFIG, V2V_VM_OTHER_CONFIG, VM_POWER_STATE, type XoV2vDisk, type XoV2vNic } from '@vates/types'
import { V2V_VM_OTHER_CONFIG, VM_POWER_STATE, type XoV2vDisk, type XoV2vNic } from '@vates/types'
const DEFAULTS = createV2vDestinationDefaults()
@@ -159,13 +155,11 @@ export const V2V_NIC_ROWS: V2vNicMappingRow[] = [
// The first sync of the mapped VM put its system disk, last synced from its snapshot delta, on the SSD SR, and its NICs
// on the devices 0 and 1, the MAC of the second one changed. Its data disk is not paired: no VDI of the import has it
export const V2V_SYNCED_VDI = createVdi({
id: 'vdi-system' as FrontXoVdi['id'],
$SR: V2V_SSD_SR.id,
other_config: { [V2V_VDI_OTHER_CONFIG.DISK_PATH]: V2V_SYSTEM_DISK.chainPaths[1] },
})
const SYNCED_DISK = createV2vImportedDisk('system', V2V_SSD_SR.id, V2V_SYSTEM_DISK.chainPaths[1])
export const V2V_SYNCED_VBD = createVbd({ id: 'vbd-system' as FrontXoVbd['id'], VDI: V2V_SYNCED_VDI.id })
export const V2V_SYNCED_VDI = SYNCED_DISK.vdi
export const V2V_SYNCED_VBD = SYNCED_DISK.vbd
export const V2V_SYNCED_VIFS = [
createVif({

View File

@@ -0,0 +1,71 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createPool } from '@/test/create-pool.ts'
import { createSr } from '@/test/create-sr.ts'
import {
createV2vCompleteImportSnapshot,
createV2vImportedDisk,
createV2vImportVm,
} from '@/test/create-v2v-import-vm.ts'
import { createV2vMigrationList } from '@/test/create-v2v-migration-list.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { vmDeviceCollectionsMock } from '@/test/mock-xo-vm-device-collections.ts'
import { V2V_DEFAULT_OFFERS, V2V_SSD_SR } from '@/test/v2v-mapping-fixtures.ts'
import {
seedV2vMappingStepCollections,
V2V_DB_IMPORT_VM,
V2V_DB_SYNC_SNAPSHOT,
V2V_GONE_VM_ID,
} from '@/test/v2v-mapping-step-fixtures.ts'
import { V2V_DB_VM, V2V_LISTED_VM, V2V_WEB_VM } from '@/test/v2v-vms-fixtures.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import type { XoV2vVm } from '@vates/types'
// The web VM is new, the DB one resumed, the listed one already migrated, the last one gone from the source
export const V2V_REVIEW_STEP_LIST = createV2vMigrationList({
poolId: V2V_LIST_POOL.id,
vms: [{ id: V2V_WEB_VM.id }, { id: V2V_DB_VM.id }, { id: V2V_LISTED_VM.id }, { id: V2V_GONE_VM_ID }],
})
export const V2V_MIGRATED_VM_POOL = createPool({ id: 'pool-forcair' as FrontXoPool['id'], name_label: 'forcair' })
export const V2V_MIGRATED_VM_SR = createSr({ id: 'sr-migrated' as FrontXoSr['id'], name_label: 'sr-ssd-03' })
// The disk an import made of the first disk of a source VM, on an SR
function createImportedDisk(vm: XoV2vVm, sr: FrontXoSr) {
return createV2vImportedDisk(vm.id, sr.id, vm.disks[0].chainPaths[0])
}
const RESUMED_DISK = createImportedDisk(V2V_DB_VM, V2V_SSD_SR)
const MIGRATED_DISK = createImportedDisk(V2V_LISTED_VM, V2V_MIGRATED_VM_SR)
// The DB VM synced its first disk to the SSD SR of the pool of the list
export const V2V_REVIEW_DB_IMPORT_VM: FrontXoVm = { ...V2V_DB_IMPORT_VM, $VBDs: [RESUMED_DISK.vbd.id] }
// The listed VM was cut over to another pool
export const V2V_MIGRATED_IMPORT_VM = createV2vImportVm(
{ isWarm: false, sourceVmId: V2V_LISTED_VM.id },
{
id: 'xo-vm-migrated' as FrontXoVm['id'],
name_label: 'listed-01 (imported)',
$pool: V2V_MIGRATED_VM_POOL.id,
$VBDs: [MIGRATED_DISK.vbd.id],
}
)
export const V2V_CUTOVER_SNAPSHOT = createV2vCompleteImportSnapshot({ $snapshot_of: V2V_MIGRATED_IMPORT_VM.id })
// The mapping step collections, plus the import, the pool, the SR and the cutover snapshot of the migrated VM, and the
// disks of both imports
export function seedV2vReviewStepCollections() {
seedV2vMappingStepCollections()
xoCollectionsMock.pools.value = [V2V_LIST_POOL, V2V_MIGRATED_VM_POOL]
xoCollectionsMock.srs.value = [...V2V_DEFAULT_OFFERS.srs, V2V_SSD_SR, V2V_MIGRATED_VM_SR]
v2vVmRowsCollectionsMock.vms.value = [V2V_REVIEW_DB_IMPORT_VM, V2V_MIGRATED_IMPORT_VM]
v2vVmRowsCollectionsMock.snapshots.value = [V2V_DB_SYNC_SNAPSHOT, V2V_CUTOVER_SNAPSHOT]
vmDeviceCollectionsMock.vbds.value = [RESUMED_DISK.vbd, MIGRATED_DISK.vbd]
vmDeviceCollectionsMock.vdis.value = [RESUMED_DISK.vdi, MIGRATED_DISK.vdi]
}