Compare commits

...

21 Commits

Author SHA1 Message Date
Olivier Floch
2ac7c0ffb8 test(web): seed the V2V review step on top of the list pool seed
seedV2vReviewStepCollections adds the migrated VM's pool and SRs to what seedV2vListPool seeds instead of seeding the list pool and its default SRs again
2026-11-09 18:48:04 +01:00
Olivier Floch
68ef82a9f3 fix(web-core): add the zero form of the French V2V review step counts 2026-11-09 18:48:04 +01:00
Olivier Floch
565dbfa835 refactor(web): read the V2V connection from the wizard context in the review step 2026-11-09 18:48:04 +01:00
Olivier Floch
a8952875ac docs(changelog): add the V2V migration wizard review step 2026-11-09 18:48:03 +01:00
Olivier Floch
7b44a15a51 test(web): share the running job expectation
The 7 job tests checking the "in progress" error of a running job use expectJobRunning; the V2V idle job tests check canRun alone, as the other job tests (canRun is the absence of error).
2026-11-09 18:48:03 +01:00
Olivier Floch
22ed4ac652 test(web): share the V2V check literals
Every V2V selected VM and source VM check a test expects comes from V2V_SELECTED_VM_CHECK, V2V_VM_CHECK or createV2vUnsupportedCheck, built from literals so a broken getV2vUnsupportedCheck fails its tests.
2026-11-09 18:48:03 +01:00
Olivier Floch
ee90cea718 test(web): share the V2V wizard component mount 2026-11-09 18:48:03 +01:00
Olivier Floch
1f2469b711 refactor(web-core): share the input wrapper message merge 2026-11-09 18:47:59 +01:00
Olivier Floch
533f69244b test(web): cover the remaining V2V review step branches 2026-11-09 18:47:58 +01:00
Olivier Floch
fbcf5ad7f7 refactor(web): share the V2V pool name, import VM, row lookup and list step mount
- useV2vPoolName names a pool through getV2vTargetName
- useV2vImportVm names the pool of an import VM and reads its active mapping, used by useV2vResumedVm and the review step
- useV2vSelectedVms exposes getRow and getName, used by the review step
- mountV2vListStep replaces mountV2vMappingStep and mountV2vReviewStep
2026-11-09 18:47:47 +01:00
Olivier Floch
104cdfbcb1 feat(web): add the review step of the V2V migration wizard 2026-11-09 18:47:47 +01:00
Olivier Floch
574e427b0a feat(web-core): add VtsSuccessStateHero component 2026-11-09 18:47:47 +01:00
Olivier Floch
48b445211b refactor(lite): use VtsNumberInput in the network forms
The network MTU and VLAN messages now wrap, as in the XO 6 forms.
2026-11-09 18:47:47 +01:00
Olivier Floch
a54e37c808 feat(web): add the V2V launch options cards 2026-11-09 18:47:47 +01:00
Olivier Floch
48ad080ada feat(web): add an info line to FormCheckbox
The VDI attach form moves from its own VdiFormCheckbox to FormCheckbox.
2026-11-09 18:47:46 +01:00
Olivier Floch
210e4f124e refactor(web): use VtsNumberInput in the network and traffic rule forms
The network MTU and VLAN messages now wrap, as in every other XO 6 form input.
2026-11-09 18:47:46 +01:00
Olivier Floch
e7da1be6ba feat(web-core): add VtsNumberInput component 2026-11-09 18:47:46 +01:00
Olivier Floch
d10aa60358 feat(web): add the V2V start job
- useXoV2vStartJob posts the launch to the pool import route and returns
  its task id, without monitoring it: an import lasts hours
- useV2vLaunchForm: stop the source VMs and stop on error by default, two
  VMs at a time, concurrency an integer from 1 to 8 as the back end accepts
- Dev mock: the import route answers a task id, or the problems the back
  end would refuse the launch for
- New test helper: createV2vStartPayloadInput, the input of buildV2vStartPayload
2026-11-09 18:47:46 +01:00
Olivier Floch
374d2c9d39 feat(web): add the V2V launch plan, options and start payload utils
- getV2vLaunchPlan groups the list VMs as the selected VMs panel checks them
  (excluded, already migrated, resumed, new) and keeps the launchable ones,
  an already migrated VM only once confirmed
- getV2vVmStopSource / setV2vVmStopSource: the stop source option of a VM,
  from the launch settings or its override; resolveV2vValue now gives the
  value and origin of any V2V override (resolveV2vTarget builds on it)
- buildV2vStartPayload sends only what the back end accepts (no start, no
  cleanup, no tags, MACs kept), no mapping for a resumed VM;
  getV2vStartProblems reads the problems of a refused launch
- getV2vCutoverAt: the date of the complete import snapshot
- getUnprocessableEntityData shared by the V2V connection and start errors
- New test helpers: createApiError, V2V_SELECTED_VM_CHECK, the V2V launch
  factories and createV2vCompleteImportSnapshot
2026-11-09 18:47:46 +01:00
Olivier Floch
b82d641783 test(web): provide the V2V mapping connection beside the other connection provides
The resumed VM and VM actions tests read it without mounting a mapping. The actions test only needs a
connection to open the drawer under: it gets a plain one.
2026-11-09 18:39:29 +01:00
Olivier Floch
3a41fba148 fix(web): open the V2V VM settings drawer outside the wizard context
- The drawer is an overlay rendered at the app root, out of the migration page: reading the V2V connection there
  threw, so Edit settings opened nothing
- useV2vVmTargets: the targets and options the drawer edits, read from an inventory handed in; useV2vVmMapping adds
  the pool name, host and checks of the side panel on top
- useV2vVmSettingsEdit hands the drawer the inventory of the connected source
- Tests: the drawer is mounted without the wizard connection; v2vMappingConnectionProvide shared by the mapping tests
2026-11-09 18:39:29 +01:00
138 changed files with 4225 additions and 697 deletions

View File

@@ -7,7 +7,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -18,7 +18,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -27,6 +26,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewBondedNetworkForm } from '@/modules/network/form/new-bonded/use-new-bonded-network-form.ts'
import type { NewBondedNetworkPayload } from '@/modules/network/jobs/bonded-network-create.job.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -4,7 +4,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -16,13 +16,13 @@
</template>
<script setup lang="ts">
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
import NewNetworkNbdRadio from '@/modules/network/components/form/new/inputs/NewNetworkNbdRadio.vue'
import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewNetworkButtonsSection.vue'
import { useNewInternalNetworkForm } from '@/modules/network/form/new-internal/use-new-internal-network-form.ts'
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/internal-network-create.job.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -7,18 +7,17 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
<NetworkFormNumberInput v-bind="vlanInputBindings" class="vlan" />
<VtsNumberInput v-bind="vlanInputBindings" class="vlan" />
<NewNetworkNbdRadio class="nbd" v-bind="nbdRadioBindings" />
<NewNetworkButtonsSection :cancel-to :submit-label="t('action:create-network')" />
</VtsForm>
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -27,6 +26,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewNetworkForm } from '@/modules/network/form/new/use-new-network-form.ts'
import type { NewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -1,27 +0,0 @@
<template>
<VtsInputWrapper :label :message="messages">
<UiInput v-model.number="model" accent="brand" type="number" :required @blur="emit('blur')" />
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
label: string
error?: InputWrapperMessage
warning?: InputWrapperMessage
info?: string
required?: boolean
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<number | undefined>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
</script>

View File

@@ -422,6 +422,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/number-input/vts-number-input': RouteRecordInfo<
'/story/web-core/number-input/vts-number-input',
'/story/web-core/number-input/vts-number-input',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/object-icon/vts-object-icon': RouteRecordInfo<
'/story/web-core/object-icon/vts-object-icon',
'/story/web-core/object-icon/vts-object-icon',
@@ -499,6 +506,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/success-state-hero/vts-success-state-hero': RouteRecordInfo<
'/story/web-core/success-state-hero/vts-success-state-hero',
'/story/web-core/success-state-hero/vts-success-state-hero',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/tab/router-tab': RouteRecordInfo<
'/story/web-core/tab/router-tab',
'/story/web-core/tab/router-tab',
@@ -1489,6 +1503,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/number-input/vts-number-input.story.vue': {
routes:
| '/story/web-core/number-input/vts-number-input'
views:
| never
}
'src/stories/web-core/object-icon/vts-object-icon.story.vue': {
routes:
| '/story/web-core/object-icon/vts-object-icon'
@@ -1555,6 +1575,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/success-state-hero/vts-success-state-hero.story.vue': {
routes:
| '/story/web-core/success-state-hero/vts-success-state-hero'
views:
| never
}
'src/stories/web-core/tab/router-tab.story.vue': {
routes:
| '/story/web-core/tab/router-tab'

View File

@@ -0,0 +1,8 @@
# Usage
A number field with its label and its info, warning and error messages, ready for the field bindings of a validated
form. A cleared input gives an empty value, not `0`.
```vue-template
<VtsNumberInput v-model="quantity" label="Quantity" info="Between 1 and 8" />
```

View File

@@ -0,0 +1,23 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model().type('number | undefined').preset(2),
prop('label').str().required().preset('Quantity').widget(),
prop('info').str().widget(),
prop('warning').type('InputWrapperMessage').widget(object()),
prop('error').type('InputWrapperMessage').widget(object()),
prop('required').bool().widget(),
event('blur'),
]"
>
<VtsNumberInput v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { event, model, prop } from '@/libs/story/story-param.ts'
import { object } from '@/libs/story/story-widget.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
</script>

View File

@@ -0,0 +1,13 @@
# Usage
The card telling an action succeeded: an optional title, the success alert, then the actions that follow, centred.
```vue-template
<VtsSuccessStateHero>
<template #title>Connected to 192.168.1.1</template>
The pool is connected.
<template #actions>
<UiLink :to="poolRoute" size="medium">Visit the pool dashboard</UiLink>
</template>
</VtsSuccessStateHero>
```

View File

@@ -0,0 +1,27 @@
<template>
<ComponentStory
v-slot="{ settings }"
:params="[
slot('title').help('Optional, shown above the alert'),
slot().help('The text of the success alert'),
slot('actions').help('The links and buttons that follow the success, centred'),
setting('titleSlot').preset('Connected to 192.168.1.1').widget(),
setting('defaultSlot').preset('The pool is connected.').widget(),
]"
>
<VtsSuccessStateHero>
<template v-if="settings.titleSlot" #title>{{ settings.titleSlot }}</template>
{{ settings.defaultSlot }}
<template #actions>
<UiButton accent="brand" variant="secondary" size="medium">Continue</UiButton>
</template>
</VtsSuccessStateHero>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { setting, slot } from '@/libs/story/story-param.ts'
import VtsSuccessStateHero from '@core/components/success-state-hero/VtsSuccessStateHero.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
</script>

View File

