mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
21 Commits
c145a7a579
...
2ac7c0ffb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac7c0ffb8 | ||
|
|
68ef82a9f3 | ||
|
|
565dbfa835 | ||
|
|
a8952875ac | ||
|
|
7b44a15a51 | ||
|
|
22ed4ac652 | ||
|
|
ee90cea718 | ||
|
|
1f2469b711 | ||
|
|
533f69244b | ||
|
|
fbcf5ad7f7 | ||
|
|
104cdfbcb1 | ||
|
|
574e427b0a | ||
|
|
48b445211b | ||
|
|
a54e37c808 | ||
|
|
48ad080ada | ||
|
|
210e4f124e | ||
|
|
e7da1be6ba | ||
|
|
d10aa60358 | ||
|
|
374d2c9d39 | ||
|
|
b82d641783 | ||
|
|
3a41fba148 |
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
26
@xen-orchestra/lite/src/route-map.d.ts
vendored
26
@xen-orchestra/lite/src/route-map.d.ts
vendored
@@ -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'
|
||||
|
||||
@@ -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" />
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import V2vLaunchGroup from '@/modules/v2v/components/review/V2vLaunchGroup.vue'
|
||||
import { findCardTitleLabel } from '@/test/find-card-title.ts'
|
||||
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
|
||||
import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const HEADING = { label: 'Excluded', accent: 'danger' } as const
|
||||
|
||||
function mountGroup(count: number) {
|
||||
return mount(V2vLaunchGroup, {
|
||||
props: { heading: HEADING, count },
|
||||
slots: { default: '<p class="vms">mail-01</p>' },
|
||||
global: createGlobalTestConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
it('titles the group with its label', () => {
|
||||
expect(findCardTitleLabel(mountGroup(2))).toContain(HEADING.label)
|
||||
})
|
||||
|
||||
it('counts its VMs with the accent of the group', () => {
|
||||
expect(mountGroup(2).getComponent(UiCounter).props()).toMatchObject({ value: 2, accent: 'danger' })
|
||||
})
|
||||
|
||||
it('shows its VMs', () => {
|
||||
expect(mountGroup(2).get('.vms').text()).toBe('mail-01')
|
||||
})
|
||||
|
||||
it('is hidden without VM', () => {
|
||||
expect(mountGroup(0).findComponent(UiCard).exists()).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<UiCard v-if="count > 0">
|
||||
<UiCardTitle>
|
||||
{{ heading.label }}
|
||||
<UiCounter :value="count" :accent="heading.accent" size="medium" variant="primary" />
|
||||
</UiCardTitle>
|
||||
<slot />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { V2vAccentedLabel } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
|
||||
import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
|
||||
import UiCounter, { type CounterAccent } from '@core/components/ui/counter/UiCounter.vue'
|
||||
|
||||
defineProps<{
|
||||
heading: V2vAccentedLabel<CounterAccent>
|
||||
count: number
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
default(): any
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
|
||||
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
function mountVm() {
|
||||
return mount(V2vLaunchGroupVm, {
|
||||
slots: { name: 'web-01', default: '<span class="details">Delta from last sync</span>' },
|
||||
global: createGlobalTestConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
it('names the VM, then tells its details', () => {
|
||||
expect(mountVm().text()).toBe('web-01Delta from last sync')
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="v2v-launch-group-vm typo-body-regular-small">
|
||||
<span class="typo-body-bold-small"><slot name="name" /></span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineSlots<{
|
||||
name(): any
|
||||
default(): any
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.v2v-launch-group-vm {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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]])
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -0,0 +1,76 @@
|
||||
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
|
||||
import V2vMigratedVmItem from '@/modules/v2v/components/review/V2vMigratedVmItem.vue'
|
||||
import type { V2vMigratedVm } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
|
||||
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
|
||||
import { createV2vMigratedVm } from '@/test/create-v2v-launch.ts'
|
||||
import { getCheckbox } from '@/test/get-checkbox.ts'
|
||||
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
|
||||
import { t } from '@/test/i18n.ts'
|
||||
import UiLink from '@core/components/ui/link/UiLink.vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const DESTINATION_POOL_NAME = 'diskor'
|
||||
|
||||
function mountItem(vm: V2vMigratedVm = createV2vMigratedVm()) {
|
||||
return mount(V2vMigratedVmItem, {
|
||||
props: { vm, destinationPoolName: DESTINATION_POOL_NAME },
|
||||
global: createGlobalTestConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
function getDetails(vm: V2vMigratedVm) {
|
||||
return t('v2v:migrated-vm-details', {
|
||||
vm: vm.importVm.name,
|
||||
pool: vm.poolName,
|
||||
srs: vm.srs,
|
||||
destinationPool: DESTINATION_POOL_NAME,
|
||||
})
|
||||
}
|
||||
|
||||
function getSecondCopyLabel(vm: V2vMigratedVm) {
|
||||
return t('v2v:create-second-copy', { name: vm.name })
|
||||
}
|
||||
|
||||
// The VM name, then what the sentence tells of it
|
||||
function findVmText(wrapper: ReturnType<typeof mountItem>) {
|
||||
return wrapper.getComponent(V2vLaunchGroupVm).text()
|
||||
}
|
||||
|
||||
it('names the VM, then tells when it was cut over, where it is and that a second VM will be created', () => {
|
||||
const vm = createV2vMigratedVm()
|
||||
|
||||
expect(findVmText(mountItem(vm))).toBe(`${vm.name}${vm.cutover}${getDetails(vm)}`)
|
||||
})
|
||||
|
||||
it('tells where the VM is without its cutover date once unknown', () => {
|
||||
const vm = createV2vMigratedVm({ cutover: undefined })
|
||||
|
||||
expect(findVmText(mountItem(vm))).toBe(`${vm.name}${getDetails(vm)}`)
|
||||
})
|
||||
|
||||
it('links to the dashboard of the existing VM', () => {
|
||||
const vm = createV2vMigratedVm()
|
||||
|
||||
expect(mountItem(vm).getComponent(UiLink).props('to')).toEqual({
|
||||
name: '/vm/[id]/dashboard',
|
||||
params: { id: vm.importVm.id },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('offers a second copy of the VM, confirmed: %s', isConfirmed => {
|
||||
const vm = createV2vMigratedVm({ isConfirmed })
|
||||
|
||||
expect(mountItem(vm).getComponent(FormCheckbox).props()).toMatchObject({
|
||||
label: getSecondCopyLabel(vm),
|
||||
modelValue: isConfirmed,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])('confirms the second copy once set to %s', async isConfirmed => {
|
||||
const vm = createV2vMigratedVm({ isConfirmed: !isConfirmed })
|
||||
const wrapper = mountItem(vm)
|
||||
|
||||
await getCheckbox(wrapper, getSecondCopyLabel(vm)).setValue(isConfirmed)
|
||||
|
||||
expect(wrapper.emitted('confirm')).toEqual([[isConfirmed]])
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="v2v-migrated-vm-item">
|
||||
<V2vLaunchGroupVm>
|
||||
<template #name>{{ vm.name }}</template>
|
||||
<span v-if="vm.cutover !== undefined">{{ vm.cutover }}</span>
|
||||
<I18nT keypath="v2v:migrated-vm-details" scope="global" tag="span">
|
||||
<template #vm>
|
||||
<UiLink size="small" display-inline :to="{ name: '/vm/[id]/dashboard', params: { id: vm.importVm.id } }">
|
||||
{{ vm.importVm.name }}
|
||||
</UiLink>
|
||||
</template>
|
||||
<template #pool>{{ vm.poolName }}</template>
|
||||
<template #srs>{{ vm.srs }}</template>
|
||||
<template #destinationPool>{{ destinationPoolName }}</template>
|
||||
</I18nT>
|
||||
</V2vLaunchGroupVm>
|
||||
<FormCheckbox
|
||||
:label="t('v2v:create-second-copy', { name: vm.name })"
|
||||
:model-value="vm.isConfirmed"
|
||||
@update:model-value="value => emit('confirm', value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import V2vLaunchGroupVm from '@/modules/v2v/components/review/V2vLaunchGroupVm.vue'
|
||||
import type { V2vMigratedVm } from '@/modules/v2v/composables/use-v2v-review-step.composable.ts'
|
||||
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
|
||||
import UiLink from '@core/components/ui/link/UiLink.vue'
|
||||
import { I18nT, useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
vm: V2vMigratedVm
|
||||
destinationPoolName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [isConfirmed: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.v2v-migrated-vm-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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')
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')])
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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!'))
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
51
@xen-orchestra/web/src/modules/v2v/jobs/xo-v2v-start.job.ts
Normal file
51
@xen-orchestra/web/src/modules/v2v/jobs/xo-v2v-start.job.ts
Normal 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'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
30
@xen-orchestra/web/src/modules/v2v/types/v2v-launch.type.ts
Normal file
30
@xen-orchestra/web/src/modules/v2v/types/v2v-launch.type.ts
Normal 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
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type V2vVmRow = {
|
||||
supported: boolean
|
||||
unsupportedReason?: V2vUnsupportedReason
|
||||
lastSyncAt?: number
|
||||
cutoverAt?: number
|
||||
isInList: boolean
|
||||
importVm?: FrontXoVm
|
||||
vm: XoV2vVm
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,7 @@ export function getV2vOverride<TValue>(value: TValue | undefined, defaultValue:
|
||||
}
|
||||
|
||||
export function setV2vOverride<TKey extends string, TValue>(
|
||||
overrides: Record<TKey, TValue> | undefined,
|
||||
overrides: Partial<Record<TKey, TValue>> | undefined,
|
||||
key: TKey,
|
||||
value: TValue | undefined,
|
||||
defaultValue: TValue
|
||||
@@ -66,9 +66,19 @@ export function setV2vOverride<TKey extends string, TValue>(
|
||||
return isEmpty(nextOverrides) ? undefined : nextOverrides
|
||||
}
|
||||
|
||||
type V2vTargetNameGetter<TId extends string> = (id: TId) => string | undefined
|
||||
|
||||
// A target gone from its collection or from the pool offers, or still loading, is named by its id rather than not at all
|
||||
export function getV2vTargetName<TId extends string>(id: TId, getName: (id: TId) => string | undefined): string {
|
||||
return getName(id) ?? id
|
||||
export function getV2vTargetName<TId extends string>(id: TId, getName: V2vTargetNameGetter<TId>): string
|
||||
export function getV2vTargetName<TId extends string>(
|
||||
id: TId | undefined,
|
||||
getName: V2vTargetNameGetter<TId>
|
||||
): string | undefined
|
||||
export function getV2vTargetName<TId extends string>(
|
||||
id: TId | undefined,
|
||||
getName: V2vTargetNameGetter<TId>
|
||||
): string | undefined {
|
||||
return id === undefined ? undefined : (getName(id) ?? id)
|
||||
}
|
||||
|
||||
export function getV2vTargetOptionLabel<TId extends string>(options: V2vTargetOption<TId>[], id: TId): string {
|
||||
|
||||
@@ -177,6 +177,10 @@ describe('getV2vTargetName', () => {
|
||||
it('names a target without a name found, gone or still loading, by its id', () => {
|
||||
expect(getV2vTargetName(V2V_SSD_SR.id, () => undefined)).toBe(V2V_SSD_SR.id)
|
||||
})
|
||||
|
||||
it('has no name without target', () => {
|
||||
expect(getV2vTargetName(undefined, () => V2V_SSD_SR.name_label)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getV2vTargetOptionLabel', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user