mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(web): add the review step of the V2V migration wizard
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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]])
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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!'))
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type V2vVmRow = {
|
||||
supported: boolean
|
||||
unsupportedReason?: V2vUnsupportedReason
|
||||
lastSyncAt?: number
|
||||
cutoverAt?: number
|
||||
isInList: boolean
|
||||
importVm?: FrontXoVm
|
||||
vm: XoV2vVm
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]) })
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 }) }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
20
@xen-orchestra/web/src/test/mount-v2v-review-step.ts
Normal file
20
@xen-orchestra/web/src/test/mount-v2v-review-step.ts
Normal 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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
71
@xen-orchestra/web/src/test/v2v-review-step-fixtures.ts
Normal file
71
@xen-orchestra/web/src/test/v2v-review-step-fixtures.ts
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user