@@ -8,6 +8,7 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { mergeInputWrapperMessages } from '@core/utils/input-wrapper-message.util.ts'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
@@ -19,9 +20,8 @@ const { info, warning, error } = defineProps<{
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<number | undefined>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
const messages = computed(() => mergeInputWrapperMessages(info, warning, error))
</script>

View File

@@ -0,0 +1,69 @@
<template>
<VtsStateHero class="vts-success-state-hero" type="all-good" format="card" size="medium">
<div class="container">
<div v-if="slots.title" class="typo-h1 title"><slot name="title" /></div>
<div class="content" :class="{ mobile: uiStore.isSmall, desktop: !uiStore.isLarge }">
<UiAlert accent="success"><slot /></UiAlert>
<div class="action-buttons" :class="{ mobile: uiStore.isSmall }">
<slot name="actions" />
</div>
</div>
</div>
</VtsStateHero>
</template>
<script lang="ts" setup>
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
const slots = defineSlots<{
title?(): any
default(): any
actions(): any
}>()
const uiStore = useUiStore()
</script>
<style lang="postcss" scoped>
.vts-success-state-hero {
.container {
display: flex;
flex-direction: column;
gap: 4rem;
}
.title {
color: var(--color-neutral-txt-primary);
}
.content {
color: var(--color-neutral-txt-primary);
display: flex;
flex-direction: column;
gap: 4rem;
margin: 0 auto;
width: 65%;
&.desktop {
width: 80%;
}
&.mobile {
width: 100%;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 2.4rem;
&.mobile {
flex-direction: column;
align-items: center;
}
}
}
}
</style>

View File

@@ -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",
@@ -394,8 +397,10 @@
"default-behavior": "Default behavior",
"default-locking-mode": "Default locking mode",
"default-network": "Default network",
"default-options": "Default options",
"default-storage-repository": "Default storage repository",
"default-template": "Default template",
"default-value": "Default ({value})",
"deletion-blocked": "Deletion Blocked",
"delta-transfer": "Delta transfer",
"deploy-xoa-available-on-desktop": "XOA deployment is available on your desktop interface",
@@ -413,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.",
@@ -721,6 +727,8 @@
"job:sr-delete:missing-sr": "No SRs to delete",
"job:sr-disconnect:missing-sr": "No SRs to disconnect",
"job:traffic-rule-delete:missing-rule": "No traffic rule selected",
"job:v2v-start:in-progress": "Migration launch in progress…",
"job:v2v-start:missing-vm": "No VMs to migrate",
"job:vbd-connect:missing-vbd": "No VBDs to connect",
"job:vbd-connect:vbd-already-connected": "VBD is already connected",
"job:vbd-delete:missing-vbd": "No VBDs to delete",
@@ -938,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",
@@ -954,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.",
@@ -1071,6 +1081,7 @@
"not-ready": "Not ready",
"not-resident-on": "Not resident on",
"not-running": "Not running",
"not-supported-yet": "Not supported yet",
"not-yet-available": "Information is not yet available",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
@@ -1102,6 +1113,8 @@
"operation:vm-clean-shutdown": "VMs clean shutting down",
"operation:vm-hard-shutdown": "VMs hard shutting down",
"operation:vm-suspend": "VMs suspending",
"option-off": "@:off",
"option-on": "@:on",
"optional": "Optional",
"options": "Options",
"or": "Or",
@@ -1130,6 +1143,7 @@
"pause-blocked": "Pause Blocked",
"pbd-details": "Physical Block Device (PBD) details",
"pending": "Pending",
"per-vm-options": "Per-VM options",
"physical-interface-status": "Physical interface status",
"pif": "PIF",
"pif-status": "PIF status",
@@ -1186,6 +1200,7 @@
"power-on-vm-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"power-state-on-host": "{power} on {host}",
"preview": "Preview",
"pro-support": "{name} pro support",
"professional-support": "Professional support",
"progress": "Progress",
@@ -1272,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.",
@@ -1381,6 +1397,7 @@
"status:unknown": "Unknown",
"status:unreachable": "Unreachable",
"step-n-of": "Step {current} / {total}",
"stop-on-error": "Stop on error",
"storage": "Storage",
"storage-configuration": "Storage configuration",
"storage-format": "Storage format",
@@ -1519,29 +1536,45 @@
"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",
"v2v:check:no-snapshot": "No snapshot on the source VM",
"v2v:check:no-unsupported-disk": "No unsupported disk (RDM, independent, encrypted)",
"v2v:choose-vmware-source": "Choose the VMware source",
"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)",
"v2v:no-vm-in-list": "No VM in the migration list",
"v2v:not-found-in-source": "Not found in the source",
"v2v:partial-import-on-pool": "Partial import on pool {pool}",
"v2v:per-vm-options-info": "\"Default\" follows the default options above",
"v2v:prerequisite-checking": "Prerequisite check: checking vectura…",
"v2v:prerequisite-error": "Prerequisite check: vectura cannot be used, VMs cannot be migrated until it is fixed",
"v2v:prerequisite-error-detail": "Prerequisite check: vectura cannot be used ({error}), VMs cannot be migrated until it is fixed",
@@ -1551,12 +1584,20 @@
"v2v:sent-to-xo-at-launch": "sent to XO at launch",
"v2v:source-snapshot-changed": "Source snapshot changed since the last sync",
"v2v:start-blocked-partial": "Start blocked while the import is partial",
"v2v:start-on-xcp-ng": "Start on XCP-ng",
"v2v:start-vm-on-xcp-ng": "Start the VM on XCP-ng",
"v2v:status:cold": "Cutover done",
"v2v:status:warm": "Warm synced",
"v2v:stop-on-error-info": "Stop on the first error when importing VMs. Applies to the whole launch.",
"v2v:stop-source": "Stop source",
"v2v:stop-source-vm": "Stop the source VM",
"v2v:stop-source-vm-info": "Source VM stopped before the last delta transfer (after final snapshot). Needed to fully transfer a running VM.",
"v2v:tags-backup-info": "Use tags to pick up the VM in smart backup jobs. Until the cutover, the VM also carries {tag}, so backup jobs skip it; the tag is removed at cutover.",
"v2v:tags-mac-not-applied": "Tags and the Keep MAC addresses option of the mapping step are not applied yet.",
"v2v:unsupported-encrypted-disk": "Unsupported: encrypted disk",
"v2v:unsupported-independent-disk": "Unsupported: independent disk",
"v2v:unsupported-rdm-disk": "Unsupported: RDM disk",
"v2v:vm-start-problem": "{vm}: {message}",
"value-by-default": "{value} by default",
"value-from": "Value from",
"vcpu": "vCPU",

View File

@@ -21,6 +21,7 @@
"action:attach": "Attacher",
"action:attach-vdi": "@:attach-vdi",
"action:back": "Retour",
"action:back-to-vms": "Retour aux VMs",
"action:boot-vm": "Démarrer la VM",
"action:change-state": "Changer l'état",
"action:clear": "Vider",
@@ -123,6 +124,7 @@
"action:import-export": "Importer/Exporter",
"action:install-app": "Installer {name}",
"action:install-guest-tools": "Installer les guest tools",
"action:launch-n-vms": "Lancer 0 VM | Lancer 1 VM | Lancer {n} VMs",
"action:load": "Charger",
"action:load-now": "Charger maintenant",
"action:log-out": "Se déconnecter",
@@ -159,6 +161,7 @@
"action:see-all": "Voir tout",
"action:see-less": "Voir moins",
"action:see-n-more": "Voir {n} de plus",
"action:see-task": "Voir la tâche",
"action:select-all": "Tout sélectionner",
"action:select-all-n-objects": "sélectionner {n} objet | sélectionner les {n} objets",
"action:select-n-objects-of": "{from} - {to} de {total} objets",
@@ -394,8 +397,10 @@
"default-behavior": "Comportement par défaut",
"default-locking-mode": "Mode de verrouillage par défaut",
"default-network": "Réseau par défaut",
"default-options": "Options par défaut",
"default-storage-repository": "Dépôt de stockage par défaut",
"default-template": "Modèle par défaut",
"default-value": "Par défaut ({value})",
"deletion-blocked": "Suppression Bloquée",
"delta-transfer": "Transfert delta",
"deploy-xoa-available-on-desktop": "Le déploiement de la XOA est disponible sur ordinateur",
@@ -413,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.",
@@ -721,6 +727,8 @@
"job:sr-delete:missing-sr": "Aucun SR à supprimer",
"job:sr-disconnect:missing-sr": "Aucun SR à déconnecter",
"job:traffic-rule-delete:missing-rule": "Aucune règle de trafic sélectionnée",
"job:v2v-start:in-progress": "Lancement de la migration en cours…",
"job:v2v-start:missing-vm": "Aucune VM à migrer",
"job:vbd-connect:missing-vbd": "Aucun VBD à connecter",
"job:vbd-connect:vbd-already-connected": "Le VBD est déjà connecté",
"job:vbd-delete:missing-vbd": "Aucun VBD à supprimer",
@@ -938,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": "0 à confirmer | 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",
@@ -954,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.",
@@ -1071,6 +1081,7 @@
"not-ready": "Non prête",
"not-resident-on": "Non résident sur",
"not-running": "Pas en cours d'exécution",
"not-supported-yet": "Pas encore pris en charge",
"not-yet-available": "Information pas encore disponible",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
@@ -1102,6 +1113,8 @@
"operation:vm-clean-shutdown": "Arrêt des VMs",
"operation:vm-hard-shutdown": "Arrêt forcé des VMs",
"operation:vm-suspend": "Suspension des VMs",
"option-off": "Désactivé",
"option-on": "Activé",
"optional": "Facultatif",
"options": "Options",
"or": "Ou",
@@ -1130,6 +1143,7 @@
"pause-blocked": "Mise en pause bloquée",
"pbd-details": "Détails du périphérique de bloc physique (PBD)",
"pending": "En cours",
"per-vm-options": "Options par VM",
"physical-interface-status": "Statut de l'interface physique",
"pif": "PIF",
"pif-status": "Statut du PIF",
@@ -1186,6 +1200,7 @@
"power-on-vm-for-console": "Allumez votre VM pour accéder à sa console",
"power-state": "État d'alimentation",
"power-state-on-host": "{power} sur {host}",
"preview": "Aperçu",
"pro-support": "Support pro {name}",
"professional-support": "Support professionnel",
"progress": "Progression",
@@ -1272,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.",
@@ -1381,6 +1397,7 @@
"status:unknown": "Inconnu",
"status:unreachable": "Injoignable | Injoignable | Injoignables",
"step-n-of": "Étape {current} / {total}",
"stop-on-error": "Arrêter en cas d’erreur",
"storage": "Stockage",
"storage-configuration": "Configuration du stockage",
"storage-format": "Format de stockage",
@@ -1519,29 +1536,45 @@
"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",
"v2v:check:no-snapshot": "Aucun instantané sur la VM source",
"v2v:check:no-unsupported-disk": "Aucun disque non pris en charge (RDM, indépendant, chiffré)",
"v2v:choose-vmware-source": "Choisir la source VMware",
"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": "0 exclue | 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)",
"v2v:no-vm-in-list": "Aucune VM dans la liste de migration",
"v2v:not-found-in-source": "Introuvable dans la source",
"v2v:partial-import-on-pool": "Import partiel sur le pool {pool}",
"v2v:per-vm-options-info": "« Par défaut » suit les options par défaut ci-dessus",
"v2v:prerequisite-checking": "Vérification des prérequis : vérification de vectura…",
"v2v:prerequisite-error": "Vérification des prérequis : vectura ne peut pas être utilisé, aucune VM ne peut être migrée tant que ce n'est pas corrigé",
"v2v:prerequisite-error-detail": "Vérification des prérequis : vectura ne peut pas être utilisé ({error}), aucune VM ne peut être migrée tant que ce n'est pas corrigé",
@@ -1551,12 +1584,20 @@
"v2v:sent-to-xo-at-launch": "envoyée à XO au lancement",
"v2v:source-snapshot-changed": "Le snapshot source a changé depuis la dernière synchronisation",
"v2v:start-blocked-partial": "Démarrage bloqué tant que l’import est partiel",
"v2v:start-on-xcp-ng": "Démarrer sur XCP-ng",
"v2v:start-vm-on-xcp-ng": "Démarrer la VM sur XCP-ng",
"v2v:status:cold": "Bascule effectuée",
"v2v:status:warm": "Synchronisée à chaud",
"v2v:stop-on-error-info": "S’arrêter à la première erreur lors de l’import des VMs. S’applique à tout le lancement.",
"v2v:stop-source": "Arrêt de la source",
"v2v:stop-source-vm": "Arrêter la VM source",
"v2v:stop-source-vm-info": "VM source arrêtée avant le dernier transfert delta (après le snapshot final). Nécessaire pour transférer entièrement une VM en cours d’exécution.",
"v2v:tags-backup-info": "Utilisez les tags pour retrouver la VM dans les jobs de sauvegarde en mode intelligent. Jusqu’à la bascule, la VM porte aussi {tag}, pour que les jobs de sauvegarde l’ignorent ; ce tag est retiré à la bascule.",
"v2v:tags-mac-not-applied": "Les tags et l’option Conserver les adresses MAC de l’étape Correspondance ne sont pas encore appliqués.",
"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",

View File

@@ -0,0 +1,6 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import { toArray } from '@core/utils/to-array.utils.ts'
export function mergeInputWrapperMessages(...messages: (InputWrapperMessage | undefined)[]): InputWrapperMessage {
return messages.flatMap(message => toArray(message))
}

View File

@@ -1,4 +1,4 @@
import type { XoV2vInventoryPayload, XoV2vStatus } from '@vates/types'
import type { XoV2vInventoryPayload, XoV2vStartPayload, XoV2vStatus, XoV2vVm } from '@vates/types'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Plugin } from 'vite'
import { createMockInventory } from './fixtures.ts'
@@ -45,6 +45,91 @@ async function handleInventory(request: IncomingMessage, response: ServerRespons
sendJson(response, 200, createMockInventory(connection.host))
}
type V2vStartProblem = { vm?: XoV2vVm['id']; field?: string; message: string }
const START_VM_UNSUPPORTED = 'starting the VM after its import is not supported yet'
const CLEANUP_UNSUPPORTED = 'cleaning up the VM is not supported yet'
// Mirrors `planMigration` of the REST API (`@xen-orchestra/rest-api/src/v2v/v2v.plan.mts`): every problem at once
function planMockMigration({ connection, defaults, vms }: XoV2vStartPayload): V2vStartProblem[] {
const problems: V2vStartProblem[] = []
const sourceVms = new Map(createMockInventory(connection.host).vms.map(vm => [vm.id, vm]))
const seenVmIds = new Set<XoV2vVm['id']>()
if (defaults.startVm) {
problems.push({ field: 'defaults.startVm', message: START_VM_UNSUPPORTED })
}
if (defaults.cleanup) {
problems.push({ field: 'defaults.cleanup', message: CLEANUP_UNSUPPORTED })
}
for (const vm of vms) {
const report = (message: string, field?: string) => problems.push({ vm: vm.id, field, message })
if (seenVmIds.has(vm.id)) {
report('the VM is listed more than once')
continue
}
seenVmIds.add(vm.id)
const sourceVm = sourceVms.get(vm.id)
if (sourceVm === undefined) {
report('no such VM on the source')
continue
}
if (vm.startVm) {
report(START_VM_UNSUPPORTED, 'startVm')
}
if (vm.cleanup) {
report(CLEANUP_UNSUPPORTED, 'cleanup')
}
if (vm.tags.length > 0) {
report('setting tags is not supported yet', 'tags')
}
if (!vm.keepMac) {
report('the MAC addresses are always kept for now', 'keepMac')
}
for (const disk of sourceVm.disks) {
if (disk.isRdm) {
report(`the disk ${disk.key} is a raw device mapping, which cannot be imported`, 'disks')
}
if (disk.isEncrypted) {
report(`the disk ${disk.key} is encrypted, which cannot be imported`, 'disks')
}
}
}
return problems
}
async function handleStart(request: IncomingMessage, response: ServerResponse) {
const problems = planMockMigration(await readJson<XoV2vStartPayload>(request))
if (problems.length > 0) {
// What the REST API error handler answers for `invalidParameters`
sendJson(response, 422, { error: 'the migration cannot be started', data: { errors: problems } })
return
}
// The task does not exist in XO: its link leads to an empty task panel
sendJson(response, 202, { taskId: `v2v-mock-task-${Date.now()}` })
}
const START_PATHNAME = /^\/rest\/v0\/pools\/[^/]+\/actions\/import_vms_from_vmware$/
// Stands in for the V2V REST routes until the back end ships them (dev server only, never built)
export function v2vMockPlugin({ enabled, vecturaError }: V2vMockOptions): Plugin {
return {
@@ -70,6 +155,12 @@ export function v2vMockPlugin({ enabled, vecturaError }: V2vMockOptions): Plugin
return
}
if (START_PATHNAME.test(pathname) && request.method === 'POST') {
handleStart(request, response).catch(next)
return
}
next()
})
},

File diff suppressed because one or more lines are too long

View File

@@ -53,4 +53,4 @@ The same goes for `mock-xo-v2v-vm-rows-collections.ts`, the VM, task and VM snap
## Network
`src/test/setup.ts` stubs the global `fetch` with a never-settling promise before each test, so composables that trigger remote requests on use never hit the network in unit tests. When a test needs a real response, `stubFetchResponse(status, body)` from `src/test/stub-fetch-response.ts` overrides it for that test only and returns the mock, to assert the request when the request is the contract (a job).
`src/test/setup.ts` stubs the global `fetch` with a never-settling promise before each test, so composables that trigger remote requests on use never hit the network in unit tests. When a test needs a real response, `stubFetchResponse(status, body)` from `src/test/stub-fetch-response.ts` overrides it for that test only and returns the mock, to assert the request when the request is the contract (a job). `stubFetchServerError()` answers a 500 the server explains with `SERVER_ERROR_MESSAGE`; `stubV2vStartAccepted()` (the V2V start route answering `V2V_START_TASK_ID`) and `stubV2vStartProblems(errors)` (refusing the launch for those problems) live in `create-v2v-launch.ts`.

View File

@@ -259,7 +259,7 @@ describe('multi-edit mode', () => {
}
// The selects display the label of their selected option
function findSelectedLabels(
function findShownSelections(
formData: Partial<BackupRepositoryGeneralFormData>,
mixedFields?: (keyof BackupRepositoryGeneralFormData)[]
) {
@@ -280,7 +280,7 @@ describe('multi-edit mode', () => {
}
it('shows the mixed value on the type, the format and the proxy which differ between the repositories', () => {
expect(findSelectedLabels({ name: 'My repository' }, ['type', 'backupFormat', 'proxy'])).toEqual({
expect(findShownSelections({ name: 'My repository' }, ['type', 'backupFormat', 'proxy'])).toEqual({
[t('type')]: t('mixed-value'),
[t('backup-format')]: t('mixed-value'),
[t('proxy')]: t('mixed-value'),
@@ -288,7 +288,7 @@ describe('multi-edit mode', () => {
})
it('shows the values shared by all the repositories', () => {
const selectedLabels = findSelectedLabels({
const selectedLabels = findShownSelections({
name: 'My repository',
type: 'nfs',
backupFormat: 'block',
@@ -300,7 +300,7 @@ describe('multi-edit mode', () => {
})
it('removes the proxy of all the repositories with "None"', () => {
expect(findSelectedLabels({ name: 'My repository', proxy: null }, ['proxy'])[t('proxy')]).toBe(t('none'))
expect(findShownSelections({ name: 'My repository', proxy: null }, ['proxy'])[t('proxy')]).toBe(t('none'))
})
it('accepts a type and a format left mixed', async () => {

View File

@@ -7,7 +7,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -20,7 +20,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -29,6 +28,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewBondedNetworkForm } from '@/modules/network/form/new-bonded/use-new-bonded-network-form.ts'
import type { NewBondedNetworkPayload } from '@/modules/network/jobs/xo-bonded-network-create.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -4,7 +4,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -16,7 +16,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -25,6 +24,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewInternalNetworkForm } from '@/modules/network/form/new-internal/use-new-internal-network-form.ts'
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/xo-internal-network-create.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -7,11 +7,11 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
<NetworkFormNumberInput v-bind="vlanInputBindings" class="vlan" />
<VtsNumberInput v-bind="vlanInputBindings" class="vlan" />
<div class="nbd">
<NewNetworkNbdRadio v-bind="nbdRadioBindings" />
</div>
@@ -20,7 +20,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -29,6 +28,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewNetworkForm } from '@/modules/network/form/new/use-new-network-form.ts'
import type { NewNetworkPayload } from '@/modules/network/jobs/xo-network-create.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -1,27 +0,0 @@
<template>
<VtsInputWrapper :label :message="messages">
<UiInput v-model.number="model" accent="brand" type="number" :required @blur="emit('blur')" />
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
label: string
error?: InputWrapperMessage
warning?: InputWrapperMessage
info?: string
required?: boolean
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<number | undefined>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
</script>

View File

@@ -3,6 +3,7 @@ import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plu
import type { fetchPatch } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { patch } = vi.hoisted(() => ({
@@ -63,9 +64,6 @@ describe('validate', () => {
patch.mockReturnValue(new Promise(() => {}))
const job = mountAutoloadJob(createPlugin())
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:edit:in-progress'))
expectJobRunning(job, t('job:edit:in-progress'))
})
})

View File

@@ -4,6 +4,7 @@ import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composab
import type { fetchPatch, fetchPost } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
@@ -128,9 +129,6 @@ describe('validate', () => {
post.mockReturnValue(new Promise(() => {}))
const job = mountChangeStateJob(plugin, loaded)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t(key))
expectJobRunning(job, t(key))
})
})

View File

@@ -3,6 +3,7 @@ import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plu
import type { fetchPut } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { put } = vi.hoisted(() => ({
@@ -72,9 +73,6 @@ describe('validate', () => {
put.mockReturnValue(new Promise(() => {}))
const job = mountConfigureJob(configurablePlugin)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:edit:in-progress'))
expectJobRunning(job, t('job:edit:in-progress'))
})
})

View File

@@ -3,6 +3,7 @@ import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plu
import type { fetchDelete } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { remove } = vi.hoisted(() => ({
@@ -79,9 +80,6 @@ describe('validate', () => {
remove.mockReturnValue(new Promise(() => {}))
const job = mountDeleteConfigurationJob(configuredPlugin)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:delete:in-progress'))
expectJobRunning(job, t('job:delete:in-progress'))
})
})

View File

@@ -4,6 +4,7 @@ import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composab
import type { fetchPost } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { post, monitorTask } = vi.hoisted(() => ({
@@ -85,9 +86,6 @@ describe('validate', () => {
post.mockReturnValue(new Promise(() => {}))
const job = mountTestJob(testablePlugin)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:plugin-test:in-progress'))
expectJobRunning(job, t('job:plugin-test:in-progress'))
})
})

View File

@@ -0,0 +1,74 @@
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 { findTitle } from '@/test/find-titles.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(findTitle(mountSuccess())).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(findTitle(wrapper)).toBeUndefined()
expect(wrapper.findComponent(UiLink).exists()).toBe(false)
})
it('has no link while the pool of the server is unknown', () => {
servers.value = [{ ...SERVER, poolId: undefined }]
expect(mountSuccess().findComponent(UiLink).exists()).toBe(false)
})

View File

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

View File

@@ -12,7 +12,7 @@
</template>
</TrafficRuleFormSelect>
<TrafficRuleFormSelect v-bind="protocolSelectBindings" />
<TrafficRuleFormNumberInput v-if="hasPort" v-bind="portInputBindings" />
<VtsNumberInput v-if="hasPort" v-bind="portInputBindings" />
</div>
<div class="row">
@@ -50,11 +50,11 @@
</template>
<script setup lang="ts">
import TrafficRuleFormNumberInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormNumberInput.vue'
import TrafficRuleFormSelect from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormSelect.vue'
import TrafficRuleFormTextInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormTextInput.vue'
import { useEditTrafficRuleForm } from '@/modules/traffic-rules/form/edit/use-edit-traffic-rule-form.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import VtsOption from '@core/components/select/VtsOption.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import type { TrafficRule } from '@vates/types'

View File

@@ -15,6 +15,7 @@ import type { IconName } from '@core/icons'
import type { FormOption, FormSelectId } from '@core/packages/form-select'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import { mergeInputWrapperMessages } from '@core/utils/input-wrapper-message.util.ts'
import { computed } from 'vue'
export type TrafficRuleFormSelectOption = FormOption<{
@@ -34,7 +35,5 @@ const slots = defineSlots<{
option?(props: { option: TrafficRuleFormSelectOption }): any
}>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
const messages = computed(() => mergeInputWrapperMessages(info, warning, error))
</script>

View File

@@ -8,6 +8,7 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { mergeInputWrapperMessages } from '@core/utils/input-wrapper-message.util.ts'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
@@ -22,7 +23,5 @@ const emit = defineEmits<{ blur: [] }>()
const model = defineModel<string>({ required: true })
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
const messages = computed(() => mergeInputWrapperMessages(info, warning, error))
</script>

View File

@@ -12,7 +12,7 @@
</template>
</TrafficRuleFormSelect>
<TrafficRuleFormSelect v-bind="protocolSelectBindings" />
<TrafficRuleFormNumberInput v-if="hasPort" v-bind="portInputBindings" />
<VtsNumberInput v-if="hasPort" v-bind="portInputBindings" />
</div>
<div class="row">
@@ -66,7 +66,6 @@
<script setup lang="ts">
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import TrafficRuleFormNumberInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormNumberInput.vue'
import TrafficRuleFormSelect from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormSelect.vue'
import TrafficRuleFormTextInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormTextInput.vue'
import NewTrafficRuleButtonsSection from '@/modules/traffic-rules/components/form/new/NewTrafficRuleButtonsSection.vue'
@@ -75,6 +74,7 @@ import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import VtsOption from '@core/components/select/VtsOption.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -20,6 +20,7 @@ import {
V2V_DISK_ROWS,
V2V_LAN_NIC,
V2V_MAPPED_VM,
V2V_MAPPING_INVENTORY,
V2V_NIC_OVERRIDES,
V2V_NIC_ROWS,
V2V_POOL_OFFERS,
@@ -60,15 +61,14 @@ vi.mock(
)
// The tables and the fields render what the drawer hands them, each covered by its own test: here only the wiring
// matters
// matters. As an overlay, the drawer renders outside the wizard: it is handed the inventory, never its connection
function mountDrawer({ vm = V2V_MAPPED_VM, listVm }: { vm?: XoV2vVm; listVm?: V2vMigrationListVm } = {}) {
return mountV2vVmMappingComponent(
(list, router, provide) =>
(list, router) =>
mount(V2vVmSettingsDrawer, {
props: { vm, list },
props: { vm, list, inventory: V2V_MAPPING_INVENTORY },
global: createGlobalTestConfig({
router,
provide,
stubs: { V2vDiskMappingTable: true, V2vNicMappingTable: true, V2vVmOptionsFields: true },
}),
attachTo: document.body,

View File

@@ -60,12 +60,13 @@ import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelBut
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import type { XoV2vVm } from '@vates/types'
import type { XoV2vInventory, XoV2vVm } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { vm, list } = defineProps<{
const { vm, list, inventory } = defineProps<{
vm: XoV2vVm
list: V2vMigrationList
inventory: XoV2vInventory | undefined
}>()
const emit = defineEmits<{
@@ -91,7 +92,7 @@ const {
setKeepMac,
setTags,
toOverrides,
} = useV2vVmMappingDraft(() => vm, { list: () => list })
} = useV2vVmMappingDraft(() => vm, { list: () => list, inventory: () => inventory })
</script>
<style lang="postcss" scoped>

View File

@@ -3,20 +3,20 @@ import V2vMappingTable from '@/modules/v2v/components/mapping/V2vMappingTable.vu
import V2vVmSidePanel from '@/modules/v2v/components/panel/V2vVmSidePanel.vue'
import { findV2vRouteQuery } from '@/test/create-v2v-wizard-router.ts'
import { getButton, getUiButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { deleteModalMock } from '@/test/mock-delete-modal.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { mountOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vMappingStep } from '@/test/mount-v2v-mapping-step.ts'
import { mountComponentOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vListStep } from '@/test/mount-v2v-list-step.ts'
import { readV2vStoredListVm } from '@/test/mount-v2v-vm-mapping.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { V2V_GONE_VM_ID } from '@/test/v2v-mapping-step-fixtures.ts'
import { readV2vMigrationListVmIds } from '@/test/v2v-migration-list-storage.ts'
import { V2V_DB_VM, V2V_WEB_VM } from '@/test/v2v-vms-fixtures.ts'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'),
@@ -50,26 +50,21 @@ vi.mock(import('@core/composables/modals/use-delete-modal.ts'), () => import('@/
// The children render what the step hands them, each covered by its own test: here only the wiring matters
async function mountStep(vm?: string) {
const { mounted: wrapper, router } = await mountV2vMappingStep(
return mountV2vListStep(
'mapping',
setup =>
mountOnV2vWizard(
(router, provide) =>
mount(V2vMappingStep, {
global: createGlobalTestConfig({
router,
provide,
stubs: {
V2vMappingTable: true,
V2vVmSidePanel: true,
},
}),
}),
mountComponentOnV2vWizard(
V2vMappingStep,
{
stubs: {
V2vMappingTable: true,
V2vVmSidePanel: true,
},
},
setup
),
{ vm }
)
return { wrapper, router }
}
function getTable(wrapper: VueWrapper) {
@@ -81,9 +76,7 @@ function getVmPanel(wrapper: VueWrapper) {
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
deleteModalMock.reset()
resetV2vCollectionMocks()
deleteModalMock.answer('onConfirm')
})

View File

@@ -10,6 +10,7 @@ import V2vDestinationMappingCard from '@/modules/v2v/components/mapping/V2vDesti
import V2vVmChecksCard from '@/modules/v2v/components/mapping/V2vVmChecksCard.vue'
import V2vVmSourceCard from '@/modules/v2v/components/mapping/V2vVmSourceCard.vue'
import V2vVmOptionsCard from '@/modules/v2v/components/panel/V2vVmOptionsCard.vue'
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import type { V2vNewMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { useV2vVmMapping } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
@@ -30,5 +31,9 @@ const {
areOffersReady,
keepMac,
tags,
} = useV2vVmMapping(() => vm.vm, { list: () => vm.list, listIssue: () => vm.listIssue })
} = useV2vVmMapping(() => vm.vm, {
list: () => vm.list,
listIssue: () => vm.listIssue,
inventory: useV2vConnection().inventory,
})
</script>

View File

@@ -3,10 +3,9 @@ import V2vDestinationVmCard from '@/modules/v2v/components/mapping/V2vDestinatio
import V2vNextRunCard from '@/modules/v2v/components/mapping/V2vNextRunCard.vue'
import V2vResumedVmCards from '@/modules/v2v/components/panel/V2vResumedVmCards.vue'
import { createV2vResumedMappingStepVm } from '@/test/create-v2v-mapping-step-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { vmDeviceCollectionsMock } from '@/test/mock-xo-vm-device-collections.ts'
import { mountOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountComponentOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { seedV2vResumedVmCollections } from '@/test/seed-v2v-resumed-vm.ts'
import {
V2V_ACTIVE_DISK_ROWS,
@@ -15,7 +14,6 @@ import {
V2V_RESUMED_IMPORT_VM,
} from '@/test/v2v-mapping-fixtures.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import { mount } from '@vue/test-utils'
vi.mock(
import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'),
@@ -48,16 +46,12 @@ const VM = createV2vResumedMappingStepVm()
async function mountCards() {
seedV2vResumedVmCollections()
const { mounted: wrapper } = await mountOnV2vWizard(
(router, provide) =>
mount(V2vResumedVmCards, {
props: { vm: VM },
global: createGlobalTestConfig({
router,
provide,
stubs: { V2vDestinationVmCard: true, V2vNextRunCard: true, V2vActiveMappingCard: true },
}),
}),
const { wrapper } = await mountComponentOnV2vWizard(
V2vResumedVmCards,
{
props: { vm: VM },
stubs: { V2vDestinationVmCard: true, V2vNextRunCard: true, V2vActiveMappingCard: true },
},
{ step: 'mapping', source: { inventory: V2V_MAPPING_INVENTORY } }
)
@@ -65,8 +59,7 @@ async function mountCards() {
}
beforeEach(() => {
xoCollectionsMock.reset()
vmDeviceCollectionsMock.reset()
resetV2vCollectionMocks()
})
it('shows the import VM on the pool it was synced to', async () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import V2vLaunchOptionSelect from '@/modules/v2v/components/review/V2vLaunchOptionSelect.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { findShownSelections, openSelect, pickSelectOption } from '@/test/select.ts'
import { flushPromises, mount } from '@vue/test-utils'
// What the select offers and shows belongs to useV2vLaunchOptionSelect: here only the wiring matters
async function mountSelect(modelValue: boolean | undefined, defaultValue = true) {
const wrapper = mount(V2vLaunchOptionSelect, {
props: { modelValue, defaultValue, 'onUpdate:modelValue': () => {} },
global: createGlobalTestConfig(),
})
await flushPromises()
return wrapper
}
it('follows the default value it is given', async () => {
const wrapper = await mountSelect(undefined)
await wrapper.setProps({ defaultValue: false })
expect(findShownSelections(wrapper)).toEqual([t('default-value', { value: t('option-off') })])
})
it('emits the value picked', async () => {
const wrapper = await mountSelect(undefined)
await openSelect(wrapper)
await pickSelectOption(wrapper, t('option-off'))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})

View File

@@ -0,0 +1,17 @@
<template>
<VtsSelect :id accent="brand" />
</template>
<script lang="ts" setup>
import { useV2vLaunchOptionSelect } from '@/modules/v2v/composables/use-v2v-launch-option-select.composable.ts'
import VtsSelect from '@core/components/select/VtsSelect.vue'
const { defaultValue } = defineProps<{
defaultValue: boolean
}>()
// `undefined` follows the default value
const model = defineModel<boolean | undefined>()
const { id } = useV2vLaunchOptionSelect(model, () => defaultValue)
</script>

View File

@@ -0,0 +1,98 @@
import V2vLaunchOptionsCard from '@/modules/v2v/components/review/V2vLaunchOptionsCard.vue'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import { findAccentedInfos, findAccentedTags } from '@/test/find-accented-labels.ts'
import { findCardTitle } from '@/test/find-card-title.ts'
import { getCheckbox } from '@/test/get-checkbox.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mountV2vLaunchForm } from '@/test/mount-v2v-launch-form.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { mount } from '@vue/test-utils'
function mountCard() {
const form = mountV2vLaunchForm()
const wrapper = mount(V2vLaunchOptionsCard, {
props: {
stopSourceCheckboxBindings: form.stopSourceCheckboxBindings,
stopOnErrorCheckboxBindings: form.stopOnErrorCheckboxBindings,
concurrencyInputBindings: form.concurrencyInputBindings,
},
global: createGlobalTestConfig(),
})
return { wrapper, form }
}
function pickCheckboxBindings({ label, info, modelValue }: { label: string; info?: string; modelValue: boolean }) {
return { label, info, modelValue }
}
function findCheckboxes(wrapper: ReturnType<typeof mountCard>['wrapper']) {
return wrapper.findAllComponents(FormCheckbox).map(checkbox => {
const { label, info, modelValue, disabled } = checkbox.props()
return { label, info, modelValue, disabled }
})
}
it('shows its title and that the options apply to every VM unless overridden', () => {
expect(findCardTitle(mountCard().wrapper)).toBe(`${t('default-options')} ${t('v2v:default-options-info')}`)
})
it('shows stopping the source VM first and stopping on error last, bound to the form', () => {
const { wrapper, form } = mountCard()
const [stopSource, , , stopOnError] = findCheckboxes(wrapper)
expect([stopSource, stopOnError]).toEqual([
{ ...pickCheckboxBindings(form.stopSourceCheckboxBindings), disabled: false },
{ ...pickCheckboxBindings(form.stopOnErrorCheckboxBindings), disabled: false },
])
})
it('shows starting then cleaning up the VM in between, off and disabled, as not supported yet', () => {
const [, startVm, cleanup] = findCheckboxes(mountCard().wrapper)
expect([startVm, cleanup]).toEqual([
{ label: t('v2v:start-vm-on-xcp-ng'), info: t('not-supported-yet'), modelValue: false, disabled: true },
{ label: t('v2v:clean-up-vm'), info: t('not-supported-yet'), modelValue: false, disabled: true },
])
})
it('tags cleaning up the VM as a preview', () => {
expect(findAccentedTags(mountCard().wrapper)).toEqual([{ label: t('preview'), accent: 'warning' }])
})
it('tells the tags and the Keep MAC addresses option are not applied yet', () => {
expect(findAccentedInfos(mountCard().wrapper)).toContainEqual({
label: t('v2v:tags-mac-not-applied'),
accent: 'info',
})
})
it.each([
['stop the source VM', 'v2v:stop-source-vm', 'stopSource'],
['stop on error', 'stop-on-error', 'stopOnError'],
] as const)('sets in the form whether to %s', async (_, labelKey, field) => {
const { wrapper, form } = mountCard()
await getCheckbox(wrapper, t(labelKey)).setValue(false)
expect(form.formData[field]).toBe(false)
})
it('shows the concurrency input of the form', () => {
const { wrapper, form } = mountCard()
const { label, modelValue, required } = form.concurrencyInputBindings
expect(wrapper.getComponent(VtsNumberInput).props()).toMatchObject({ label, modelValue, required })
})
it('sets the concurrency in the form', async () => {
const { wrapper, form } = mountCard()
await wrapper.getComponent(VtsNumberInput).get('input').setValue('4')
expect(form.formData.concurrency).toBe(4)
})

View File

@@ -0,0 +1,66 @@
<template>
<UiCard class="v2v-launch-options-card">
<UiCardTitle>
{{ t('default-options') }}
<template #description>{{ t('v2v:default-options-info') }}</template>
</UiCardTitle>
<div class="vm-options">
<FormCheckbox v-bind="stopSourceCheckboxBindings" />
<FormCheckbox :label="t('v2v:start-vm-on-xcp-ng')" :model-value="false" :info="t('not-supported-yet')" disabled />
<div class="preview-option">
<FormCheckbox :label="t('v2v:clean-up-vm')" :model-value="false" :info="t('not-supported-yet')" disabled />
<UiTag accent="warning" variant="secondary" class="preview-tag">{{ t('preview') }}</UiTag>
</div>
</div>
<div class="launch-options">
<FormCheckbox v-bind="stopOnErrorCheckboxBindings" />
<VtsNumberInput v-bind="concurrencyInputBindings" />
</div>
<UiInfo accent="info" wrap>{{ t('v2v:tags-mac-not-applied') }}</UiInfo>
</UiCard>
</template>
<script lang="ts" setup>
import type { V2vLaunchForm } from '@/modules/v2v/form/use-v2v-launch-form.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
stopSourceCheckboxBindings: V2vLaunchForm['stopSourceCheckboxBindings']['value']
stopOnErrorCheckboxBindings: V2vLaunchForm['stopOnErrorCheckboxBindings']['value']
concurrencyInputBindings: V2vLaunchForm['concurrencyInputBindings']['value']
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-launch-options-card {
.vm-options,
.launch-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
gap: 1.6rem;
}
.launch-options {
padding-block-start: 1.6rem;
border-block-start: 0.1rem solid var(--color-neutral-border);
}
.preview-option {
display: flex;
align-items: flex-start;
gap: 0.8rem;
}
.preview-tag {
flex-shrink: 0;
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,292 @@
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 { stubV2vStartAccepted, stubV2vStartProblems, V2V_START_TASK_ID } from '@/test/create-v2v-launch.ts'
import { findCardTitleLabel } from '@/test/find-card-title.ts'
import { findParts } from '@/test/find-parts.ts'
import { findTitle } from '@/test/find-titles.ts'
import { getButton, getUiButton } from '@/test/get-button.ts'
import { t } from '@/test/i18n.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { mountComponentOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vListStep } from '@/test/mount-v2v-list-step.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { SERVER_ERROR_MESSAGE, stubFetchServerError } from '@/test/stub-fetch-response.ts'
import { V2V_GONE_VM_ID } from '@/test/v2v-mapping-step-fixtures.ts'
import { V2V_REVIEW_STEP_EXCLUDED_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 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 { flushPromises, 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('@/modules/vif/remote-resources/use-xo-vif-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 mountV2vListStep>[2]) {
return mountV2vListStep(
'review',
setup =>
mountComponentOnV2vWizard(
V2vReviewStep,
{ stubs: { V2vLaunchOptionsCard: true, V2vVmLaunchOptionsTable: true } },
setup
),
options
)
}
// 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(() => {
resetV2vCollectionMocks()
})
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('shows the step busy without a destination pool', async () => {
const { wrapper, router } = await mountStep()
await router.replace({ query: { ...router.currentRoute.value.query, pool: undefined } })
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 { wrapper } = await mountStep({ list: V2V_REVIEW_STEP_EXCLUDED_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 { wrapper } = await mountStep({ list: V2V_REVIEW_STEP_EXCLUDED_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()
stubV2vStartProblems([{ 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()
stubFetchServerError()
const { wrapper } = await mountStep()
await launch(wrapper)
expect(wrapper.getComponent(UiAlert).text()).toBe(`${t('error')} ${SERVER_ERROR_MESSAGE}`)
})
describe('once launched', () => {
async function mountLaunched() {
stubV2vStartAccepted()
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(findTitle(hero)).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(V2V_START_TASK_ID))
})
it('goes back to the VMs step', async () => {
const { wrapper, router } = await mountLaunched()
await getButton(wrapper, t('action:back-to-vms')).trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.step).toBe('vms')
})
})
it('goes back to the mapping step', async () => {
const { wrapper, router } = await mountStep()
await getButton(wrapper, t('action:back')).trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.step).toBe('mapping')
})

View File

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

View File

@@ -0,0 +1,69 @@
import V2vLaunchOptionSelect from '@/modules/v2v/components/review/V2vLaunchOptionSelect.vue'
import V2vVmLaunchOptionsTable from '@/modules/v2v/components/review/V2vVmLaunchOptionsTable.vue'
import { createV2vVmLaunchOptionsRow } from '@/test/create-v2v-launch.ts'
import { findCardTitle } from '@/test/find-card-title.ts'
import { findTableColumn, findTableColumnCells, findTableHeaders } from '@/test/find-table-cells.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import type { XoV2vVm } from '@vates/types'
import { mount } from '@vue/test-utils'
const DEFAULT_ROW = createV2vVmLaunchOptionsRow()
const OVERRIDDEN_ROW = createV2vVmLaunchOptionsRow({
id: 'vm-2' as XoV2vVm['id'],
name: 'db-01',
overrides: { stopSource: false },
})
const ROWS = [DEFAULT_ROW, OVERRIDDEN_ROW]
function mountTable() {
return mount(V2vVmLaunchOptionsTable, {
props: { rows: ROWS, defaultStopSource: true },
global: createGlobalTestConfig(),
})
}
function findStopSourceSelects(wrapper: ReturnType<typeof mountTable>) {
return findTableColumnCells(wrapper, t('v2v:stop-source')).map(cell => cell.getComponent(V2vLaunchOptionSelect))
}
it('shows its title and that "Default" follows the default options', () => {
expect(findCardTitle(mountTable())).toBe(`${t('per-vm-options')} ${t('v2v:per-vm-options-info')}`)
})
it('shows the VM, then whether to stop its source, to start it and to clean it up, in this order', () => {
expect(findTableHeaders(mountTable())).toEqual([
t('vm'),
t('v2v:stop-source'),
t('v2v:start-on-xcp-ng'),
t('v2v:clean-up-preview'),
])
})
it('names each VM, in the given order', () => {
expect(findTableColumn(mountTable(), t('vm'))).toEqual(['web-01', 'db-01'])
})
it('shows whether each VM stops its source, or follows the default', () => {
expect(findStopSourceSelects(mountTable()).map(select => select.props())).toEqual([
{ modelValue: undefined, defaultValue: true },
{ modelValue: false, defaultValue: true },
])
})
it.each([
['a value', 0, false],
['the default', 1, undefined],
])('emits the VM and %s picked to stop its source', (_, rowIndex, value) => {
const wrapper = mountTable()
findStopSourceSelects(wrapper)[rowIndex].vm.$emit('update:modelValue', value)
expect(wrapper.emitted('changeStopSource')).toEqual([[ROWS[rowIndex].id, value]])
})
it.each([t('v2v:start-on-xcp-ng'), t('v2v:clean-up-preview')])('tells %s is not supported yet for any VM', header => {
expect(findTableColumn(mountTable(), header)).toEqual([t('not-supported-yet'), t('not-supported-yet')])
})

View File

@@ -0,0 +1,68 @@
<template>
<UiCard>
<UiCardTitle>
{{ t('per-vm-options') }}
<template #description>{{ t('v2v:per-vm-options-info') }}</template>
</UiCardTitle>
<VtsTable>
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="row of rows" :key="row.id">
<BodyCells :item="row" />
</VtsRow>
</tbody>
</VtsTable>
</UiCard>
</template>
<script lang="ts" setup>
import V2vLaunchOptionSelect from '@/modules/v2v/components/review/V2vLaunchOptionSelect.vue'
import type { V2vLaunchSettings, V2vVmLaunchOptionsRow } from '@/modules/v2v/types/v2v-launch.type.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import { renderBodyCell } from '@core/tables/helpers/render-body-cell.ts'
import { h } from 'vue'
import { useI18n } from 'vue-i18n'
const { defaultStopSource } = defineProps<{
rows: V2vVmLaunchOptionsRow[]
defaultStopSource: V2vLaunchSettings['stopSource']
}>()
const emit = defineEmits<{
changeStopSource: [vmId: V2vVmLaunchOptionsRow['id'], value: boolean | undefined]
}>()
const { t } = useI18n()
const useVmLaunchOptionsColumns = defineColumns(() => ({
vm: useTextColumn({ headerLabel: () => t('vm') }),
stopSource: useTextColumn({ headerLabel: () => t('v2v:stop-source') }),
startVm: useTextColumn({ headerLabel: () => t('v2v:start-on-xcp-ng') }),
cleanup: useTextColumn({ headerLabel: () => t('v2v:clean-up-preview') }),
}))
const { HeadCells, BodyCells } = useVmLaunchOptionsColumns({
body: (row: V2vVmLaunchOptionsRow) => ({
vm: renderer => renderer(row.name),
stopSource: () =>
renderBodyCell(() =>
h(V2vLaunchOptionSelect, {
modelValue: row.overrides?.stopSource,
defaultValue: defaultStopSource,
'onUpdate:modelValue': value => emit('changeStopSource', row.id, value),
})
),
startVm: renderer => renderer(t('not-supported-yet')),
cleanup: renderer => renderer(t('not-supported-yet')),
}),
})
</script>

View File

@@ -18,10 +18,7 @@ import type { XoV2vVm } from '@vates/types'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
const ITEMS = [
createV2vSelectedVm({ id: 'vm-1' as XoV2vVm['id'], name: 'web-01' }),
createV2vSelectedVm({ id: 'vm-2' as XoV2vVm['id'], name: 'db-01' }),
]
const ITEMS = [createV2vSelectedVm(), createV2vSelectedVm({ id: 'vm-2' as XoV2vVm['id'], name: 'db-01' })]
const READINESS_TAGS: V2vAccentedLabel<TagAccent>[] = [
{ label: '1 ready', accent: 'success' },

View File

@@ -4,6 +4,7 @@ import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collecti
import { createV2vRememberedConnection } from '@/test/create-v2v-connection.ts'
import { createV2vImportVm } from '@/test/create-v2v-import-vm.ts'
import { findParts } from '@/test/find-parts.ts'
import { findTitle } from '@/test/find-titles.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { vmCollectionMock } from '@/test/mock-xo-vm-collection.ts'
@@ -46,7 +47,7 @@ async function listMenuLabels(wrapper: ReturnType<typeof mountItem>) {
it('shows the host with its platform', () => {
const wrapper = mountItem(createV2vRememberedConnection({ host: 'vcenter-prod.lab' }))
expect(wrapper.get('.title').text()).toBe(`vcenter-prod.lab ${t('vmware')}`)
expect(findTitle(wrapper)).toBe(`vcenter-prod.lab ${t('vmware')}`)
})
it('describes the product, the username, the last use and the SSL choice', () => {

View File

@@ -12,13 +12,12 @@ import { createV2vListPair } from '@/test/create-v2v-selected-vms.ts'
import { getV2vVmIds, getV2vVmNames } from '@/test/create-v2v-vm-row.ts'
import { getV2vVmsQuery } from '@/test/create-v2v-wizard-router.ts'
import { getButton, getUiButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { deleteModalMock } from '@/test/mock-delete-modal.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { mountOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountComponentOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vVmsStep } from '@/test/mount-v2v-vms-step.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { seedV2vListPool } from '@/test/seed-v2v-list-pool.ts'
import { readV2vMigrationListStorage } from '@/test/v2v-migration-list-storage.ts'
import {
@@ -32,7 +31,7 @@ import {
import { V2V_EMPTY_LIST } from '@/test/v2v-wizard-fixtures.ts'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'),
@@ -75,21 +74,17 @@ async function mountStep({
}: { list?: V2vMigrationList; filter?: string } = {}) {
seedV2vListPool()
const { mounted: wrapper, router } = await mountOnV2vWizard(
(router, provide) =>
mount(V2vVmsStep, {
global: createGlobalTestConfig({
router,
provide,
stubs: {
V2vVmsFilterHeader: true,
VtsQueryBuilder: true,
V2vVmPresets: true,
V2vVmsTable: true,
V2vSelectedVmsPanel: true,
},
}),
}),
return mountComponentOnV2vWizard(
V2vVmsStep,
{
stubs: {
V2vVmsFilterHeader: true,
VtsQueryBuilder: true,
V2vVmPresets: true,
V2vVmsTable: true,
V2vSelectedVmsPanel: true,
},
},
{
step: 'vms',
query: getV2vVmsQuery(V2V_LIST_WITH_LISTED_VM.poolId, filter),
@@ -97,8 +92,6 @@ async function mountStep({
source: { inventory: V2V_VMS_INVENTORY },
}
)
return { wrapper, router }
}
function getFilterHeader(wrapper: VueWrapper) {
@@ -138,9 +131,7 @@ function getNextButton(wrapper: VueWrapper) {
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
deleteModalMock.reset()
resetV2vCollectionMocks()
})
describe('VMs', () => {

View File

@@ -1,17 +1,15 @@
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'
import V2vWizardHeader from '@/modules/v2v/components/wizard/V2vWizardHeader.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { mountOnV2vWizard, type V2vWizardSetup } from '@/test/mount-on-v2v-wizard.ts'
import { mountComponentOnV2vWizard, 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(
import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'),
@@ -22,15 +20,17 @@ vi.mock(
async function mountWizard(setup: V2vWizardSetup = {}) {
seedV2vListPool()
const { mounted: wrapper } = await mountOnV2vWizard(
(router, provide) =>
mount(V2vMigrationWizard, {
global: createGlobalTestConfig({
router,
provide,
stubs: { V2vSourceStep: true, V2vDestinationStep: true, V2vVmsStep: true, V2vMappingStep: true },
}),
}),
const { wrapper } = await mountComponentOnV2vWizard(
V2vMigrationWizard,
{
stubs: {
V2vSourceStep: true,
V2vDestinationStep: true,
V2vVmsStep: true,
V2vMappingStep: true,
V2vReviewStep: true,
},
},
setup
)
@@ -58,10 +58,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 +76,3 @@ it.each([
])('lays the %s step out without the padding of the other steps', async (_, setup: V2vWizardSetup) => {
expect((await mountWizard(setup)).find('.content').exists()).toBe(false)
})
it('tells the review step is coming soon', async () => {
const wrapper = await mountWizard({ step: 'review', source: V2V_WIZARD_SOURCE, list: V2V_LIST_WITH_VMS })
expect(wrapper.getComponent(VtsStateHero).text()).toContain(t('coming-soon!'))
})

View File

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

View File

@@ -0,0 +1,32 @@
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { getV2vActiveMapping } from '@/modules/v2v/utils/v2v-active-mapping.util.ts'
import { getV2vTargetName } from '@/modules/v2v/utils/v2v-mapping.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 { useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { XoV2vVm } from '@vates/types'
import { computed } from 'vue'
// Where the import VM of a VMware VM is: its pool, and the targets of its first sync read back from its devices
export function useV2vImportVm() {
const { getPoolById } = useXoPoolCollection()
const { getVbdsByIds, areVbdsReady } = useXoVbdCollection()
const { vdis, areVdisReady } = useXoVdiCollection()
const { getVifsByIds, areVifsReady } = useXoVifCollection()
function getImportPoolName(importVm: FrontXoVm) {
return getV2vTargetName(importVm.$pool, id => getPoolById(id)?.name_label)
}
function getImportActiveMapping(vm: Pick<XoV2vVm, 'disks' | 'nics'>, importVm: FrontXoVm) {
return getV2vActiveMapping(vm, vdis.value, getVbdsByIds(importVm.$VBDs), getVifsByIds(importVm.VIFs))
}
const areImportDevicesReady = computed(() => areVbdsReady.value && areVdisReady.value && areVifsReady.value)
return { getImportPoolName, getImportActiveMapping, areImportDevicesReady }
}

View File

@@ -0,0 +1,18 @@
import { useV2vLaunchOptionSelection } from '@/modules/v2v/composables/use-v2v-launch-option-selection.composable.ts'
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import { useFormSelect } from '@core/packages/form-select'
import { type MaybeRefOrGetter, type Ref, toValue } from 'vue'
// `undefined` follows the default value
export function useV2vLaunchOptionSelect(model: Ref<boolean | undefined>, defaultValue: MaybeRefOrGetter<boolean>) {
const { getLaunchOptionLabel } = useV2vVmLabels()
return useFormSelect([true, false], {
model: useV2vLaunchOptionSelection(model),
emptyOption: () => ({ value: undefined, label: getLaunchOptionLabel(undefined, toValue(defaultValue)) }),
option: {
id: value => String(value),
label: value => getLaunchOptionLabel(value, toValue(defaultValue)),
},
})
}

View File

@@ -0,0 +1,53 @@
import { useV2vLaunchOptionSelect } from '@/modules/v2v/composables/use-v2v-launch-option-select.composable.ts'
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
function mountSelect(modelValue: boolean | undefined, initialDefaultValue = true) {
const model = ref(modelValue)
const defaultValue = ref(initialDefaultValue)
const { wrapper } = mountComposable(() => {
const { allOptions, selectedLabel } = useV2vLaunchOptionSelect(model, defaultValue)
return { allOptions, selectedLabel, labels: useV2vVmLabels() }
})
return { vm: wrapper.vm, defaultValue }
}
describe('allOptions', () => {
it('offers the default, on, then off', () => {
const { vm } = mountSelect(undefined)
expect(vm.allOptions.map(({ properties: { value, label } }) => ({ value, label }))).toEqual([
{ value: undefined, label: vm.labels.getLaunchOptionLabel(undefined, true) },
{ value: true, label: vm.labels.getLaunchOptionLabel(true, true) },
{ value: false, label: vm.labels.getLaunchOptionLabel(false, true) },
])
})
})
describe('selectedLabel', () => {
it.each([true, false])('shows an option following its default of %s as that default', defaultValue => {
const { vm } = mountSelect(undefined, defaultValue)
expect(vm.selectedLabel).toBe(vm.labels.getLaunchOptionLabel(undefined, defaultValue))
})
it.each([true, false])('shows an option set to %s as that value', value => {
const { vm } = mountSelect(value, !value)
expect(vm.selectedLabel).toBe(vm.labels.getLaunchOptionLabel(value, !value))
})
it('follows a default that changes', async () => {
const { vm, defaultValue } = mountSelect(undefined)
defaultValue.value = false
await flushPromises()
expect(vm.selectedLabel).toBe(vm.labels.getLaunchOptionLabel(undefined, false))
})
})

View File

@@ -0,0 +1,27 @@
import { nextTick, ref, watch, type Ref } from 'vue'
// What a launch option select shows goes back to its model when the parent does not keep the value picked: picking
// the default value leaves the option on its default
export function useV2vLaunchOptionSelection(model: Ref<boolean | undefined>) {
const selection = ref<boolean>()
watch(
model,
value => {
selection.value = value
},
{ immediate: true }
)
watch(selection, async value => {
if (value === model.value) {
return
}
model.value = value
await nextTick()
selection.value = model.value
})
return selection
}

View File

@@ -0,0 +1,59 @@
import { useV2vLaunchOptionSelection } from '@/modules/v2v/composables/use-v2v-launch-option-selection.composable.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
// A model bound like a parent does: `keepsPicks` false clears every value picked, as a pick of the default value
function mountSelection(initialValue: boolean | undefined, keepsPicks = true) {
const value = ref(initialValue)
const picks: (boolean | undefined)[] = []
const model = computed({
get: () => value.value,
set: picked => {
picks.push(picked)
if (keepsPicks) {
value.value = picked
}
},
})
const { wrapper } = mountComposable(() => ({ selection: useV2vLaunchOptionSelection(model) }))
return { vm: wrapper.vm, value, picks }
}
it('selects the value of its model', () => {
expect(mountSelection(false).vm.selection).toBe(false)
})
it('follows its model when it changes', async () => {
const { vm, value, picks } = mountSelection(undefined)
value.value = true
await flushPromises()
expect(vm.selection).toBe(true)
expect(picks).toEqual([])
})
it('keeps a value picked once its model takes it', async () => {
const { vm, picks } = mountSelection(undefined)
vm.selection = false
await flushPromises()
expect(vm.selection).toBe(false)
expect(picks).toEqual([false])
})
it('goes back to its model when the model does not keep the value picked', async () => {
const { vm, picks } = mountSelection(undefined, false)
vm.selection = true
await flushPromises()
expect(vm.selection).toBeUndefined()
expect(picks).toEqual([true])
})

View File

@@ -8,7 +8,8 @@ import { deleteModalMock } from '@/test/mock-delete-modal.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { mountComposableOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vMappingStep } from '@/test/mount-v2v-mapping-step.ts'
import { mountV2vListStep } from '@/test/mount-v2v-list-step.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import {
V2V_DB_IMPORT_VM,
V2V_DB_SYNC_SNAPSHOT,
@@ -18,6 +19,7 @@ import {
V2V_MAPPING_STEP_LIST,
} from '@/test/v2v-mapping-step-fixtures.ts'
import { readV2vMigrationListVmIds } from '@/test/v2v-migration-list-storage.ts'
import { V2V_SELECTED_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
import { V2V_DB_VM, V2V_WEB_VM } from '@/test/v2v-vms-fixtures.ts'
import type { XoV2vVm } from '@vates/types'
import { flushPromises } from '@vue/test-utils'
@@ -54,8 +56,9 @@ vi.mock(import('@core/composables/modals/use-delete-modal.ts'), () => import('@/
const SOURCE_HOST = createV2vConnection().host
function mountMappingStep(options?: Parameters<typeof mountV2vMappingStep>[1]) {
return mountV2vMappingStep(
function mountMappingStep(options?: Parameters<typeof mountV2vListStep>[2]) {
return mountV2vListStep(
'mapping',
setup => mountComposableOnV2vWizard(() => ({ ...useV2vMappingStep(), labels: useV2vVmLabels() }), setup),
options
)
@@ -71,9 +74,7 @@ async function removeOnceConfirmed({ result }: MappingStep, id: XoV2vVm['id']) {
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
deleteModalMock.reset()
resetV2vCollectionMocks()
})
describe('selectedVm', () => {
@@ -117,7 +118,7 @@ describe('selectedVm', () => {
expect(result.selectedVm).toMatchObject({
kind: 'new',
listIssue: result.labels.getCheckInfo({ type: 'mapping-incomplete', level: 'blocking' }),
listIssue: result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.mappingIncomplete),
})
})

View File

@@ -1,10 +1,13 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useV2vSourceAndDestination } from '@/modules/v2v/composables/use-v2v-source-and-destination.composable.ts'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { type FrontXoPool, useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { getV2vTargetName } from '@/modules/v2v/utils/v2v-mapping.util.ts'
import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
// A pool still loading is named by its id rather than not at all
export function useV2vPoolName(rawPoolId: MaybeRefOrGetter<FrontXoPool['id']>) {
const { poolName } = useV2vSourceAndDestination(rawPoolId)
export function useV2vPoolName(rawPoolId: MaybeRefOrGetter<FrontXoPool['id']>): ComputedRef<string>
export function useV2vPoolName(
rawPoolId: MaybeRefOrGetter<FrontXoPool['id'] | undefined>
): ComputedRef<string | undefined>
export function useV2vPoolName(rawPoolId: MaybeRefOrGetter<FrontXoPool['id'] | undefined>) {
const { getPoolById } = useXoPoolCollection()
return computed(() => poolName.value ?? toValue(rawPoolId))
return computed(() => getV2vTargetName(toValue(rawPoolId), id => getPoolById(id)?.name_label))
}

View File

@@ -28,3 +28,9 @@ it('names the pool', () => {
it('names the pool by its id while the pools load', () => {
expect(mountPoolName().poolName).toBe(V2V_LIST_POOL.id)
})
it('names no pool without a pool id', () => {
seedV2vListPool()
expect(mountComposable(() => ({ poolName: useV2vPoolName(undefined) })).wrapper.vm.poolName).toBeUndefined()
})

View File

@@ -1,17 +1,13 @@
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import { useV2vPoolName } from '@/modules/v2v/composables/use-v2v-pool-name.composable.ts'
import { useV2vImportVm } from '@/modules/v2v/composables/use-v2v-import-vm.composable.ts'
import { useV2vTargetNameGetters } from '@/modules/v2v/composables/use-v2v-target-name-getters.composable.ts'
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import type { V2vActiveMappingRow } from '@/modules/v2v/types/v2v-mapping.type.ts'
import { getV2vActiveMapping } from '@/modules/v2v/utils/v2v-active-mapping.util.ts'
import { getV2vTargetName } from '@/modules/v2v/utils/v2v-mapping.util.ts'
import { findV2vPortGroup, getV2vDatastoreName } from '@/modules/v2v/utils/v2v-source.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 { useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { type IconName, objectIcon } from '@core/icons'
@@ -27,25 +23,13 @@ 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()
const { getDiskDetailParts, getNicDetailParts } = useV2vVmLabels()
const { getVbdsByIds, areVbdsReady } = useXoVbdCollection()
const { vdis, areVdisReady } = useXoVdiCollection()
const { getVifsByIds, areVifsReady } = useXoVifCollection()
const { getImportPoolName, getImportActiveMapping, areImportDevicesReady } = useV2vImportVm()
const { areSrsReady } = useXoSrCollection()
@@ -57,7 +41,7 @@ export function useV2vResumedVm(rawVm: MaybeRefOrGetter<XoV2vVm>, rawImportVm: M
const importVm = toComputed(rawImportVm)
const poolName = useV2vPoolName(() => importVm.value.$pool)
const poolName = computed(() => getImportPoolName(importVm.value))
const { powerState } = useXoVmUtils(importVm)
@@ -70,9 +54,7 @@ export function useV2vResumedVm(rawVm: MaybeRefOrGetter<XoV2vVm>, rawImportVm: M
})
)
const activeMapping = computed(() =>
getV2vActiveMapping(vm.value, vdis.value, getVbdsByIds(importVm.value.$VBDs), getVifsByIds(importVm.value.VIFs))
)
const activeMapping = computed(() => getImportActiveMapping(vm.value, importVm.value))
const activeDiskRows = computed(() =>
activeMapping.value.disks.map(
@@ -82,7 +64,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,13 +74,13 @@ 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),
})
)
)
const isActiveMappingReady = computed(
() => areVbdsReady.value && areVdisReady.value && areVifsReady.value && areSrsReady.value && areNetworksReady.value
() => areImportDevicesReady.value && areSrsReady.value && areNetworksReady.value
)
const nextTransfer = computed(() => getV2vNextTransfer(vm.value, importVm.value))

View File

@@ -4,14 +4,14 @@ import { createV2vVm } from '@/test/create-v2v-inventory.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { vmDeviceCollectionsMock } from '@/test/mock-xo-vm-device-collections.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { seedV2vResumedVmCollections } from '@/test/seed-v2v-resumed-vm.ts'
import { createConnectedV2vContext, v2vConnectionProvide } from '@/test/v2v-connection-provide.ts'
import { v2vMappingConnectionProvide } from '@/test/v2v-connection-provide.ts'
import {
V2V_ACTIVE_DISK_ROWS,
V2V_ACTIVE_NIC_ROWS,
V2V_DEFAULT_NETWORK,
V2V_MAPPED_VM,
V2V_MAPPING_INVENTORY,
V2V_RESUMED_IMPORT_VM,
V2V_SSD_SR,
V2V_SYNCED_VM,
@@ -50,13 +50,12 @@ function mountResumedVm(vm: XoV2vVm = V2V_SYNCED_VM, importVm: FrontXoVm = V2V_R
seedV2vResumedVmCollections()
return mountComposableWithI18n(() => useV2vResumedVm(vm, importVm), {
provide: v2vConnectionProvide(createConnectedV2vContext({ inventory: V2V_MAPPING_INVENTORY })),
provide: v2vMappingConnectionProvide(),
})
}
beforeEach(() => {
xoCollectionsMock.reset()
vmDeviceCollectionsMock.reset()
resetV2vCollectionMocks()
})
describe('destinationVm', () => {

View File

@@ -0,0 +1,322 @@
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 { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import { useV2vImportVm } from '@/modules/v2v/composables/use-v2v-import-vm.composable.ts'
import { useV2vMigrationListRows } from '@/modules/v2v/composables/use-v2v-migration-list-rows.composable.ts'
import { useV2vPoolName } from '@/modules/v2v/composables/use-v2v-pool-name.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 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 { 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 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 { connection } = useV2vConnection()
const { goToStep, getStepLocation } = useV2vWizardNavigation()
const labels = useV2vVmLabels()
const listRows = useV2vMigrationListRows()
const { poolId, migrationList } = listRows
const { vmStates, getRow, getName, isBusy } = useV2vSelectedVms(listRows)
const { getPoolById } = useXoPoolCollection()
const { getSrById } = useXoSrCollection()
const { getImportPoolName, getImportActiveMapping } = useV2vImportVm()
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 destinationPool = computed(() => getPoolById(poolId.value))
const destinationPoolName = useV2vPoolName(poolId)
// 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[] {
const { disks } = getImportActiveMapping(row.vm, importVm)
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 = getRow(item.id)
const importVm = row?.importVm
if (row === undefined || importVm === undefined) {
return []
}
return [
{
item,
row,
importVm,
poolName: getImportPoolName(importVm),
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
function getProblemLabel({ vmId, message }: V2vStartProblem) {
return labels.getStartProblemLabel(message, vmId === undefined ? undefined : getName(vmId))
}
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 sourceConnection = connection.value
const list = migrationList.list.value
// Without a connection or a list, the wizard does not show the step: the check narrows their types
if (sourceConnection === 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: sourceConnection,
defaults: list.defaults,
settings: { ...form.formData, concurrency },
vms: startVms.value,
})
try {
const taskId = await run()
launched.value = { count, taskRoute: getTaskRoute(taskId) }
} catch (error) {
launchError.value = getLaunchError(error)
}
}
return {
isBusy,
summaryParts,
destinationPoolName,
form,
vmLaunchOptionsRows,
groupHeadings,
excludedVms,
migratedVms,
resumedVms,
newVmsCount,
newVmsSummary,
launchCount,
canLaunch,
isLaunching,
launchError,
launched,
confirmSecondCopy,
changeStopSource,
launch,
back: () => goToStep('mapping'),
backToVms: () => goToStep('vms'),
}
}

View File

@@ -0,0 +1,514 @@
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,
stubV2vStartAccepted,
stubV2vStartProblems,
V2V_START_TASK_ID,
} from '@/test/create-v2v-launch.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 { mountComposableOnV2vWizard } from '@/test/mount-on-v2v-wizard.ts'
import { mountV2vListStep } from '@/test/mount-v2v-list-step.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { SERVER_ERROR_MESSAGE, stubFetchServerError } 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, V2V_MAPPING_STEP_INVENTORY } from '@/test/v2v-mapping-step-fixtures.ts'
import {
createV2vReviewStepList,
V2V_CUTOVER_SNAPSHOT,
V2V_MIGRATED_IMPORT_VM,
V2V_MIGRATED_VM_POOL,
V2V_MIGRATED_VM_SR,
V2V_REVIEW_STEP_EXCLUDED_LIST,
V2V_REVIEW_STEP_LIST,
} from '@/test/v2v-review-step-fixtures.ts'
import { V2V_SELECTED_VM_CHECK } from '@/test/v2v-selected-vm-checks.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 { XoV2vDisk } 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('@/modules/vif/remote-resources/use-xo-vif-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' })
// The disks of the factory all share one key and chain: the DB VM gets two distinct ones, only the first held by its
// import
const DB_VM_DISTINCT_DISKS_INVENTORY = (() => {
const [systemDisk, dataDisk] = V2V_DB_VM.disks
const dbVm = {
...V2V_DB_VM,
disks: [systemDisk, { ...dataDisk, key: 'scsi0:1' as XoV2vDisk['key'], chainPaths: ['db-01/db-01_1.vmdk'] }],
}
return {
...V2V_MAPPING_STEP_INVENTORY,
vms: V2V_MAPPING_STEP_INVENTORY.vms.map(vm => (vm.id === dbVm.id ? dbVm : vm)),
}
})()
function mountReviewStep(options?: Parameters<typeof mountV2vListStep>[2]) {
return mountV2vListStep(
'review',
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(() => {
resetV2vCollectionMocks()
})
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(V2V_SELECTED_VM_CHECK.notFound).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('leaves out of the SRs of a resumed VM a disk its import does not hold yet', async () => {
const { result } = await mountReviewStep({ inventory: DB_VM_DISTINCT_DISKS_INVENTORY })
expect(result.resumedVms[0].parts).toEqual(
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]))
})
it('names once an SR several new VMs are mapped to', async () => {
const { result } = await mountReviewStep({
list: createV2vReviewStepList([{ id: V2V_WEB_VM.id }, { id: V2V_DB_VM.id }]),
inventory: DB_VM_DISTINCT_DISKS_INVENTORY,
})
// Without its import, the DB VM is new too
v2vVmRowsCollectionsMock.vms.value = []
expect(result.newVmsCount).toBe(2)
expect(result.newVmsSummary).toBe(
result.labels.getNewVmsSummary([V2V_WEB_VM.name, V2V_DB_VM.name], [V2V_DEFAULT_SR.name_label])
)
})
it('has nothing to review nor to launch on an empty list', async () => {
const { result } = await mountReviewStep({ list: createV2vReviewStepList([]) })
expect(result).toMatchObject({
summaryParts: result.labels.getReviewSummaryParts(DISKOR_POOL_NAME, 0, { excluded: 0, unconfirmed: 0 }),
excludedVms: [],
migratedVms: [],
resumedVms: [],
newVmsCount: 0,
vmLaunchOptionsRows: [],
launchCount: 0,
canLaunch: false,
})
})
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 { result } = await mountReviewStep({ list: V2V_REVIEW_STEP_EXCLUDED_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 mountReviewStep({ isSourceConnected: false })
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 () => {
stubV2vStartAccepted()
const { result } = await mountReviewStep()
await launch(result)
expect(hasRequested('POST', START_ENDPOINT, buildPayload())).toBe(true)
expect(result.launched).toEqual({ count: 2, taskRoute: getTaskRoute(V2V_START_TASK_ID) })
})
it('sends the mapping a new VM overrides on the list, and the overrides of the launch', async () => {
stubV2vStartAccepted()
const list = createV2vReviewStepList([
{ 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 () => {
stubV2vStartAccepted()
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('sends neither the tags nor the MAC choice of the list, which the back end does not support', async () => {
stubV2vStartAccepted()
const list = createV2vReviewStepList([
{ id: V2V_WEB_VM.id, tags: ['migrated-from:vmware'], keepMac: false },
{ id: V2V_DB_VM.id },
])
const { result } = await mountReviewStep({ list })
await launch(result)
const payload = {
...buildPayload(),
vms: [
{ id: V2V_WEB_VM.id, tags: [], keepMac: true },
{ id: V2V_DB_VM.id, tags: [], keepMac: true },
],
}
expect(hasRequested('POST', START_ENDPOINT, payload)).toBe(true)
})
it('does not launch while the destination pool loads', async () => {
const fetchMock = stubV2vStartAccepted()
const { result } = await mountReviewStep()
xoCollectionsMock.pools.value = []
await launch(result)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.launchError).toEqual({ title: result.t('error'), messages: [result.t('job:arg:pool-id-required')] })
})
it('does not launch while the launch options are invalid', async () => {
const fetchMock = stubV2vStartAccepted()
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()
stubV2vStartProblems([
{ 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()
stubFetchServerError()
const { result } = await mountReviewStep()
await launch(result)
expect(result.launchError).toEqual({ title: result.t('error'), messages: [SERVER_ERROR_MESSAGE] })
})
it('forgets the error of the previous launch', async () => {
silenceConsoleError()
stubFetchServerError()
const { result } = await mountReviewStep()
await launch(result)
stubV2vStartAccepted()
await launch(result)
expect(result.launchError).toBeUndefined()
})
})
describe('navigation', () => {
it('goes back to the mapping step', async () => {
const { result, router } = await mountReviewStep()
await result.back()
expect(router.currentRoute.value.query.step).toBe('mapping')
})
it('goes back to the VMs step', async () => {
const { result, router } = await mountReviewStep()
await result.backToVms()
expect(router.currentRoute.value.query.step).toBe('vms')
})
})

View File

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

View File

@@ -16,9 +16,11 @@ import { deleteModalMock } from '@/test/mock-delete-modal.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { v2vVmRowsCollectionsMock } from '@/test/mock-xo-v2v-vm-rows-collections.ts'
import { mountV2vVmsStep } from '@/test/mount-v2v-vms-step.ts'
import { resetV2vCollectionMocks } from '@/test/reset-v2v-collection-mocks.ts'
import { seedV2vListPool } from '@/test/seed-v2v-list-pool.ts'
import { V2V_DEFAULT_SR, V2V_SSD_SR } from '@/test/v2v-mapping-fixtures.ts'
import { readV2vMigrationListStorage } from '@/test/v2v-migration-list-storage.ts'
import { V2V_SELECTED_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
import { V2V_DB_VM, V2V_LISTED_VM, V2V_RDM_VM, V2V_WEB_VM } from '@/test/v2v-vms-fixtures.ts'
import { V2V_EMPTY_LIST, V2V_LIST_POOL, V2V_OTHER_POOL } from '@/test/v2v-wizard-fixtures.ts'
import type { XoV2vVm } from '@vates/types'
@@ -66,6 +68,17 @@ 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]) })
// 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(
@@ -110,9 +123,7 @@ function findItemIds(result: SelectedVms) {
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
deleteModalMock.reset()
resetV2vCollectionMocks()
})
describe('items', () => {
@@ -184,10 +195,10 @@ describe('items', () => {
})
expect(result.items.map(item => item.check)).toEqual([
result.labels.getCheckInfo({ type: 'ready', level: 'ready' }),
result.labels.getCheckInfo({ type: 'unsupported', level: 'blocking', reason: 'rdm' }),
result.labels.getCheckInfo({ type: 'already-migrated', level: 'warning' }),
result.labels.getCheckInfo({ type: 'not-found', level: 'blocking' }),
result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.ready),
result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.unsupported),
result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.alreadyMigrated),
result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.notFound),
])
})
@@ -197,8 +208,8 @@ describe('items', () => {
xoCollectionsMock.srs.value = []
expect(result.items.map(item => item.check)).toEqual([
result.labels.getCheckInfo({ type: 'mapping-incomplete', level: 'blocking' }),
result.labels.getCheckInfo({ type: 'ready', level: 'ready' }),
result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.mappingIncomplete),
result.labels.getCheckInfo(V2V_SELECTED_VM_CHECK.ready),
])
})
@@ -323,12 +334,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: V2V_SELECTED_VM_CHECK.ready, mapping: WEB_MAPPING, item: result.items[0] },
{ kind: 'resume', check: V2V_SELECTED_VM_CHECK.ready, mapping: DB_MAPPING, item: result.items[1] },
])
})
@@ -339,6 +350,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: V2V_SELECTED_VM_CHECK.ready,
checkInfo: result.items[0].check,
mapping: WEB_MAPPING,
},
{
id: V2V_DB_VM.id,
name: V2V_DB_VM.name,
kind: 'resume',
check: V2V_SELECTED_VM_CHECK.ready,
checkInfo: result.items[1].check,
mapping: DB_MAPPING,
},
])
})
})
describe('readinessTags', () => {
it('counts the VMs of each readiness level', async () => {
const { result } = await mountSelectedVms({

View File

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

View File

@@ -12,7 +12,7 @@ import { v2vStatusMock } from '@/test/mock-xo-v2v-status.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { NOW } from '@/test/now.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { stubFetchResponse } from '@/test/stub-fetch-response.ts'
import { SERVER_ERROR_MESSAGE, stubFetchResponse, stubFetchServerError } from '@/test/stub-fetch-response.ts'
import { stubV2vInvalidCredentialsResponse } from '@/test/stub-v2v-invalid-credentials-response.ts'
import { v2vConnectionProvide } from '@/test/v2v-connection-provide.ts'
import { seedV2vRememberedConnections } from '@/test/v2v-remembered-connections-storage.ts'
@@ -159,25 +159,15 @@ describe('submit', () => {
it('reports any other failure as a connection error', async () => {
silenceConsoleError()
stubFetchResponse(500, { error: 'unreachable' })
stubFetchServerError()
const { result, router } = await mountSourceStep()
await submitConnection(result)
expect(result.connectionError).toBe('unreachable')
expect(result.connectionError).toBe(SERVER_ERROR_MESSAGE)
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)
@@ -390,7 +380,7 @@ describe('forgetRememberedPassword', () => {
describe('cancel', () => {
it('resets the form and the connection error', async () => {
silenceConsoleError()
stubFetchResponse(500, { error: 'unreachable' })
stubFetchServerError()
const { result } = await mountSourceStep()
await submitConnection(result)

View File

@@ -7,6 +7,7 @@ import type { useVmDelete } from '@/modules/vm/composables/use-vm-delete.composa
import { V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM } from '@/test/create-v2v-mapping-step-vm.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { v2vConnectionProvide } from '@/test/v2v-connection-provide.ts'
import { ref, toValue } from 'vue'
const { useDelete, deleteVm } = vi.hoisted(() => ({
@@ -37,7 +38,10 @@ function mockDelete(isDeletingVm = false) {
function mountNewVmActions() {
const remove = vi.fn()
const actions = mountComposableWithI18n(() => useV2vNewVmActions(V2V_NEW_STEP_VM, { updateVm: vi.fn(), remove }))
// The drawer is opened from the mapping step, under the connection of the wizard
const actions = mountComposableWithI18n(() => useV2vNewVmActions(V2V_NEW_STEP_VM, { updateVm: vi.fn(), remove }), {
provide: v2vConnectionProvide(),
})
return { actions, remove }
}
@@ -61,14 +65,13 @@ describe('useV2vNewVmActions', () => {
expect(actions.editAction).toMatchObject({ label: actions.t('action:edit-settings'), icon: 'action:edit' })
})
it('opens the settings drawer of the VM and its list', () => {
// What the drawer is opened with is told by the settings edit composable
it('opens the settings drawer', () => {
const { actions } = mountNewVmActions()
actions.editAction.onClick()
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({
props: { vm: V2V_NEW_STEP_VM.vm, list: V2V_NEW_STEP_VM.list },
})
expect(overlayMock.getOverlay().open).toHaveBeenCalledOnce()
})
})

View File

@@ -1,3 +1,4 @@
import type { V2vLaunchGroupType } from '@/modules/v2v/types/v2v-launch.type.ts'
import type { V2vTargetOrigin } from '@/modules/v2v/types/v2v-mapping.type.ts'
import type {
V2vMappingSummary,
@@ -16,14 +17,21 @@ import { V2V_READINESS_LEVELS } from '@/modules/v2v/utils/v2v-selected-vm.util.t
import type { V2vVmPresetId } from '@/modules/v2v/utils/v2v-vm-presets.util.ts'
import { V2V_VM_STATUSES } from '@/modules/v2v/utils/v2v-vm-status.util.ts'
import { mapCountedKeys } from '@/shared/utils/count.util.ts'
import type { CounterAccent } from '@core/components/ui/counter/UiCounter.vue'
import type { InfoAccent } from '@core/components/ui/info/UiInfo.vue'
import type { TagAccent } from '@core/components/ui/tag/UiTag.vue'
import { formatSize } from '@core/utils/size.util.ts'
import { formatDateTime } from '@core/utils/time.util.ts'
import { V2V_VM_POWER_STATE, type XoV2vDisk, type XoV2vPortGroup } from '@vates/types'
import { sumBy } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
// A list, not only a type: `mapCountedKeys` needs the keys in the order the review summary tells them
export const V2V_REVIEW_COUNTED = ['excluded', 'unconfirmed'] as const
export type V2vReviewCounts = Record<(typeof V2V_REVIEW_COUNTED)[number], number>
export type V2vVmStatusSummary = {
total: number
counts: Record<V2vVmStatus, number>
@@ -51,6 +59,13 @@ const KIND_ACCENTS: Record<V2vSelectedVmKind, TagAccent> = {
resume: 'info',
}
const LAUNCH_GROUP_ACCENTS: Record<V2vLaunchGroupType, CounterAccent> = {
excluded: 'danger',
migrated: 'warning',
resumed: 'brand',
new: 'success',
}
// Shared by the readiness tags and the check of each VM, so a level reads the same in both
const READINESS_ACCENTS: Record<V2vReadinessLevel, TagAccent & InfoAccent> = {
ready: 'success',
@@ -204,6 +219,16 @@ export function useV2vVmLabels() {
full: t('full-transfer'),
}))
// The panel and the review word a resumed VM alike: its transfer, then where it goes, then its kept mapping
const resumedTransferLabels = computed<Record<V2vNextTransfer, string>>(() => ({
delta: t('v2v:delta-from-last-sync'),
full: nextTransferLabels.value.full,
}))
function getResumedParts(transfer: V2vNextTransfer, ...details: string[]): string[] {
return [resumedTransferLabels.value[transfer], ...details, t('v2v:mapping-locked')]
}
function withTargets(countLabel: string, targets: string[] | undefined): string {
return targets === undefined
? countLabel
@@ -216,7 +241,7 @@ export function useV2vVmLabels() {
}
if (summary.type === 'locked') {
return [t('v2v:delta-from-last-sync'), t('v2v:mapping-locked')]
return getResumedParts('delta')
}
const { diskCount, srNames, nicCount, networkNames } = summary
@@ -240,6 +265,60 @@ export function useV2vVmLabels() {
}))
}
function getOnOffLabel(value: boolean): string {
return value ? t('option-on') : t('option-off')
}
// A launch option left to its default reads as that default
function getLaunchOptionLabel(value: boolean | undefined, defaultValue: boolean): string {
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,
@@ -259,5 +338,12 @@ export function useV2vVmLabels() {
nextTransferLabels,
getMappingParts,
getReadinessTags,
getLaunchOptionLabel,
getLaunchGroupHeading,
getReviewSummaryParts,
getResumedVmParts,
getCutoverLabel,
getNewVmsSummary,
getStartProblemLabel,
}
}

View File

@@ -1,9 +1,11 @@
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import type { V2vMappingSummary, V2vSelectedVmCheck, V2vVmCheck } from '@/modules/v2v/types/v2v-selected-vm.type.ts'
import type { V2vUnsupportedReason, V2vVmStatus } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import { DISKOR_POOL_NAME, OTHER_POOL_ID } from '@/test/create-pool.ts'
import { DISKOR_POOL_NAME, UNKNOWN_POOL_ID } from '@/test/create-pool.ts'
import { createV2vDisk, createV2vPortGroup } from '@/test/create-v2v-inventory.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { createV2vUnsupportedCheck, V2V_SELECTED_VM_CHECK, V2V_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
import { formatDateTime } from '@core/utils/time.util.ts'
import { V2V_VM_POWER_STATE } from '@vates/types'
function mountLabels() {
@@ -137,23 +139,13 @@ describe('getKindTag', () => {
describe('getCheckInfo', () => {
it.each([
['a ready', { type: 'ready', level: 'ready' }, 'ready', 'success'],
['a missing', { type: 'not-found', level: 'blocking' }, 'v2v:not-found-in-source', 'danger'],
['a running', { type: 'running', level: 'blocking' }, 'import-in-progress', 'danger'],
[
'an already migrated',
{ type: 'already-migrated', level: 'warning' },
'v2v:already-migrated-confirm-at-launch',
'warning',
],
[
'an incompletely mapped',
{ type: 'mapping-incomplete', level: 'blocking' },
'v2v:check:mapping-incomplete',
'danger',
],
['a fully supported', { type: 'no-unsupported-disk', level: 'ready' }, 'v2v:check:no-unsupported-disk', 'success'],
['a snapshot-free', { type: 'no-snapshot', level: 'ready' }, 'v2v:check:no-snapshot', 'success'],
['a ready', V2V_SELECTED_VM_CHECK.ready, 'ready', 'success'],
['a missing', V2V_SELECTED_VM_CHECK.notFound, 'v2v:not-found-in-source', 'danger'],
['a running', V2V_SELECTED_VM_CHECK.running, 'import-in-progress', 'danger'],
['an already migrated', V2V_SELECTED_VM_CHECK.alreadyMigrated, 'v2v:already-migrated-confirm-at-launch', 'warning'],
['an incompletely mapped', V2V_SELECTED_VM_CHECK.mappingIncomplete, 'v2v:check:mapping-incomplete', 'danger'],
['a fully supported', V2V_VM_CHECK.noUnsupportedDisk, 'v2v:check:no-unsupported-disk', 'success'],
['a snapshot-free', V2V_VM_CHECK.noSnapshot, 'v2v:check:no-snapshot', 'success'],
] as const)('tells %s VM', (_, check: V2vSelectedVmCheck | V2vVmCheck, labelKey, accent) => {
const labels = mountLabels()
@@ -167,7 +159,7 @@ describe('getCheckInfo', () => {
] as const)('tells a VM unsupported for its %s disk', (reason, labelKey) => {
const labels = mountLabels()
expect(labels.getCheckInfo({ type: 'unsupported', level: 'blocking', reason })).toEqual({
expect(labels.getCheckInfo(createV2vUnsupportedCheck(reason))).toEqual({
label: labels.t(labelKey),
accent: 'danger',
})
@@ -185,16 +177,17 @@ describe('getCheckInfo', () => {
it('names the pool a VM is partially imported on', () => {
const labels = mountLabels()
expect(
labels.getCheckInfo({ type: 'partial-on-other-pool', level: 'blocking', poolId: OTHER_POOL_ID }, DISKOR_POOL_NAME)
).toEqual({ label: labels.t('v2v:partial-import-on-pool', { pool: DISKOR_POOL_NAME }), accent: 'danger' })
expect(labels.getCheckInfo(V2V_SELECTED_VM_CHECK.partialOnOtherPool, DISKOR_POOL_NAME)).toEqual({
label: labels.t('v2v:partial-import-on-pool', { pool: DISKOR_POOL_NAME }),
accent: 'danger',
})
})
it('names the pool by its id while its name is unknown', () => {
const labels = mountLabels()
expect(labels.getCheckInfo({ type: 'partial-on-other-pool', level: 'blocking', poolId: OTHER_POOL_ID }).label).toBe(
labels.t('v2v:partial-import-on-pool', { pool: OTHER_POOL_ID })
expect(labels.getCheckInfo(V2V_SELECTED_VM_CHECK.partialOnOtherPool).label).toBe(
labels.t('v2v:partial-import-on-pool', { pool: UNKNOWN_POOL_ID })
)
})
})
@@ -384,3 +377,115 @@ describe('getNicDetailParts', () => {
expect(labels.getNicDetailParts(createV2vPortGroup(), false)).toEqual(['PG-Prod', labels.t('vlan-id', { id: 10 })])
})
})
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'],
[false, 'option-off'],
] as const)('tells a launch option set to %s', (value, labelKey) => {
const labels = mountLabels()
expect(labels.getLaunchOptionLabel(value, !value)).toBe(labels.t(labelKey))
})
it.each([
[true, 'option-on'],
[false, 'option-off'],
] as const)('reads a launch option left to its default of %s as that default', (defaultValue, labelKey) => {
const labels = mountLabels()
expect(labels.getLaunchOptionLabel(undefined, defaultValue)).toBe(
labels.t('default-value', { value: labels.t(labelKey) })
)
})
})

View File

@@ -1,7 +1,8 @@
import {
useV2vVmMapping,
useV2vVmTargets,
useV2vVmMappingSetters,
type V2vVmMappingContext,
type V2vVmMappingOptions,
type V2vVmOverrides,
} from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import type { V2vMigrationListVm } from '@/modules/v2v/types/v2v-migration-list.type.ts'
@@ -11,7 +12,10 @@ import { omit, omitBy } from 'lodash-es'
import { type MaybeRefOrGetter, ref, toValue } from 'vue'
// The settings of a VM are edited on a copy of its stored overrides, taken once: the stored list is never written
export function useV2vVmMappingDraft(rawVm: MaybeRefOrGetter<XoV2vVm>, { list }: Pick<V2vVmMappingContext, 'list'>) {
export function useV2vVmMappingDraft(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list, inventory }: Pick<V2vVmMappingOptions, 'list' | 'inventory'>
) {
const draft = ref<V2vMigrationListVm>({ ...findV2vMigrationListVm(toValue(list), toValue(rawVm).id) })
const updateDraft: V2vVmMappingContext['updateVm'] = (_id, update) => {
@@ -24,7 +28,7 @@ export function useV2vVmMappingDraft(rawVm: MaybeRefOrGetter<XoV2vVm>, { list }:
}
return {
...useV2vVmMapping(rawVm, { list, listVm: draft }),
...useV2vVmTargets(rawVm, { list, listVm: draft, inventory }),
...useV2vVmMappingSetters(rawVm, { list, updateVm: updateDraft }),
toOverrides,
}

View File

@@ -52,8 +52,8 @@ vi.mock(
function mountVmMappingDraft(options?: MountV2vVmMappingOptions) {
return mountV2vVmMapping(
(migrationList, list) => ({
...useV2vVmMappingDraft(V2V_MAPPED_VM, { list }),
(migrationList, list, inventory) => ({
...useV2vVmMappingDraft(V2V_MAPPED_VM, { list, inventory }),
updateStoredVm: migrationList.updateVm,
}),
options

View File

@@ -1,7 +1,6 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import type { useV2vMigrationList } from '@/modules/v2v/composables/use-v2v-migration-list.composable.ts'
import { useV2vPoolDestinationChoices } from '@/modules/v2v/composables/use-v2v-pool-destination-choices.composable.ts'
import { useV2vPoolName } from '@/modules/v2v/composables/use-v2v-pool-name.composable.ts'
@@ -21,33 +20,40 @@ import { getV2vVmChecks } from '@/modules/v2v/utils/v2v-vm-checks.util.ts'
import type { FrontXoVmTemplate } from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import type { InfoAccent } from '@core/components/ui/info/UiInfo.vue'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { XoV2vDisk, XoV2vNic, XoV2vVm } from '@vates/types'
import type { XoV2vDisk, XoV2vInventory, XoV2vNic, XoV2vVm } from '@vates/types'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
// The issue of the VM in the list, if any, is told with the checks of its source.
// `listVm` is where the overrides are read from, the VM of the list by default: the `updateVm` of the setters must
// write to it
export type V2vVmMappingContext = {
list: MaybeRefOrGetter<V2vMigrationList>
updateVm: ReturnType<typeof useV2vMigrationList>['updateVm']
listIssue?: MaybeRefOrGetter<V2vAccentedLabel<InfoAccent> | undefined>
listVm?: MaybeRefOrGetter<V2vMigrationListVm>
}
export type V2vVmOverrides = Omit<V2vMigrationListVm, 'id'>
// The inventory the disks and NICs are located in: handed in, since the settings drawer renders outside the wizard.
// `listVm` is where the overrides are read from, the VM of the list by default: a draft hands its copy, which the
// `updateVm` of its setters must write to
type V2vVmTargetsOptions = Pick<V2vVmMappingContext, 'list'> & {
listVm?: MaybeRefOrGetter<V2vMigrationListVm>
inventory: MaybeRefOrGetter<XoV2vInventory | undefined>
}
// The issue of the VM in the list, if any, is told with the checks of its source
export type V2vVmMappingOptions = Omit<V2vVmTargetsOptions, 'listVm'> & {
listIssue?: MaybeRefOrGetter<V2vAccentedLabel<InfoAccent> | undefined>
}
function toTargetOption<TId>({ id, name_label }: { id: TId; name_label: string }): V2vTargetOption<TId> {
return { id, label: name_label }
}
// A new VM is mapped on the pool of its list, its overrides on top of the list defaults
export function useV2vVmMapping(
// The targets and options of a new VM on the pool of its list, its overrides on top of the list defaults: what its
// settings drawer edits, so nothing here reads the wizard
export function useV2vVmTargets(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list: rawList, listIssue, listVm: rawListVm }: Omit<V2vVmMappingContext, 'updateVm'>
{ list: rawList, listVm: rawListVm, inventory: rawInventory }: V2vVmTargetsOptions
) {
const { inventory } = useV2vConnection()
const { getCheckInfo, getPortGroupParts } = useV2vVmLabels()
const { getPortGroupParts } = useV2vVmLabels()
const { getSrSelectLabel } = useXoSrUtils()
@@ -55,18 +61,14 @@ export function useV2vVmMapping(
const list = toComputed(rawList)
const poolId = computed(() => list.value.poolId)
const inventory = toComputed(rawInventory)
const { choices: offers, areChoicesReady: areOffersReady } = useV2vPoolDestinationChoices(poolId)
const poolName = useV2vPoolName(poolId)
const { choices: offers, areChoicesReady: areOffersReady } = useV2vPoolDestinationChoices(() => list.value.poolId)
const listVm = computed(() => toValue(rawListVm) ?? findV2vMigrationListVm(list.value, vm.value.id))
const mapping = computed(() => getV2vVmMapping(vm.value, listVm.value, list.value.defaults, offers.value))
const hostName = computed(() => findV2vVmHost(inventory.value, vm.value)?.name)
const diskRows = computed(() =>
mapping.value.disks.map(
({ disk, sr }): V2vDiskMappingRow => ({
@@ -93,13 +95,6 @@ export function useV2vVmMapping(
const template = computed(() => mapping.value.template)
const checks = computed(() => {
const vmChecks = getV2vVmChecks(vm.value).map(check => getCheckInfo(check))
const issue = toValue(listIssue)
return issue === undefined ? vmChecks : [...vmChecks, issue]
})
const srOptions = computed(() =>
offers.value.srs.map((sr): V2vTargetOption<FrontXoSr['id']> => ({ id: sr.id, label: getSrSelectLabel(sr) }))
)
@@ -113,9 +108,6 @@ export function useV2vVmMapping(
const tags = computed(() => listVm.value.tags ?? V2V_DEFAULT_TAGS)
return {
poolName,
hostName,
checks,
diskRows,
nicRows,
template,
@@ -128,10 +120,32 @@ export function useV2vVmMapping(
}
}
// What the side panel of a new VM shows: its targets and options, plus the pool, the source host and the checks
export function useV2vVmMapping(rawVm: MaybeRefOrGetter<XoV2vVm>, options: V2vVmMappingOptions) {
const { getCheckInfo } = useV2vVmLabels()
const vm = toComputed(rawVm)
const list = toComputed(options.list)
const poolName = useV2vPoolName(() => list.value.poolId)
const hostName = computed(() => findV2vVmHost(toValue(options.inventory), vm.value)?.name)
const checks = computed(() => {
const vmChecks = getV2vVmChecks(vm.value).map(check => getCheckInfo(check))
const issue = toValue(options.listIssue)
return issue === undefined ? vmChecks : [...vmChecks, issue]
})
return { poolName, hostName, checks, ...useV2vVmTargets(rawVm, options) }
}
// Each change goes through `updateVm`, as an override of the list defaults
export function useV2vVmMappingSetters(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list: rawList, updateVm }: Pick<V2vVmMappingContext, 'list' | 'updateVm'>
{ list: rawList, updateVm }: V2vVmMappingContext
) {
const vm = toComputed(rawVm)

View File

@@ -67,8 +67,8 @@ type MountOptions = MountV2vVmMappingOptions & {
function mountVmMapping({ vm = V2V_MAPPED_VM, listIssue, ...options }: MountOptions = {}) {
return mountV2vVmMapping(
(migrationList, list) => ({
...useV2vVmMapping(vm, { list, listIssue }),
(migrationList, list, inventory) => ({
...useV2vVmMapping(vm, { list, listIssue, inventory }),
...useV2vVmMappingSetters(vm, { list, updateVm: migrationList.updateVm }),
getSrSelectLabel: useXoSrUtils().getSrSelectLabel,
}),

View File

@@ -21,7 +21,7 @@ vi.mock(
() => import('@/test/mock-xo-v2v-vm-rows-collections.ts')
)
const VM_1 = createV2vVm({ id: 'vm-1' as XoV2vVm['id'], name: 'app-01' })
const VM_1 = createV2vVm({ name: 'app-01' })
const VM_2 = createV2vVm({ id: 'vm-2' as XoV2vVm['id'], name: 'db-01' })

View File

@@ -1,3 +1,4 @@
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import type { V2vVmMappingContext, V2vVmOverrides } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
@@ -5,15 +6,18 @@ import type { XoV2vVm } from '@vates/types'
import { reactiveComputed } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
// The drawer confirms the whole settings of the VM: they replace its stored overrides
// The drawer confirms the whole settings of the VM: they replace its stored overrides. As an overlay, it renders
// outside the wizard: it is handed the inventory of the connected source
export function useV2vVmSettingsEdit(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list: rawList, updateVm }: Pick<V2vVmMappingContext, 'list' | 'updateVm'>
{ list: rawList, updateVm }: V2vVmMappingContext
) {
const vm = toComputed(rawVm)
const list = toComputed(rawList)
const { inventory } = useV2vConnection()
const { open } = useOverlay({
component: () => import('@/modules/v2v/components/drawer/V2vVmSettingsDrawer.vue'),
events: {
@@ -27,6 +31,7 @@ export function useV2vVmSettingsEdit(
const props = reactiveComputed(() => ({
vm: vm.value,
list: list.value,
inventory: inventory.value,
}))
function editVmSettings() {

View File

@@ -2,7 +2,12 @@ import type { V2vVmOverrides } from '@/modules/v2v/composables/use-v2v-vm-mappin
import { useV2vVmSettingsEdit } from '@/modules/v2v/composables/use-v2v-vm-settings-edit.composable.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountV2vVmMapping, readV2vStoredListVm } from '@/test/mount-v2v-vm-mapping.ts'
import { V2V_DEBIAN_TEMPLATE, V2V_DISK_OVERRIDES, V2V_MAPPED_VM } from '@/test/v2v-mapping-fixtures.ts'
import {
V2V_DEBIAN_TEMPLATE,
V2V_DISK_OVERRIDES,
V2V_MAPPED_VM,
V2V_MAPPING_INVENTORY,
} from '@/test/v2v-mapping-fixtures.ts'
type OverlayOptions = {
events: {
@@ -34,12 +39,14 @@ function getOverlayOptions(): OverlayOptions {
}
describe('editVmSettings', () => {
it('opens the drawer with the VM and its list', () => {
it('opens the drawer with the VM, its list and the inventory of the connected source', () => {
const { result } = mountVmSettingsEdit()
result.editVmSettings()
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({ props: { vm: V2V_MAPPED_VM, list: result.list } })
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({
props: { vm: V2V_MAPPED_VM, list: result.list, inventory: V2V_MAPPING_INVENTORY },
})
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -0,0 +1,48 @@
import type { V2vLaunchSettings } from '@/modules/v2v/types/v2v-launch.type.ts'
import { integer, outOfRange, required } from '@core/packages/form-validation'
import { useValidatedForm } from '@core/packages/validated-form'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
// The bounds the back end accepts
const MIN_CONCURRENCY = 1
const MAX_CONCURRENCY = 8
// The number input of the concurrency can be cleared
export type V2vLaunchFormData = Omit<V2vLaunchSettings, 'concurrency'> & {
concurrency: V2vLaunchSettings['concurrency'] | undefined
}
export type V2vLaunchForm = ReturnType<typeof useV2vLaunchForm>
export function useV2vLaunchForm() {
const { t } = useI18n()
const formData = reactive<V2vLaunchFormData>({ stopSource: true, stopOnError: true, concurrency: 2 })
const { useField, validate } = useValidatedForm(formData, {
errors: {
onSubmit: () => ({
concurrency: { required },
}),
onBlur: () => ({
concurrency: { integer, outOfRange: outOfRange(MIN_CONCURRENCY, MAX_CONCURRENCY) },
}),
},
})
return {
formData,
validate,
stopSourceCheckboxBindings: useField('stopSource', () => ({
label: t('v2v:stop-source-vm'),
info: t('v2v:stop-source-vm-info'),
})),
stopOnErrorCheckboxBindings: useField('stopOnError', () => ({
label: t('stop-on-error'),
info: t('v2v:stop-on-error-info'),
})),
concurrencyInputBindings: useField('concurrency', () => ({ label: t('concurrency'), required: true })),
}
}

View File

@@ -0,0 +1,81 @@
import { createV2vLaunchSettings } from '@/test/create-v2v-launch.ts'
import { dangerMessage, requiredMessage } from '@/test/form-messages.ts'
import { mountV2vLaunchForm } from '@/test/mount-v2v-launch-form.ts'
async function validateConcurrency(concurrency: number) {
const form = mountV2vLaunchForm()
form.formData.concurrency = concurrency
return { form, isValid: await form.validate() }
}
// A cleared number input gives an empty string, whatever the type of its model
const EMPTY_INPUT = '' as unknown as number
describe('formData', () => {
it('starts stopping the source VMs and on the first error, two VMs at a time', () => {
expect(mountV2vLaunchForm().formData).toEqual(createV2vLaunchSettings())
})
})
describe('stopSourceCheckboxBindings', () => {
it('labels the stop of the source VM and tells when it happens', () => {
const form = mountV2vLaunchForm()
expect(form.stopSourceCheckboxBindings).toMatchObject({
label: form.t('v2v:stop-source-vm'),
info: form.t('v2v:stop-source-vm-info'),
})
})
})
describe('stopOnErrorCheckboxBindings', () => {
it('labels the stop on error and tells it applies to the whole launch', () => {
const form = mountV2vLaunchForm()
expect(form.stopOnErrorCheckboxBindings).toMatchObject({
label: form.t('stop-on-error'),
info: form.t('v2v:stop-on-error-info'),
})
})
})
describe('concurrencyInputBindings', () => {
it('labels the concurrency, required', () => {
const form = mountV2vLaunchForm()
expect(form.concurrencyInputBindings).toMatchObject({ label: form.t('concurrency'), required: true })
})
})
describe('validate', () => {
it.each([0, 9])('reports a concurrency of %s as out of the 1 to 8 range', async concurrency => {
const { form, isValid } = await validateConcurrency(concurrency)
expect(isValid).toBe(false)
expect(form.concurrencyInputBindings.error).toEqual(
dangerMessage(form.t('form:warning:out-of-range', { min: 1, max: 8 }))
)
})
it('reports a concurrency that is not an integer', async () => {
const { form, isValid } = await validateConcurrency(1.5)
expect(isValid).toBe(false)
expect(form.concurrencyInputBindings.error).toEqual(dangerMessage(form.t('form:error:integer')))
})
it('reports a missing concurrency', async () => {
const { form, isValid } = await validateConcurrency(EMPTY_INPUT)
expect(isValid).toBe(false)
expect(form.concurrencyInputBindings.error).toEqual(requiredMessage())
})
it.each([1, 8])('accepts a concurrency of %s', async concurrency => {
const { form, isValid } = await validateConcurrency(concurrency)
expect(isValid).toBe(true)
expect(form.concurrencyInputBindings.error).toBeUndefined()
})
})

View File

@@ -2,6 +2,8 @@ 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 { expectJobRunning } from '@/test/job-running.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 +16,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 () => {
@@ -45,16 +41,12 @@ describe('errorMessage', () => {
it('reports a connection in progress', () => {
const job = mountInventoryJob(createV2vConnection())
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(job.t('job:connect:in-progress'))
expectJobRunning(job, job.t('job:connect:in-progress'))
})
it('has no error with a connection when idle', () => {
it('can run with a connection', () => {
const job = mountInventoryJob(createV2vConnection())
expect(job.canRun).toBe(true)
expect(job.errorMessage).toBeUndefined()
})
})

View File

@@ -0,0 +1,51 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { fetchPost } from '@/shared/utils/fetch.util.ts'
import { defineJob, defineJobArg, JobError, JobRunningError } from '@core/packages/job'
import type { XoTask, XoV2vStartPayload } from '@vates/types'
import { useI18n } from 'vue-i18n'
// The pool, not its id: a branded id argument cannot be named in the declaration of the job (TS4023). The job identifies
// its arguments before validating them: a missing pool must not throw here
const poolArg = defineJobArg<FrontXoPool>({
identify: (pool: FrontXoPool | undefined) => pool?.id,
toArray: false,
})
const payloadArg = defineJobArg<XoV2vStartPayload>({
identify: false,
toArray: false,
})
export const useXoV2vStartJob = defineJob('v2v.start', [poolArg, payloadArg], () => {
const { t } = useI18n()
return {
// An import lasts hours: the task is not monitored, the caller links to it
async run(pool: FrontXoPool, payload: XoV2vStartPayload): Promise<XoTask['id']> {
const { taskId } = await fetchPost<{ taskId: XoTask['id'] }>(
`pools/${pool.id}/actions/import_vms_from_vmware`,
payload
)
return taskId
},
validate(isRunning, pool: FrontXoPool | undefined, payload: XoV2vStartPayload | undefined) {
if (isRunning) {
throw new JobRunningError(t('job:v2v-start:in-progress'))
}
if (pool === undefined) {
throw new JobError(t('job:arg:pool-id-required'))
}
if (payload === undefined) {
throw new JobError(t('job:arg:missing-payload'))
}
if (payload.vms.length === 0) {
throw new JobError(t('job:v2v-start:missing-vm'))
}
},
}
})

View File

@@ -0,0 +1,74 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useXoV2vStartJob } from '@/modules/v2v/jobs/xo-v2v-start.job.ts'
import { buildV2vStartPayload } from '@/modules/v2v/utils/v2v-start-payload.util.ts'
import { ApiError } from '@/shared/error/api.error.ts'
import { createPool } from '@/test/create-pool.ts'
import {
createV2vStartPayloadInput,
stubV2vStartAccepted,
stubV2vStartProblems,
V2V_START_TASK_ID,
} from '@/test/create-v2v-launch.ts'
import { hasRequested } from '@/test/fetch-requests.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import type { XoV2vStartPayload } from '@vates/types'
const POOL = createPool()
const PAYLOAD = buildV2vStartPayload(createV2vStartPayloadInput())
function mountStartJob(pool?: FrontXoPool, payload?: XoV2vStartPayload) {
return mountComposableWithI18n(() => useXoV2vStartJob(pool, payload))
}
describe('run', () => {
it('posts the payload to the import route of the pool and resolves the task id', async () => {
const fetchMock = stubV2vStartAccepted()
await expect(mountStartJob(POOL, PAYLOAD).run()).resolves.toBe(V2V_START_TASK_ID)
expect(fetchMock).toHaveBeenCalledOnce()
expect(hasRequested('POST', `pools/${POOL.id}/actions/import_vms_from_vmware`, PAYLOAD)).toBe(true)
})
it('rejects with the API error the server answered', async () => {
stubV2vStartProblems([])
await expect(mountStartJob(POOL, PAYLOAD).run()).rejects.toBeInstanceOf(ApiError)
})
})
describe('errorMessage', () => {
it('reports a launch in progress', () => {
const job = mountStartJob(POOL, PAYLOAD)
expectJobRunning(job, job.t('job:v2v-start:in-progress'))
})
it('reports a missing pool', () => {
const job = mountStartJob(undefined, PAYLOAD)
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(job.t('job:arg:pool-id-required'))
})
it('reports a missing payload', () => {
const job = mountStartJob(POOL, undefined)
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(job.t('job:arg:missing-payload'))
})
it('reports a payload without VMs', () => {
const job = mountStartJob(POOL, { ...PAYLOAD, vms: [] })
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(job.t('job:v2v-start:missing-vm'))
})
it('can run with a pool and a payload of one VM', () => {
const job = mountStartJob(POOL, PAYLOAD)
expect(job.canRun).toBe(true)
})
})

View File

@@ -0,0 +1,30 @@
import type { V2vMigrationListVm } from '@/modules/v2v/types/v2v-migration-list.type.ts'
import type { V2vSelectedVmState } from '@/modules/v2v/types/v2v-selected-vm.type.ts'
import type { XoV2vStartOptions, XoV2vStartPayload, XoV2vVm } from '@vates/types'
// What the user sets for the whole launch: starting the VM and cleaning it up are not supported yet
export type V2vLaunchSettings = Pick<XoV2vStartOptions, 'stopSource'> &
Pick<XoV2vStartPayload, 'stopOnError' | 'concurrency'>
export type V2vLaunchGroupType = 'excluded' | 'migrated' | 'resumed' | 'new'
export type V2vLaunchPlanItem = V2vSelectedVmState & { id: XoV2vVm['id'] }
export type V2vLaunchPlan<TItem extends V2vLaunchPlanItem> = Record<V2vLaunchGroupType, TItem[]> & {
launchable: TItem[]
}
// An absent override leaves the VM on the launch settings
export type V2vVmLaunchOverrides = Partial<Pick<XoV2vStartOptions, 'stopSource'>>
export type V2vStartVm = Pick<V2vMigrationListVm, 'id' | 'disks' | 'nics' | 'template'> &
Pick<V2vSelectedVmState, 'kind'> & { overrides?: V2vVmLaunchOverrides }
// A row of the per-VM launch options table: an absent override follows the launch settings
export type V2vVmLaunchOptionsRow = Pick<XoV2vVm, 'id' | 'name'> & Pick<V2vStartVm, 'overrides'>
// A problem the back end refused the launch for, of one VM or of the whole launch
export type V2vStartProblem = {
vmId?: XoV2vVm['id']
message: string
}

View File

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

View File

@@ -1,27 +1,9 @@
import { ApiError } from '@/shared/error/api.error.ts'
import { HttpCodes } from '@core/types/http-codes.type.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'
export function isV2vInvalidCredentialsError(error: unknown): boolean {
const isUnprocessableApiError = error instanceof ApiError && error.status === HttpCodes.UnprocessableEntity
if (!isUnprocessableApiError) {
return false
}
const errorData = hasObjectProperty(error.cause, 'data') ? error.cause.data : undefined
const errorData = getUnprocessableEntityData(error)
return hasObjectProperty(errorData, 'code') && errorData.code === V2V_ERROR_CODE.INVALID_CREDENTIALS
}
export function getV2vConnectionErrorMessage(error: unknown): string | undefined {
const serverError =
error instanceof ApiError && hasObjectProperty(error.cause, 'error') ? error.cause.error : undefined
if (typeof serverError === 'string' && serverError !== '') {
return serverError
}
return error instanceof Error && error.message !== '' ? error.message : undefined
}

View File

@@ -1,54 +1,21 @@
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'
function createApiError(status: number, cause?: Record<string, unknown>) {
return new ApiError('Request failed', { status, cause })
}
function createCodedApiError(status: number, code: string) {
return createApiError(status, { error: 'failed', data: { code } })
function createCodedApiError(code: string) {
return createApiError(422, { error: 'failed', data: { code } })
}
describe('isV2vInvalidCredentialsError', () => {
it('detects a 422 carrying the invalid credentials code', () => {
expect(isV2vInvalidCredentialsError(createCodedApiError(422, V2V_ERROR_CODE.INVALID_CREDENTIALS))).toBe(true)
expect(isV2vInvalidCredentialsError(createCodedApiError(V2V_ERROR_CODE.INVALID_CREDENTIALS))).toBe(true)
})
it('ignores a 422 with another code', () => {
expect(isV2vInvalidCredentialsError(createCodedApiError(422, 'OTHER'))).toBe(false)
})
it('ignores another status carrying the invalid credentials code', () => {
expect(isV2vInvalidCredentialsError(createCodedApiError(500, V2V_ERROR_CODE.INVALID_CREDENTIALS))).toBe(false)
expect(isV2vInvalidCredentialsError(createCodedApiError('OTHER'))).toBe(false)
})
it.each([undefined, { error: 'failed' }])('ignores a 422 without error code (%j)', cause => {
expect(isV2vInvalidCredentialsError(createApiError(422, cause))).toBe(false)
})
it.each([new Error('network'), 'string', undefined])('ignores a non API error (%s)', error => {
expect(isV2vInvalidCredentialsError(error)).toBe(false)
})
})
describe('getV2vConnectionErrorMessage', () => {
it('reads the error the server answered', () => {
expect(getV2vConnectionErrorMessage(createApiError(500, { error: 'host unreachable' }))).toBe('host unreachable')
})
it('falls back to the error message', () => {
expect(getV2vConnectionErrorMessage(createApiError(502))).toBe('Request failed')
expect(getV2vConnectionErrorMessage(new Error('network down'))).toBe('network down')
})
it.each([new ApiError('', { status: 500 }), new Error(''), 'string', undefined])(
'has no message to show without any (%s)',
error => {
expect(getV2vConnectionErrorMessage(error)).toBeUndefined()
}
)
})

View File

@@ -0,0 +1,38 @@
import type { V2vLaunchGroupType, V2vLaunchPlan, V2vLaunchPlanItem } from '@/modules/v2v/types/v2v-launch.type.ts'
import type { V2vSelectedVmState } from '@/modules/v2v/types/v2v-selected-vm.type.ts'
import type { XoV2vVm } from '@vates/types'
// The same checks as the selected VMs panel: a VM it shows blocked is never launched
export function getV2vLaunchGroupType({ kind, check }: V2vSelectedVmState): V2vLaunchGroupType {
if (check.level === 'blocking') {
return 'excluded'
}
if (check.type === 'already-migrated') {
return 'migrated'
}
return kind === 'resume' ? 'resumed' : 'new'
}
// An already migrated VM is imported again as a second VM: only once the user confirms it
export function getV2vLaunchPlan<TItem extends V2vLaunchPlanItem>(
items: TItem[],
confirmedIds: ReadonlySet<XoV2vVm['id']>
): V2vLaunchPlan<TItem> {
const plan: V2vLaunchPlan<TItem> = { excluded: [], migrated: [], resumed: [], new: [], launchable: [] }
for (const item of items) {
const groupType = getV2vLaunchGroupType(item)
plan[groupType].push(item)
const isLaunchable = groupType !== 'excluded' && (groupType !== 'migrated' || confirmedIds.has(item.id))
if (isLaunchable) {
plan.launchable.push(item)
}
}
return plan
}

View File

@@ -0,0 +1,86 @@
import { getV2vLaunchGroupType, getV2vLaunchPlan } from '@/modules/v2v/utils/v2v-launch-plan.util.ts'
import { createV2vLaunchPlanItem } from '@/test/create-v2v-launch.ts'
import { getV2vVmIds } from '@/test/create-v2v-vm-row.ts'
import { V2V_SELECTED_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
import type { XoV2vVm } from '@vates/types'
const { ready, notFound, unsupported, running, partialOnOtherPool, mappingIncomplete, alreadyMigrated } =
V2V_SELECTED_VM_CHECK
const NO_CONFIRMATION = new Set<XoV2vVm['id']>()
function createItem(id: string, overrides: Parameters<typeof createV2vLaunchPlanItem>[0] = {}) {
return createV2vLaunchPlanItem({ id: id as XoV2vVm['id'], ...overrides })
}
describe('getV2vLaunchGroupType', () => {
it.each([
['not found', notFound],
['unsupported', unsupported],
['running', running],
['partially imported on another pool', partialOnOtherPool],
['of an incomplete mapping', mappingIncomplete],
])('excludes a VM %s', (_, check) => {
expect(getV2vLaunchGroupType({ kind: 'new', check })).toBe('excluded')
})
it('excludes a resumed VM that is blocked', () => {
expect(getV2vLaunchGroupType({ kind: 'resume', check: running })).toBe('excluded')
})
it('groups an already migrated VM apart', () => {
expect(getV2vLaunchGroupType({ kind: 'new', check: alreadyMigrated })).toBe('migrated')
})
it('groups a ready VM by its kind', () => {
expect(getV2vLaunchGroupType({ kind: 'resume', check: ready })).toBe('resumed')
expect(getV2vLaunchGroupType({ kind: 'new', check: ready })).toBe('new')
})
})
describe('getV2vLaunchPlan', () => {
const items = [
createItem('new-1'),
createItem('excluded-1', { check: running }),
createItem('resumed-1', { kind: 'resume' }),
createItem('migrated-1', { check: alreadyMigrated }),
createItem('new-2'),
createItem('migrated-2', { check: alreadyMigrated }),
]
it('groups the VMs in list order', () => {
const plan = getV2vLaunchPlan(items, NO_CONFIRMATION)
expect(getV2vVmIds(plan.excluded)).toEqual(['excluded-1'])
expect(getV2vVmIds(plan.migrated)).toEqual(['migrated-1', 'migrated-2'])
expect(getV2vVmIds(plan.resumed)).toEqual(['resumed-1'])
expect(getV2vVmIds(plan.new)).toEqual(['new-1', 'new-2'])
})
it('launches the resumed and new VMs in list order, not the unconfirmed already migrated ones', () => {
expect(getV2vVmIds(getV2vLaunchPlan(items, NO_CONFIRMATION).launchable)).toEqual(['new-1', 'resumed-1', 'new-2'])
})
it('launches an already migrated VM once confirmed, in list order', () => {
const plan = getV2vLaunchPlan(items, new Set(['migrated-1' as XoV2vVm['id']]))
expect(getV2vVmIds(plan.launchable)).toEqual(['new-1', 'resumed-1', 'migrated-1', 'new-2'])
expect(getV2vVmIds(plan.migrated)).toEqual(['migrated-1', 'migrated-2'])
})
it('never launches a confirmed VM that is excluded', () => {
expect(getV2vVmIds(getV2vLaunchPlan(items, new Set(['excluded-1' as XoV2vVm['id']])).launchable)).not.toContain(
'excluded-1'
)
})
it('plans nothing for an empty list', () => {
expect(getV2vLaunchPlan([], NO_CONFIRMATION)).toEqual({
excluded: [],
migrated: [],
resumed: [],
new: [],
launchable: [],
})
})
})

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import {
V2V_POOL_OFFERS,
V2V_SSD_SR,
} from '@/test/v2v-mapping-fixtures.ts'
import { createV2vUnsupportedCheck, V2V_SELECTED_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import type { XoV2vVm } from '@vates/types'
@@ -50,66 +51,65 @@ function createMapping(vm: XoV2vVm) {
}
describe('getV2vSelectedVmState', () => {
const READY = { type: 'ready', level: 'ready' }
it('resumes a VM warm synced on the pool of the list, ready', () => {
expect(getV2vSelectedVmState(createWarmRow(V2V_LIST_POOL.id), V2V_LIST_POOL.id)).toEqual({
kind: 'resume',
check: READY,
check: V2V_SELECTED_VM_CHECK.ready,
})
})
it('imports a supported VM not migrated as new, ready', () => {
expect(getV2vSelectedVmState(createV2vVmRow(), V2V_LIST_POOL.id)).toEqual({ kind: 'new', check: READY })
expect(getV2vSelectedVmState(createV2vVmRow(), V2V_LIST_POOL.id)).toEqual({
kind: 'new',
check: V2V_SELECTED_VM_CHECK.ready,
})
})
it('imports a VM warm without its import VM as new, ready', () => {
expect(getV2vSelectedVmState(createV2vVmRow({ migration: 'warm' }), V2V_LIST_POOL.id)).toEqual({
kind: 'new',
check: READY,
check: V2V_SELECTED_VM_CHECK.ready,
})
})
it('imports a VM warm synced on another pool as new, blocked by that pool', () => {
expect(getV2vSelectedVmState(createWarmRow(OTHER_POOL_ID), V2V_LIST_POOL.id)).toEqual({
kind: 'new',
check: { type: 'partial-on-other-pool', level: 'blocking', poolId: OTHER_POOL_ID },
check: { ...V2V_SELECTED_VM_CHECK.partialOnOtherPool, poolId: OTHER_POOL_ID },
})
})
it('imports a VM warm synced as new, ready, while there is no pool to compare it with', () => {
expect(getV2vSelectedVmState(createWarmRow(OTHER_POOL_ID), undefined)).toEqual({ kind: 'new', check: READY })
expect(getV2vSelectedVmState(createWarmRow(OTHER_POOL_ID), undefined)).toEqual({
kind: 'new',
check: V2V_SELECTED_VM_CHECK.ready,
})
})
it('imports a migrated VM as new, with a warning', () => {
expect(getV2vSelectedVmState(createMigratedRow(), V2V_LIST_POOL.id)).toEqual({
kind: 'new',
check: { type: 'already-migrated', level: 'warning' },
check: V2V_SELECTED_VM_CHECK.alreadyMigrated,
})
})
it('blocks a VM missing from the inventory, imported as new', () => {
expect(getV2vSelectedVmState(undefined, V2V_LIST_POOL.id)).toEqual({
kind: 'new',
check: { type: 'not-found', level: 'blocking' },
check: V2V_SELECTED_VM_CHECK.notFound,
})
})
it('blocks an unsupported VM, with its reason', () => {
const row = createV2vVmRow({ supported: false, unsupportedReason: 'encrypted' })
expect(getV2vSelectedVmState(row, V2V_LIST_POOL.id).check).toEqual({
type: 'unsupported',
level: 'blocking',
reason: 'encrypted',
})
expect(getV2vSelectedVmState(row, V2V_LIST_POOL.id).check).toEqual(createV2vUnsupportedCheck('encrypted'))
})
it('blocks a VM being imported', () => {
expect(getV2vSelectedVmState(createV2vVmRow({ migration: 'running' }), V2V_LIST_POOL.id).check).toEqual({
type: 'running',
level: 'blocking',
})
expect(getV2vSelectedVmState(createV2vVmRow({ migration: 'running' }), V2V_LIST_POOL.id).check).toEqual(
V2V_SELECTED_VM_CHECK.running
)
})
it('tells an unsupported VM unsupported whatever its migration, still resumed on the pool of the list', () => {
@@ -117,7 +117,7 @@ describe('getV2vSelectedVmState', () => {
expect(getV2vSelectedVmState(row, V2V_LIST_POOL.id)).toEqual({
kind: 'resume',
check: { type: 'unsupported', level: 'blocking', reason: 'rdm' },
check: V2V_SELECTED_VM_CHECK.unsupported,
})
})
@@ -130,18 +130,21 @@ describe('getV2vSelectedVmState', () => {
it('blocks a new VM whose mapping is incomplete', () => {
expect(getV2vSelectedVmState(createV2vVmRow(), V2V_LIST_POOL.id, 'incomplete')).toEqual({
kind: 'new',
check: { type: 'mapping-incomplete', level: 'blocking' },
check: V2V_SELECTED_VM_CHECK.mappingIncomplete,
})
})
it('tells a new VM whose mapping is complete ready', () => {
expect(getV2vSelectedVmState(createV2vVmRow(), V2V_LIST_POOL.id, 'complete')).toEqual({ kind: 'new', check: READY })
expect(getV2vSelectedVmState(createV2vVmRow(), V2V_LIST_POOL.id, 'complete')).toEqual({
kind: 'new',
check: V2V_SELECTED_VM_CHECK.ready,
})
})
it('ignores the mapping state of a resumed VM, locked since its first sync', () => {
expect(getV2vSelectedVmState(createWarmRow(V2V_LIST_POOL.id), V2V_LIST_POOL.id, 'incomplete')).toEqual({
kind: 'resume',
check: READY,
check: V2V_SELECTED_VM_CHECK.ready,
})
})
@@ -207,19 +210,19 @@ describe('getV2vMappingSummary', () => {
describe('isV2vListIssue', () => {
it.each([
['running', { type: 'running', level: 'blocking' }],
['partially imported on another pool', { type: 'partial-on-other-pool', level: 'blocking', poolId: OTHER_POOL_ID }],
['with an incomplete mapping', { type: 'mapping-incomplete', level: 'blocking' }],
['already migrated', { type: 'already-migrated', level: 'warning' }],
['not found in the source', { type: 'not-found', level: 'blocking' }],
] as const)('tells the issue of a VM %s', (_, check) => {
['running', V2V_SELECTED_VM_CHECK.running],
['partially imported on another pool', V2V_SELECTED_VM_CHECK.partialOnOtherPool],
['with an incomplete mapping', V2V_SELECTED_VM_CHECK.mappingIncomplete],
['already migrated', V2V_SELECTED_VM_CHECK.alreadyMigrated],
['not found in the source', V2V_SELECTED_VM_CHECK.notFound],
])('tells the issue of a VM %s', (_, check) => {
expect(isV2vListIssue(check)).toBe(true)
})
it.each([
['ready', { type: 'ready', level: 'ready' }],
['unsupported, which its source checks tell', { type: 'unsupported', level: 'blocking', reason: 'rdm' }],
] as const)('tells no issue of a VM %s', (_, check) => {
['ready', V2V_SELECTED_VM_CHECK.ready],
['unsupported, which its source checks tell', V2V_SELECTED_VM_CHECK.unsupported],
])('tells no issue of a VM %s', (_, check) => {
expect(isV2vListIssue(check)).toBe(false)
})
})

View File

@@ -0,0 +1,62 @@
import type { V2vLaunchSettings, V2vStartProblem, V2vStartVm } from '@/modules/v2v/types/v2v-launch.type.ts'
import type { V2vDestinationDefaults } from '@/modules/v2v/types/v2v-migration-list.type.ts'
import { getUnprocessableEntityData } from '@/shared/utils/api-error.util.ts'
import { hasObjectProperty } from '@core/utils/object.util.ts'
import type { XoV2vConnection, XoV2vStartPayload, XoV2vStartPayloadVm, XoV2vVm } from '@vates/types'
export type V2vStartPayloadInput = {
connection: XoV2vConnection
defaults: V2vDestinationDefaults
settings: V2vLaunchSettings
vms: V2vStartVm[]
}
// The back end refuses to start or clean up a VM, to tag it or to give it new MACs: only what it accepts is sent,
// whatever the list holds
const UNSUPPORTED_DEFAULTS = { startVm: false, cleanup: false }
const UNSUPPORTED_VM_OPTIONS = { tags: [], keepMac: true }
// A resumed VM keeps the mapping of its first sync, which the import reads from the XCP-ng VM
function buildVm({ id, kind, disks, nics, template, overrides }: V2vStartVm): XoV2vStartPayloadVm {
const mapping = kind === 'resume' ? {} : { disks, nics, template }
// An `undefined` key is not sent: the VM is then on the list defaults or the launch settings
return { id, ...mapping, stopSource: overrides?.stopSource, ...UNSUPPORTED_VM_OPTIONS }
}
export function buildV2vStartPayload({ connection, defaults, settings, vms }: V2vStartPayloadInput): XoV2vStartPayload {
const { stopSource, stopOnError, concurrency } = settings
return {
connection,
defaults: { ...defaults, stopSource, ...UNSUPPORTED_DEFAULTS },
vms: vms.map(buildVm),
stopOnError,
concurrency,
}
}
function getProblem(entry: unknown): V2vStartProblem | undefined {
if (!hasObjectProperty(entry, 'message') || typeof entry.message !== 'string') {
return undefined
}
const vmId = hasObjectProperty(entry, 'vm') && typeof entry.vm === 'string' ? (entry.vm as XoV2vVm['id']) : undefined
return vmId === undefined ? { message: entry.message } : { vmId, message: entry.message }
}
// The back end reports every problem of a refused launch at once (`invalidParameters`)
export function getV2vStartProblems(error: unknown): V2vStartProblem[] | undefined {
const errorData = getUnprocessableEntityData(error)
const errors = hasObjectProperty(errorData, 'errors') ? errorData.errors : undefined
if (!Array.isArray(errors)) {
return undefined
}
const problems = errors.map(getProblem).filter(problem => problem !== undefined)
return problems.length === 0 ? undefined : problems
}

View File

@@ -0,0 +1,102 @@
import {
buildV2vStartPayload,
getV2vStartProblems,
type V2vStartPayloadInput,
} from '@/modules/v2v/utils/v2v-start-payload.util.ts'
import { createApiError } from '@/test/create-api-error.ts'
import { createV2vConnection } from '@/test/create-v2v-connection.ts'
import { createV2vLaunchSettings, createV2vStartPayloadInput, createV2vStartVm } from '@/test/create-v2v-launch.ts'
import { createV2vDestinationDefaults } from '@/test/create-v2v-migration-list.ts'
import { getV2vVmIds } from '@/test/create-v2v-vm-row.ts'
import type { XoNetwork, XoSr, XoV2vVm, XoVmTemplate } from '@vates/types'
const OVERRIDES = {
disks: { 'disk-1': 'sr-456' as XoSr['id'] },
nics: { 'nic-1': 'network-456' as XoNetwork['id'] },
template: 'template-456' as XoVmTemplate['id'],
}
function buildPayload(overrides?: Partial<V2vStartPayloadInput>) {
return buildV2vStartPayload(createV2vStartPayloadInput(overrides))
}
// The body of the 422 the REST API answers a refused launch with
function createRefusedLaunchError(errors: unknown) {
return createApiError(422, { error: 'the migration cannot be started', data: { errors } })
}
describe('buildV2vStartPayload', () => {
it('sends the connection, the launch settings and the list defaults, never starting nor cleaning up the VMs', () => {
const payload = buildPayload({
settings: createV2vLaunchSettings({ stopSource: false, stopOnError: false, concurrency: 4 }),
})
expect(payload).toMatchObject({
connection: createV2vConnection(),
defaults: { ...createV2vDestinationDefaults(), stopSource: false, startVm: false, cleanup: false },
stopOnError: false,
concurrency: 4,
})
})
it('sends a new VM with its mapping overrides only', () => {
expect(buildPayload({ vms: [createV2vStartVm(OVERRIDES)] }).vms).toEqual([
{ id: 'vm-1', ...OVERRIDES, tags: [], keepMac: true },
])
})
it('sends a new VM on the list defaults without mapping', () => {
expect(buildPayload().vms).toEqual([{ id: 'vm-1', tags: [], keepMac: true }])
})
it('sends a resumed VM without mapping, the import reads it from its first sync', () => {
expect(buildPayload({ vms: [createV2vStartVm({ kind: 'resume', ...OVERRIDES })] }).vms).toEqual([
{ id: 'vm-1', tags: [], keepMac: true },
])
})
it.each([true, false])('sends the stop source override (%s) of a VM', stopSource => {
expect(buildPayload({ vms: [createV2vStartVm({ overrides: { stopSource } })] }).vms).toEqual([
{ id: 'vm-1', stopSource, tags: [], keepMac: true },
])
})
it('sends the VMs given, in order', () => {
const vms = ['vm-2', 'vm-1'].map(id => createV2vStartVm({ id: id as XoV2vVm['id'] }))
expect(getV2vVmIds(buildPayload({ vms }).vms)).toEqual(['vm-2', 'vm-1'])
})
})
describe('getV2vStartProblems', () => {
it('reads every problem of a refused launch, of a VM or of the whole launch', () => {
const error = createRefusedLaunchError([
{ vm: 'vm-1', field: 'tags', message: 'tags are not supported yet' },
{ field: 'defaults.startVm', message: 'starting the VM after its import is not supported yet' },
])
expect(getV2vStartProblems(error)).toEqual([
{ vmId: 'vm-1', message: 'tags are not supported yet' },
{ message: 'starting the VM after its import is not supported yet' },
])
})
it('ignores the problems it cannot read', () => {
const error = createRefusedLaunchError([
{ vm: 'vm-1' },
{ vm: 'vm-1', message: 42 },
{ vm: 42, message: 'no such VM on the source' },
null,
])
expect(getV2vStartProblems(error)).toEqual([{ message: 'no such VM on the source' }])
})
it.each([
['a 422 without problem list', createApiError(422, { error: 'invalid' })],
['a 422 whose problems are not a list', createRefusedLaunchError('invalid')],
['a 422 without readable problem', createRefusedLaunchError([{ vm: 'vm-1' }])],
])('reads no problem from %s', (_, error) => {
expect(getV2vStartProblems(error)).toBeUndefined()
})
})

View File

@@ -1,6 +1,7 @@
import type { V2vUnsupportedReason } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import { getV2vUnsupportedReason, getV2vVmChecks } from '@/modules/v2v/utils/v2v-vm-checks.util.ts'
import { createV2vVm, createV2vVmWithDisks } from '@/test/create-v2v-inventory.ts'
import { createV2vUnsupportedCheck, V2V_VM_CHECK } from '@/test/v2v-selected-vm-checks.ts'
import { V2V_DISK_MODE, type XoV2vDisk } from '@vates/types'
describe('getV2vUnsupportedReason', () => {
@@ -40,18 +41,16 @@ describe('getV2vUnsupportedReason', () => {
describe('getV2vVmChecks', () => {
it('tells a VM without unsupported disk nor snapshot ready', () => {
expect(getV2vVmChecks(createV2vVm({ snapshotCount: 0 }))).toEqual([
{ type: 'no-unsupported-disk', level: 'ready' },
{ type: 'no-snapshot', level: 'ready' },
V2V_VM_CHECK.noUnsupportedDisk,
V2V_VM_CHECK.noSnapshot,
])
})
// Each reason and their precedence are those of `getV2vUnsupportedReason`, tested above
it('blocks a VM with an unsupported disk, with its reason', () => {
expect(getV2vVmChecks(createV2vVmWithDisks([{}, { isEncrypted: true }]))[0]).toEqual({
type: 'unsupported',
level: 'blocking',
reason: 'encrypted',
})
expect(getV2vVmChecks(createV2vVmWithDisks([{}, { isEncrypted: true }]))[0]).toEqual(
createV2vUnsupportedCheck('encrypted')
)
})
it('warns about the snapshots of the source VM, with their count', () => {

Some files were not shown because too many files have changed in this diff Show More