Compare commits

...

63 Commits

Author SHA1 Message Date
Olivier Floch
1667ed2a16 test(web): seed every V2V list pool and list VMs through the shared helpers
- seedV2vListPool takes partial offers and the other pool, and sets the collections itself: seedChoices is gone
- the wizard, pool name and source-and-destination tests seed the list pool through it
- createV2vMigrationListVmsOf builds the list VMs of given ids, replacing the hand-written ones; V2vMappingStep reads the stored VM through readV2vStoredListVm
2026-11-09 12:43:34 +01:00
Olivier Floch
55cf10d8bf test(web): seed the pool of the V2V list through one helper
- `seedV2vListPool` seeds the pool of the list and its offers, in place of
  the same two lines in the VMs step, selected VMs and mapping step tests
- the resumed VM is imported to the pool of the list: `V2V_RESUMED_VM_POOL`
  is gone
- the network and template of the other pool get their own ids, apart from
  the list defaults
2026-11-09 12:22:53 +01:00
Olivier Floch
7ec1e941a5 test(web): use the shared list pool in the V2V pool name test 2026-11-09 11:57:47 +01:00
Olivier Floch
f8dc85855b test(web): use the shared list pool in the V2V VM mapping mount 2026-11-09 11:56:46 +01:00
Olivier Floch
8ae1f4e811 test(web): use the shared id of another pool in the V2V VM mapping mount 2026-11-09 11:02:57 +01:00
Olivier Floch
1c1960a09c fix(web-core): add the zero form of the French V2V snapshot check 2026-11-09 11:02:57 +01:00
Olivier Floch
892fff90ab refactor(web): read the V2V connection from the wizard context in the mapping step 2026-11-09 11:02:57 +01:00
Olivier Floch
d4c8b7aef4 test(web): share the useOverlay mock
- mock-overlay.ts records each useOverlay call, in order, as its options and the open spy it returned
- used by the backup repository, plugin drawer job and V2V settings tests, in place of 7 hand-written mocks
2026-11-09 11:02:57 +01:00
Olivier Floch
11b3302b62 test(web): share the V2V mapping test fixtures
- createV2vOfferedTarget: a resolved target among the pool offers
- V2V_LIST_VM_OVERRIDES, V2V_FULLY_OVERRIDDEN_LIST_VM, V2V_OTHER_LIST_VM: the list VMs the mapping tests start from
- V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM: one mapping step VM of each kind
- describeV2vMappingCardLayout, countDividers: the layout the side panel mapping cards share
- mountV2vVmKindActions, findV2vRouteQuery
- the drawer cancel cases, the table menu order and the panel kind switch no longer re-test what another test covers
2026-11-09 11:02:57 +01:00
Olivier Floch
587d273e67 fix(web): type the pool connection success server id as FrontXoServer
XoServer['id'] is branded: Vue resolves it to a runtime Object check and warns on the string id.
2026-11-09 11:02:56 +01:00
Olivier Floch
3dd6126425 fix(web): give the V2V VM heading card id a string prop type
XoV2vVm['id'] is branded: Vue resolves it to a runtime Object check and warns on every panel render.
2026-11-09 11:02:56 +01:00
Olivier Floch
92496e7a19 refactor(web-core): remove the unused selected state of UiPanelCard
The V2V mapping step no longer selects a panel card since it shows its VMs as a table.
2026-11-09 11:02:56 +01:00
Olivier Floch
d6e7324892 feat(web): show the V2V mapping step as a table 2026-11-09 11:02:56 +01:00
Olivier Floch
13986f2828 feat(web): add the V2V VM side panel 2026-11-09 11:02:41 +01:00
Olivier Floch
f7cad58fa3 feat(web): add the V2V VM settings drawer
Edit settings opens a drawer holding the per-VM settings of a new VM on a
draft: SR per disk, network per NIC, keep MAC, template and tags. Save
replaces the stored overrides of the VM once, Cancel writes nothing.
The options card becomes V2vVmOptionsFields, so the drawer reuses them.
2026-11-09 11:02:41 +01:00
Olivier Floch
d1e023bdd4 refactor(web): add an edit draft to the V2V VM mapping
The VM mapping reads its overrides from an injectable source, so that
useV2vVmMappingDraft edits a copy of them and hands back the changed
overrides, without writing the stored list.
2026-11-09 11:02:41 +01:00
Olivier Floch
710cc1915e docs(changelog): add the V2V migration wizard mapping step 2026-11-09 11:02:41 +01:00
Olivier Floch
873644d9e0 fix(web): let the V2V source step cards shrink on a small screen 2026-11-09 11:02:41 +01:00
Olivier Floch
35082b5d9e feat(web): add the mapping step of the V2V migration wizard 2026-11-09 11:02:41 +01:00
Olivier Floch
a8ab6a1f99 feat(web): add the V2V resumed VM mapping 2026-11-09 10:59:59 +01:00
Olivier Floch
fb1da9305e refactor(web): extract the VM delete flow into useVmDelete 2026-11-09 10:59:55 +01:00
Olivier Floch
04d86aca15 test(web): share the useXoRoutes mock 2026-11-09 10:59:49 +01:00
Olivier Floch
73207050a0 feat(web): add the V2V new VM mapping 2026-11-09 10:59:49 +01:00
Olivier Floch
a61182a466 feat(web): store the per-VM overrides of the V2V migration list 2026-11-09 10:59:36 +01:00
Olivier Floch
6866393ba8 feat(web): add V2V mapping, checks and active mapping utils
- per-VM overrides in the migration list (disks, NICs, template, keep MAC,
  tags), parsed with their shape
- one mapping resolver: each target from its override or the list default,
  offered or not, and the mapping complete or incomplete
- `mapping-incomplete` check of a new VM, after `partial-on-other-pool`
- mapping summary from the resolved mapping, with distinct target names
- source VM checks (unsupported disks, snapshots), next transfer (delta or
  full), datacenter of the VM
- active mapping of a resumed VM: disk to VDI by its `esxi_diskPath` in the
  disk chain, NIC to VIF by device, MAC kept or not
2026-11-09 10:59:16 +01:00
Olivier Floch
d1358444e7 feat(web-core): add a selected state to UiPanelCard 2026-11-09 10:57:55 +01:00
Olivier Floch
75e990343a feat(web-core): add VtsTagsInput component 2026-11-09 10:57:55 +01:00
Olivier Floch
8d25cb6b94 feat(types): add the vmdk chain paths of a V2V source disk 2026-11-09 10:57:55 +01:00
Olivier Floch
c099fabb2b test(web): read the first two V2V list VMs from the stored list fixture 2026-11-09 10:51:50 +01:00
Olivier Floch
b20630c512 test(web): read the V2V listed VM, empty and stored lists from the shared fixtures 2026-11-09 10:27:15 +01:00
Olivier Floch
e63e6a3810 test(web): read the V2V other pool and empty list from the shared fixtures 2026-11-09 10:15:03 +01:00
Olivier Floch
8d69b4095e test(web): read the V2V list pool and pair from the shared fixtures 2026-11-09 09:57:02 +01:00
Olivier Floch
43eefcf01f fix(web): drop the unstyled root class of the inline parts 2026-11-09 09:57:02 +01:00
Olivier Floch
67ea6b5a53 test(web): use the shared id of another pool in the V2V VMs step tests 2026-11-09 09:57:02 +01:00
Olivier Floch
c1c2673441 refactor(web): name the V2V selected VMs test pool id after the id it holds 2026-11-09 09:57:02 +01:00
Olivier Floch
23ffab2373 refactor(web): name the V2V list summary after its source and pool pair, not a lock 2026-11-09 09:57:02 +01:00
Olivier Floch
2a4c19d281 fix(web-core): drop the V2V empty list translation no screen uses any more 2026-11-09 09:57:02 +01:00
Olivier Floch
ba7059af89 docs(web): drop the removed mountComposable render option from the testing guide 2026-11-09 09:57:02 +01:00
Olivier Floch
a51c206614 fix(web): say that another V2V source or pool opens its own list
The selected VMs panel claimed the source and destination were locked
until the list was cleared, while each (source, pool) pair has its own
list and choosing another one just opens it.
2026-11-09 09:57:02 +01:00
Olivier Floch
3532de374d test(web): keep the fetch stub of the test setup under its comment 2026-11-09 09:57:02 +01:00
Olivier Floch
f27fdb5c96 style(web): drop the V2V comments that only say what the code does 2026-11-09 09:57:02 +01:00
Olivier Floch
79d303db55 fix(web): open and close the V2V selected VMs panel like the other side panels
Its VMs act as the selection of the side panel: it opens with them and
closes when the list is emptied, without forcing it closed on small screens.
2026-11-09 09:57:02 +01:00
Olivier Floch
e70963dc74 fix(web-core): show the persistent side panel close button wherever closing collapses it
On a large screen with the panel unpinned, a persistent panel could not be
closed: closing collapses it there as on small screens.
2026-11-09 09:57:02 +01:00
Olivier Floch
a654570dd1 test(web): share the V2V datastore filters and the VMs step query 2026-11-09 09:57:02 +01:00
Olivier Floch
38522688ca fix(web): name the root of the V2V selected VMs panel and item after the component 2026-11-09 09:57:02 +01:00
Olivier Floch
b5a6a0f2b2 fix(web-core): add the zero form of the French V2V VM step counts 2026-11-09 09:57:02 +01:00
Olivier Floch
9f10b3f942 refactor(web): read the V2V connection from the wizard context in the VMs step 2026-11-09 09:57:02 +01:00
Olivier Floch
8c14ebbb0c fix(web): leave a V2V VM not found in the source without a kind tag
A VM gone from the source is neither new nor resumed: its check alone tells it.
2026-11-09 09:57:02 +01:00
Olivier Floch
6838e0b536 test(web): read the plugin header tags with the shared findAccentedTags 2026-11-09 09:57:02 +01:00
Olivier Floch
4d671f71cd test(web): clear the local storage before every test
happy-dom keeps the local storage across the tests of a file: the global
test setup now clears it, instead of each V2V test file doing it again.
2026-11-09 09:57:02 +01:00
Olivier Floch
dc43545389 feat(web): add the VMs step of the V2V migration wizard 2026-11-09 09:57:02 +01:00
Olivier Floch
30a21d98a4 feat(web-core): add a persistent side panel, closable on small screens only
A panel whose content is not a selection has nothing to clear: its close
button only shows on small screens, where it leaves the full-screen panel.
2026-11-09 09:57:02 +01:00
Olivier Floch
bacd60115c feat(web): add the V2V selected VMs panel 2026-11-09 09:57:02 +01:00
Olivier Floch
a27f0cb5ed feat(web): add the V2V VM selection table 2026-11-09 09:57:02 +01:00
Olivier Floch
60272ed6cf feat(web): add the V2V VM query builder, presets and datastore filter 2026-11-09 09:57:02 +01:00
Olivier Floch
745b6a7012 feat(web): add, remove and clear VMs of the V2V migration list 2026-11-09 09:56:56 +01:00
Olivier Floch
8583fcdd32 feat(web): add V2V VM status, checks and rows utils 2026-11-09 09:56:56 +01:00
Olivier Floch
2a4e45f4dc feat(web-core): add a disabled state to the table checkbox column 2026-11-09 09:19:28 +01:00
Olivier Floch
0d7fba2b6a refactor(web): require the VMs of a built V2V migration list 2026-11-09 09:14:10 +01:00
Olivier Floch
9efc4df5e6 refactor(web): share the writable SR of a pool check between the V2V destination, VDI and VM migrate forms 2026-11-09 09:13:22 +01:00
Olivier Floch
cdda6f6e2a test(web): share the id of another pool between the tests 2026-11-09 09:12:36 +01:00
Olivier Floch
d85415f28e test(web): cover the V2V pool option slot of the destination pool card and name the id-only test params after the id 2026-11-09 08:56:27 +01:00
Olivier Floch
890a8aedae test(web): share the V2V pool option mount and the stored list with VMs of the destination step test 2026-11-09 08:44:27 +01:00
262 changed files with 14708 additions and 563 deletions

View File

@@ -32,6 +32,14 @@ export type V2V_VM_OTHER_CONFIG = (typeof V2V_VM_OTHER_CONFIG)[keyof typeof V2V_
export type XoV2vVmOtherConfig = Partial<Record<V2V_VM_OTHER_CONFIG, string>>
// The disk of the source chain an imported VDI last received, one of the `chainPaths` of its source disk: the CID
// cannot tell a fresh snapshot delta from its parent, the path can
export const V2V_VDI_OTHER_CONFIG = {
DISK_PATH: 'esxi_diskPath',
} as const
export type V2V_VDI_OTHER_CONFIG = (typeof V2V_VDI_OTHER_CONFIG)[keyof typeof V2V_VDI_OTHER_CONFIG]
export const V2V_START_BLOCKED_REASON = 'Esxi migration in progress...'
export const V2V_PARTIAL_IMPORT_TAG = 'xo:no-bak=V2V partial import'
@@ -120,6 +128,9 @@ export type XoV2vDisk = {
mode: V2V_DISK_MODE
isRdm: boolean
isEncrypted: boolean
// `dir/disk.vmdk` in its datastore (no `[datastore]` prefix) of the base disk, then of each snapshot delta down to
// the active disk: the chain the import walks for this vmx node
chainPaths: string[]
}
// No port group for opaque (NSX) or missing backings

View File

@@ -527,6 +527,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/tags-input/vts-tags-input': RouteRecordInfo<
'/story/web-core/tags-input/vts-tags-input',
'/story/web-core/tags-input/vts-tags-input',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/tree/vts-tree-item': RouteRecordInfo<
'/story/web-core/tree/vts-tree-item',
'/story/web-core/tree/vts-tree-item',
@@ -1572,6 +1579,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/tags-input/vts-tags-input.story.vue': {
routes:
| '/story/web-core/tags-input/vts-tags-input'
views:
| never
}
'src/stories/web-core/tree/vts-tree-item.story.vue': {
routes:
| '/story/web-core/tree/vts-tree-item'

View File

@@ -0,0 +1,14 @@
# Usage
Enter or comma adds the typed value: it is trimmed, and an empty value or a value already in the list is ignored. A
pasted comma-separated draft adds each of its values. Enter while an IME composition is in progress adds nothing.
Removing a chip updates the model with the new array.
The label comes from the surrounding `VtsInputWrapper`.
```vue-template
<VtsInputWrapper label="Values">
<VtsTagsInput v-model="values" accent="brand" placeholder="Add a value" />
</VtsInputWrapper>
```

View File

@@ -0,0 +1,21 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model().type('string[]').preset(['alpha', 'beta']).help('Enter or comma adds the typed value'),
prop('accent').enum('brand', 'warning', 'danger').required().preset('brand').widget(),
prop('placeholder').str().preset('Add a value').widget(),
]"
>
<VtsInputWrapper label="Values">
<VtsTagsInput v-bind="properties" />
</VtsInputWrapper>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { model, prop } from '@/libs/story/story-param.ts'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsTagsInput from '@core/components/tags-input/VtsTagsInput.vue'
</script>

View File

@@ -2,7 +2,7 @@
<template>
<div class="vts-side-panel-sticky" :class="{ mobile: uiStore.isSmall }">
<VtsPanel
:closable="hasSelection"
:closable="isClosable"
:close-icon="uiStore.isSmall ? 'fa:angle-left' : 'action:close-cancel-clear'"
class="vts-side-panel"
:class="{
@@ -47,11 +47,12 @@ import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { hasSelection } = defineProps<{
const { hasSelection, persistent } = defineProps<{
hasSelection?: boolean
persistent?: boolean
}>()
const emit = defineEmits<{
@@ -70,6 +71,9 @@ const uiStore = useUiStore()
const { t } = useI18n()
// A persistent panel holds no selection to clear: it only closes where closing collapses it
const isClosable = computed(() => hasSelection && (!persistent || panelStore.syncsOpenStateWithSelection))
watch(
[() => hasSelection, () => panelStore.isLocked, () => uiStore.isSmall],
([hasSelection]) => {

View File

@@ -0,0 +1,72 @@
<template>
<div class="vts-tags-input">
<UiInput v-model="draft" :accent :placeholder @keydown="(event: KeyboardEvent) => handleKeydown(event)" />
<div v-if="tags.length > 0" class="chips">
<UiChip v-for="(tag, index) of tags" :key="index" accent="info" @remove="removeTag(index)">
{{ tag }}
</UiChip>
</div>
</div>
</template>
<script lang="ts" setup>
import UiChip from '@core/components/ui/chip/UiChip.vue'
import UiInput, { type InputAccent } from '@core/components/ui/input/UiInput.vue'
import { ref } from 'vue'
const { accent, placeholder } = defineProps<{
accent: InputAccent
placeholder?: string
}>()
const tags = defineModel<string[]>({ required: true })
const draft = ref('')
const TAG_SEPARATOR_KEYS = ['Enter', ',']
// A pasted draft may hold several comma-separated tags
function addDraftTags() {
const draftTags = new Set(draft.value.split(',').map(tag => tag.trim()))
const newTags = [...draftTags].filter(tag => tag !== '' && !tags.value.includes(tag))
draft.value = ''
if (newTags.length === 0) {
return
}
tags.value = [...tags.value, ...newTags]
}
function handleKeydown(event: KeyboardEvent) {
// Enter also validates an IME composition, which must not add a half-typed tag
const isSeparator = !event.isComposing && TAG_SEPARATOR_KEYS.includes(event.key)
if (!isSeparator) {
return
}
// Prevents a form submit on Enter and keeps the comma out of the draft
event.preventDefault()
addDraftTags()
}
function removeTag(index: number) {
tags.value = tags.value.filter((_tag, tagIndex) => tagIndex !== index)
}
</script>
<style lang="postcss" scoped>
.vts-tags-input {
display: flex;
flex-direction: column;
gap: 0.8rem;
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
}
</style>

View File

@@ -30,6 +30,7 @@ The selection is stored by item ID, so it is kept when changing page or filter.
- "Select all" selects every item matching the filter
- "Clear selection" unselects every item, including the ones hidden by the filter
- IDs of items that are no longer in `items` are not part of `selectedIds`
- A row checkbox can be disabled with the optional `disabled` binding of the `checkbox` body cell; the header checkbox and "Select all" still include that row, so keep it out of `items` if it must never be selected
## Usage

View File

@@ -23,6 +23,8 @@
"action:back": "Back",
"action:boot-vm": "Boot VM",
"action:change-state": "Change state",
"action:clear": "Clear",
"action:clear-filter": "Clear filter",
"action:clear-selection": "clear selection",
"action:close": "Close",
"action:configure": "Configure",
@@ -86,7 +88,9 @@
"action:edit-br": "@:action:edit BR",
"action:edit-config": "Edit config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-settings": "@:action:edit @.lower:settings",
"action:edit-traffic-rule": "@:action:edit @.lower:traffic-rule",
"action:edit-vm-settings": "@:action:edit {name} @.lower:settings",
"action:emergency-shutdown": "Emergency shutdown",
"action:emergency-shutdown-host": "@:action:emergency-shutdown host",
"action:enable": "Enable",
@@ -126,6 +130,8 @@
"action:migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"action:migrate-vdi-on-sr": "Migrate on SR",
"action:move-filters-to-parent-group": "Move filters to parent group",
"action:next-mapping": "Next: mapping",
"action:next-review": "Next: review",
"action:open": "Open",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Open console in new tab",
@@ -137,6 +143,9 @@
"action:reboot": "Reboot",
"action:reboot-host": "@:action:reboot host",
"action:reformat": "Reformat",
"action:remove": "Remove",
"action:remove-from-list": "Remove from list",
"action:remove-name-from-list": "Remove {name} from the list",
"action:reset-all-networks": "Reset all networks",
"action:reset-all-srs": "Reset all SRs",
"action:reset-to-default": "Reset to default",
@@ -186,6 +195,8 @@
"action:update": "Update",
"action:use-query-builder": "Use query builder",
"action:visit-pool-dashboard": "Visit pool dashboard",
"active-mapping": "Active mapping",
"adapter": "Adapter",
"add-or": "+OR",
"admin-login": "Admin login",
"admin-password": "Admin password",
@@ -209,6 +220,7 @@
"all-done!": "All done!",
"all-good!": "All good!",
"all-quiet-launchpad": "All quiet on the launchpad",
"all-vms": "All VMs",
"all-srs-shared-storage-migration-optional": "All SRs are shared, storage migration is optional.",
"all-vifs-follow-vm": "All VIFs follow the VM to its new host: within the pool, their networks don't change.",
"allocated-space": "Allocated space",
@@ -217,6 +229,7 @@
"allow-unauthorized": "Allow unauthorized",
"allowed-ips": "Allowed IPs",
"allowed-ips-example": "For example: 192.168.0.1; 2001:db8::1",
"also-on": "Also on",
"and": "And",
"any-property": "Any property",
"api-error-details": "API Error details",
@@ -303,6 +316,7 @@
"character-limit": "{count}/{max} character | {count}/{max} characters",
"check-errors:": "Check out the errors:",
"check-summing": "TX checksumming",
"checks": "Checks",
"click-test-br-speed": "Click to test BR speed",
"click-to-display-alarms:": "Click to display alarms:",
"click-to-return-default-pool": "Click here to return to the default pool",
@@ -374,6 +388,8 @@
"dark-mode:disable": "Disable dark mode",
"dark-mode:enable": "Enable dark mode",
"dashboard": "Dashboard",
"datastore": "Datastore",
"datastores": "Datastores",
"date": "Date",
"default-behavior": "Default behavior",
"default-locking-mode": "Default locking mode",
@@ -381,6 +397,7 @@
"default-storage-repository": "Default storage repository",
"default-template": "Default template",
"deletion-blocked": "Deletion Blocked",
"delta-transfer": "Delta transfer",
"deploy-xoa-available-on-desktop": "XOA deployment is available on your desktop interface",
"deploy-xoa-custom-ntp-servers": "Custom NTP servers (separated by spaces)",
"deploy-xoa-status:configuring": "Configuring XOA…",
@@ -397,6 +414,7 @@
"destination-network-required": "Destination network is required.",
"destination-pool": "Destination pool",
"destination-sr": "Destination SR",
"destination-vm": "Destination VM",
"destination-sr-required": "Destination SR is required.",
"details": "Details",
"device": "Device",
@@ -416,8 +434,10 @@
"disconnected-from-physical-device": "Disconnected from physical device",
"disconnected-pbd-number": "Disconnected PBD #{n}",
"disconnecting": "Disconnecting",
"disk": "Disk",
"disk-name": "Disk name",
"disk-space": "Disk space",
"disks": "Disks",
"dns": "DNS",
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"do-you-really-want-to-leave-this-page?": "Some changes are still being deployed. Do you really want to leave this page?",
@@ -491,10 +511,12 @@
"from-vm": "From VM",
"full": "Full",
"full-copy": "Full copy",
"full-transfer": "Full transfer",
"fullscreen": "Fullscreen",
"gateway": "Gateway",
"general": "General",
"general-information": "General information",
"global-default": "Global default",
"global-defaults-for-name": "Global defaults for {name}",
"gpu": "GPU",
"gpus": "GPUs",
@@ -534,8 +556,10 @@
"https": "HTTPS",
"hyper-threading": "Hyper threading (SMT)",
"id": "ID",
"import-in-progress": "Import in progress",
"in": "in",
"in-last-three-runs": "In their last 3 runs",
"in-list": "In list",
"in-progress": "In progress",
"included": "Included",
"incremental": "Incremental",
@@ -779,10 +803,12 @@
"json-schema:numbered-item": "{label} {n}",
"json-schema:show-examples-n": "Show example ({n}) | Show examples ({n})",
"json-schema:unsupported-field": "The \"{label}\" field cannot be edited from this interface",
"keep-mac-addresses": "Keep MAC addresses",
"keep-me-logged": "Keep me logged in",
"keep-page-open": "Do not refresh or quit tab before end of deployment.",
"key": "Key",
"keywords": "Keywords",
"label-with-count": "{label} {count}",
"language": "Language",
"language-preferences": "Language preferences",
"last": "Last",
@@ -793,6 +819,7 @@
"last-revert": "Last revert",
"last-run-number": "Last run #{n}",
"last-snapshot": "Last snapshot",
"last-sync": "Last sync",
"last-week": "Last week",
"ldap-auth-providers": "LDAP/Auth providers",
"learn-more": "Learn more",
@@ -807,6 +834,7 @@
"loaded": "Loaded",
"loading": "Loading",
"local": "Local",
"location": "Location",
"locked": "Locked",
"locking-mode": "Locking mode",
"locking-mode-default": "Default locking mode",
@@ -835,6 +863,7 @@
"merge-backups-synchronously": "Merge backups synchronously",
"message": "Message",
"migrate-vms": "Migrate VMs",
"migration": "Migration",
"migration-blocked": "Migration Blocked",
"migration-compression": "Migration compression",
"migration-network": "Migration network",
@@ -895,21 +924,27 @@
"multi-pathing": "Multi-pathing",
"multiple-schedules": "Multiple schedules",
"multiple-schedules-advice": "We advise splitting a backup job with multiple schedules into multiple backup jobs with one schedule each.",
"n-blocking": "1 blocking | {n} blocking",
"n-bonded-networks": "1 bonded network | {n} bonded networks",
"n-brs": "1 BR | {n} BRs",
"n-disks": "1 disk | {n} disks",
"n-gb": "{n} GB",
"n-gb-left": "{n} GB left",
"n-gb-required": "{n} GB required",
"n-hexadecimal-characters": "{n} hexadecimal characters.",
"n-hosts": "1 host | {n} hosts",
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
"n-in-progress": "1 in progress | {n} in progress",
"n-internal-networks": "1 internal network | {n} internal networks",
"n-migrated": "1 migrated | {n} migrated",
"n-missing": "{n} missing",
"n-networks": "1 network | {n} networks",
"n-nics": "1 NIC | {n} NICs",
"n-not-migrated": "1 not migrated | {n} not migrated",
"n-partially-migrated": "1 partially migrated | {n} partially migrated",
"n-percent": "{n}%",
"n-pifs": "1 PIF | {n} PIFs",
"n-ready": "1 ready | {n} ready",
"n-snapshots": "1 snapshot | {n} snapshots",
"n-srs": "1 SR | {n} SRs",
"n-subtasks": "1 subtask | {n} subtasks",
@@ -919,6 +954,7 @@
"n-vdis": "1 VDI | {n} VDIs",
"n-vifs": "1 VIF | {n} VIFs",
"n-vms": "1 VM | {n} VMs",
"n-warnings": "1 warning | {n} warnings",
"name": "Name",
"name-required": "Name is required.",
"native": "Native",
@@ -929,6 +965,7 @@
"nested-virtualization": "Nested virtualization",
"netmask": "Netmask",
"network": "Network",
"network-adapters": "Network adapters",
"network-block-device": "Network block device",
"network-config": "Network config",
"network-create:warning:mtu-out-of-range": "MTU should be between {min} and {max}",
@@ -1030,6 +1067,8 @@
"normal": "Normal",
"not-found": "Not found",
"not-included": "Not included",
"not-migrated": "Not migrated",
"not-ready": "Not ready",
"not-resident-on": "Not resident on",
"not-running": "Not running",
"not-yet-available": "Information is not yet available",
@@ -1071,6 +1110,7 @@
"other": "Other",
"other-properties": "Other properties",
"ova": "OVA",
"overridden": "Overridden",
"page-not-found": "This page is not to be found…",
"page-please-wait": "Hold tight, astronaut! The page is getting ready to take off.{newline}We're just aligning the thrusters and calibrating the systems. It won't be long now, stay tuned!",
"pagination:all": "@:all",
@@ -1122,6 +1162,7 @@
"pool-forget-confirm-info": "This will permanently remove this host from XO. To reconnect, you will need to add it again.",
"pool-forget-confirm-title": "You are about to remove {name} host",
"pool-management": "Pool management",
"pool-named": "Pool {name}",
"pool-ram-usage": "Pool RAM Usage",
"pool-required": "Pool is required.",
"pool:status:connected": "@:status:connected",
@@ -1137,12 +1178,14 @@
"popup-first-connection-introduction": "We’re excited to introduce the first official release of Xen Orchestra 6.",
"popup-first-connection-upcoming-release": "In the upcoming releases, we’ll be adding actions on all objects, user management, backup features, and much more. We hope you’ll enjoy exploring this brand-new interface!",
"port": "Port",
"port-group": "Port group",
"port-invalid": "Port format is invalid.",
"port-required": "Port is required.",
"power-on-host-for-console": "Power on your host to access its console",
"power-on-mode": "Power on mode",
"power-on-vm-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"power-state-on-host": "{power} on {host}",
"pro-support": "{name} pro support",
"professional-support": "Professional support",
"progress": "Progress",
@@ -1196,6 +1239,7 @@
"read": "Read",
"read-only": "Read-only",
"reading-speed": "Reading speed",
"ready": "Ready",
"reboot-blocked": "Reboot Blocked",
"reboot-vm-internal-shutdown": "Reboot VM on internal shutdown",
"receive": "Receive",
@@ -1224,6 +1268,7 @@
"resources": "Resources",
"resources-overview": "Resources overview",
"rest-api": "REST API",
"resume": "Resume",
"retry": "Retry",
"review": "Review",
"review-and-confirm": "Review & confirm",
@@ -1250,7 +1295,9 @@
"select-pool": "Select a pool",
"select-preset": "Select a preset",
"select-to-see-details": "Click on {icon} to see details",
"selected-vms": "Selected VMs",
"selected-vms-in-execution": "Some selected VMs are running",
"selected-vms-n": "Selected VMs ({n})",
"self-signed-certificates": "Self-signed certificates",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
@@ -1261,6 +1308,7 @@
"size": "Size",
"size-and-speed": "@:size & @.lower:speed",
"size-on-disk": "Size on disk",
"size-on-this-datastore": "Size on this datastore",
"skip-ssl-check": "Skip SSL check (self-signed certificate)",
"skip-ssl-check-info": "The server certificate is not verified. Use it for self-signed certificates.",
"skipped": "Skipped",
@@ -1345,6 +1393,7 @@
"success": "Success",
"summary": "Summary",
"support": "Support",
"supported": "Supported",
"supported-image-formats": "Supported image formats",
"suspend-blocked": "Suspend Blocked",
"suspend-storage-repository": "Suspend storage repository",
@@ -1360,6 +1409,8 @@
"table-selection:watch-out!": "Watch out!",
"tags": "Tags",
"target": "Target",
"target-network": "Target network",
"target-sr": "Target SR",
"task": "Task",
"task-type": "Task type",
"task:ended": "@:ended",
@@ -1448,6 +1499,7 @@
"unnamed": "Unnamed",
"unreachable-hosts": "Unreachable hosts",
"unreachable-hosts-reload-page": "Done, reload the page",
"unsupported": "Unsupported",
"untitled": "Untitled",
"untranslated-text-helper": "By default, untranslated texts will be presented in english.",
"up-to-date": "Up-to-date",
@@ -1466,17 +1518,47 @@
"users": "Users",
"uuid": "UUID",
"v2v": "V2V",
"v2v:action:add-to-migration-list": "Add to migration list",
"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:connect-new-vmware-source": "Connect a new VMware source",
"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: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: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: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: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",
"v2v:prerequisite-ok": "Prerequisite check: vectura OK",
"v2v:remove-from-list-info": "Only the migration list changes: nothing is deleted on the source nor in XO.",
"v2v:saved-in-this-browser": "Saved in this browser",
"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:status:cold": "Cutover done",
"v2v:status:warm": "Warm synced",
"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:unsupported-encrypted-disk": "Unsupported: encrypted disk",
"v2v:unsupported-independent-disk": "Unsupported: independent disk",
"v2v:unsupported-rdm-disk": "Unsupported: RDM disk",
"value-by-default": "{value} by default",
"value-from": "Value from",
"vcpu": "vCPU",
"vcpu-at-startup": "vCPU at startup",
"vcpus": "vCPUs",
@@ -1525,6 +1607,7 @@
"virtualization-mode": "Virtualization mode",
"virtualization-type": "Virtualization type",
"vlan": "VLAN",
"vlan-id": "VLAN {id}",
"vlan-required": "VLAN is required.",
"vm": "VM",
"vm-backup-failure-number-of-retries": "Number of retries if VM backup fails",

View File

@@ -23,6 +23,8 @@
"action:back": "Retour",
"action:boot-vm": "Démarrer la VM",
"action:change-state": "Changer l'état",
"action:clear": "Vider",
"action:clear-filter": "Effacer le filtre",
"action:clear-selection": "vider la sélection",
"action:close": "Fermer",
"action:configure": "Configurer",
@@ -86,7 +88,9 @@
"action:edit-br": "@:action:edit le BR",
"action:edit-config": "Modifier config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-settings": "@:action:edit les @.lower:settings",
"action:edit-traffic-rule": "@:action:edit la @.lower:traffic-rule",
"action:edit-vm-settings": "@:action:edit les @.lower:settings de {name}",
"action:emergency-shutdown": "Arrêt d'urgence",
"action:emergency-shutdown-host": "@:action:emergency-shutdown de l'hôte",
"action:enable": "Activer",
@@ -126,6 +130,8 @@
"action:migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"action:migrate-vdi-on-sr": "Migrer sur le SR",
"action:move-filters-to-parent-group": "Déplacer les filtres vers le groupe parent",
"action:next-mapping": "Suivant : correspondance",
"action:next-review": "Suivant : récapitulatif",
"action:open": "Ouvrir",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
@@ -137,6 +143,9 @@
"action:reboot": "Redémarrer",
"action:reboot-host": "@:action:reboot l'hôte",
"action:reformat": "Reformater",
"action:remove": "Retirer",
"action:remove-from-list": "Retirer de la liste",
"action:remove-name-from-list": "Retirer {name} de la liste",
"action:reset-all-networks": "Réinitialiser tous les réseaux",
"action:reset-all-srs": "Réinitialiser tous les SRs",
"action:reset-to-default": "Rétablir la valeur par défaut",
@@ -186,6 +195,8 @@
"action:update": "Mettre à jour",
"action:use-query-builder": "Constructeur de requête",
"action:visit-pool-dashboard": "Visiter le tableau de bord du pool",
"active-mapping": "Correspondance active",
"adapter": "Carte",
"add-or": "+OU",
"admin-login": "Nom d'utilisateur administrateur",
"admin-password": "Mot de passe administrateur",
@@ -209,6 +220,7 @@
"all-done!": "Tout est terminé !",
"all-good!": "Tout va bien !",
"all-quiet-launchpad": "Tout est calme sur la rampe de lancement",
"all-vms": "Toutes les VMs",
"all-srs-shared-storage-migration-optional": "Tous les SRs sont partagés, la migration du stockage est facultative.",
"all-vifs-follow-vm": "Toutes les VIFs suivent la VM vers son nouvel hôte : au sein du pool, leurs réseaux ne changent pas.",
"allocated-space": "Espace alloué",
@@ -217,6 +229,7 @@
"allow-unauthorized": "Autoriser les connexions non vérifiées",
"allowed-ips": "IPs autorisées",
"allowed-ips-example": "Par exemple : 192.168.0.1; 2001:db8::1",
"also-on": "Aussi sur",
"and": "Et",
"any-property": "N'importe quelle propriété",
"api-error-details": "Détails de l'erreur API",
@@ -303,6 +316,7 @@
"character-limit": "{count}/{max} caractère | {count}/{max} caractères",
"check-errors:": "Consultez les erreurs :",
"check-summing": "TX checksumming",
"checks": "Vérifications",
"click-test-br-speed": "Cliquez pour tester la vitesse du BR",
"click-to-display-alarms:": "Cliquer pour afficher les alarmes :",
"click-to-return-default-pool": "Cliquer ici pour revenir au pool par défaut",
@@ -374,6 +388,8 @@
"dark-mode:disable": "Désactiver le mode sombre",
"dark-mode:enable": "Activer le mode sombre",
"dashboard": "Tableau de bord",
"datastore": "Banque de données",
"datastores": "Banques de données",
"date": "Date",
"default-behavior": "Comportement par défaut",
"default-locking-mode": "Mode de verrouillage par défaut",
@@ -381,6 +397,7 @@
"default-storage-repository": "Dépôt de stockage par défaut",
"default-template": "Modèle par défaut",
"deletion-blocked": "Suppression Bloquée",
"delta-transfer": "Transfert delta",
"deploy-xoa-available-on-desktop": "Le déploiement de la XOA est disponible sur ordinateur",
"deploy-xoa-custom-ntp-servers": "Serveurs NTP (séparés par des espaces)",
"deploy-xoa-status:configuring": "Configuration de la XOA…",
@@ -397,6 +414,7 @@
"destination-network-required": "Le réseau de destination est requis.",
"destination-pool": "Pool de destination",
"destination-sr": "SR de destination",
"destination-vm": "VM de destination",
"destination-sr-required": "Le SR de destination est requis.",
"details": "Détails",
"device": "Appareil",
@@ -416,8 +434,10 @@
"disconnected-from-physical-device": "Déconnecté de l'appareil physique",
"disconnected-pbd-number": "PBD déconnecté #{n}",
"disconnecting": "Déconnexion",
"disk": "Disque",
"disk-name": "Nom du disque",
"disk-space": "Espace disque",
"disks": "Disques",
"dns": "DNS",
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites-le-nous savoir",
"do-you-really-want-to-leave-this-page?": "Des modifications sont encore en cours de déploiement. Souhaitez-vous vraiment quitter cette page ?",
@@ -491,10 +511,12 @@
"from-vm": "Depuis la VM",
"full": "Complet",
"full-copy": "Copie complète",
"full-transfer": "Transfert complet",
"fullscreen": "Plein écran",
"gateway": "Passerelle",
"general": "Général",
"general-information": "Informations générales",
"global-default": "Défaut global",
"global-defaults-for-name": "Valeurs par défaut globales pour {name}",
"gpu": "GPU",
"gpus": "GPUs",
@@ -534,8 +556,10 @@
"https": "HTTPS",
"hyper-threading": "Hyper-threading (SMT)",
"id": "ID",
"import-in-progress": "Import en cours",
"in": "entrant sur",
"in-last-three-runs": "Dans leurs trois derniers runs",
"in-list": "Dans la liste",
"in-progress": "En cours",
"included": "Inclus",
"incremental": "Incrémental",
@@ -779,10 +803,12 @@
"json-schema:numbered-item": "{label} {n}",
"json-schema:show-examples-n": "Afficher l'exemple ({n}) | Afficher les exemples ({n})",
"json-schema:unsupported-field": "Le champ « {label} » ne peut pas être modifié depuis cette interface",
"keep-mac-addresses": "Conserver les adresses MAC",
"keep-me-logged": "Rester connecté",
"keep-page-open": "Ne pas rafraichir ou quitter cette page avant la fin du déploiement.",
"key": "Clé",
"keywords": "Mots-clés",
"label-with-count": "{label} {count}",
"language": "Langue",
"language-preferences": "Choix de la langue",
"last": "Dernier",
@@ -793,6 +819,7 @@
"last-revert": "Dernière restauration",
"last-run-number": "Dernier run #{n}",
"last-snapshot": "Dernier instantané",
"last-sync": "Dernière synchronisation",
"last-week": "Semaine dernière",
"ldap-auth-providers": "LDAP/Fournisseurs d'authentification",
"learn-more": "En savoir plus",
@@ -807,6 +834,7 @@
"loaded": "Chargé",
"loading": "Chargement",
"local": "Local",
"location": "Emplacement",
"locked": "Verrouillé",
"locking-mode": "Mode de verrouillage",
"locking-mode-default": "Mode de verrouillage par défaut",
@@ -835,6 +863,7 @@
"merge-backups-synchronously": "Fusionner les sauvegardes de manière synchrone",
"message": "Message",
"migrate-vms": "Migrer des VMs",
"migration": "Migration",
"migration-blocked": "Migration bloquée",
"migration-compression": "Compression des migrations",
"migration-network": "Réseau de migration",
@@ -895,21 +924,27 @@
"multi-pathing": "Multi-pathing",
"multiple-schedules": "Plusieurs planifications",
"multiple-schedules-advice": "Nous vous conseillons de diviser un job de sauvegarde à plusieurs planifications en plusieurs jobs ayant chacun une seule planification.",
"n-blocking": "0 bloquante | 1 bloquante | {n} bloquantes",
"n-bonded-networks": "1 réseau lié | {n} réseaux liés",
"n-brs": "1 BR | {n} BRs",
"n-disks": "0 disque | 1 disque | {n} disques",
"n-gb": "{n} Go",
"n-gb-left": "{n} Go libres",
"n-gb-required": "{n} Go requis",
"n-hexadecimal-characters": "{n} caractères hexadécimaux.",
"n-hosts": "0 hôte | 1 hôte | {n} hôtes",
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
"n-in-progress": "0 en cours | 1 en cours | {n} en cours",
"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-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",
"n-partially-migrated": "0 partiellement migrée | 1 partiellement migrée | {n} partiellement migrées",
"n-percent": "{n} %",
"n-pifs": "1 PIF | {n} PIFs",
"n-ready": "0 prête | 1 prête | {n} prêtes",
"n-snapshots": "0 instantané | 1 instantané | {n} instantanés",
"n-srs": "0 SR | 1 SR | {n} SRs",
"n-subtasks": "0 sous-tâche | 1 sous-tâche | {n} sous-tâches",
@@ -919,6 +954,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-warnings": "0 avertissement | 1 avertissement | {n} avertissements",
"name": "Nom",
"name-required": "Le nom est requis.",
"native": "Native",
@@ -929,6 +965,7 @@
"nested-virtualization": "Virtualisation imbriquée",
"netmask": "Masque réseau",
"network": "Réseau",
"network-adapters": "Cartes réseau",
"network-block-device": "Protocole NBD (Network Block Device)",
"network-config": "Configuration réseau",
"network-create:warning:mtu-out-of-range": "Le MTU doit être compris entre {min} et {max}",
@@ -1030,6 +1067,8 @@
"normal": "Normal",
"not-found": "Non trouvé",
"not-included": "Non inclus",
"not-migrated": "Non migrée",
"not-ready": "Non prête",
"not-resident-on": "Non résident sur",
"not-running": "Pas en cours d'exécution",
"not-yet-available": "Information pas encore disponible",
@@ -1071,6 +1110,7 @@
"other": "Autre",
"other-properties": "Autres propriétés",
"ova": "OVA",
"overridden": "Personnalisé",
"page-not-found": "Cette page est introuvable…",
"page-please-wait": "Accrochez-vous, astronaute ! La page est sur le point de décoller.{newline}Nous alignons les propulseurs et calibrons les systèmes. Plus que quelques instants, restez à l'écoute !",
"pagination:all": "Tous",
@@ -1122,6 +1162,7 @@
"pool-forget-confirm-info": "Cela supprimera définitivement cet hôte de XO. Pour vous reconnecter, vous devrez l'ajouter à nouveau.",
"pool-forget-confirm-title": "Vous êtes sur le point de supprimer l'hôte {name}",
"pool-management": "Gestion du Pool",
"pool-named": "Pool {name}",
"pool-ram-usage": "Utilisation RAM du Pool",
"pool-required": "Le pool est requis.",
"pool:status:connected": "@:status:connected",
@@ -1137,12 +1178,14 @@
"popup-first-connection-introduction": "Nous sommes ravis de vous présenter la première version officielle de Xen Orchestra 6.",
"popup-first-connection-upcoming-release": "Dans les prochaines versions, nous ajouterons des actions sur tous les objets, la gestion des utilisateurs, des fonctionnalités de sauvegarde et bien plus encore. Nous espérons que vous apprécierez découvrir cette toute nouvelle interface !",
"port": "Port",
"port-group": "Groupe de ports",
"port-invalid": "Le format du port est invalide.",
"port-required": "Le port est requis.",
"power-on-host-for-console": "Allumez votre hôte pour accéder à sa console",
"power-on-mode": "Mode d'alimentation",
"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}",
"pro-support": "Support pro {name}",
"professional-support": "Support professionnel",
"progress": "Progression",
@@ -1196,6 +1239,7 @@
"read": "Lecture",
"read-only": "Lecture seule",
"reading-speed": "Vitesse de lecture",
"ready": "Prête",
"reboot-blocked": "Redémarrage bloqué",
"reboot-vm-internal-shutdown": "Redémarrer la VM lors d'un arrêt interne",
"receive": "Recevoir",
@@ -1224,6 +1268,7 @@
"resources": "Ressources",
"resources-overview": "Vue d'ensemble des ressources",
"rest-api": "API REST",
"resume": "Reprise",
"retry": "Réessayer",
"review": "Revue",
"review-and-confirm": "Vérifier et confirmer",
@@ -1250,7 +1295,9 @@
"select-pool": "Sélectionnez un pool",
"select-preset": "Sélectionner une configuration prédéfinie",
"select-to-see-details": "Cliquez sur {icon} pour voir les détails",
"selected-vms": "VMs sélectionnées",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"selected-vms-n": "VMs sélectionnées ({n})",
"self-signed-certificates": "Certificats auto-signés",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
@@ -1261,6 +1308,7 @@
"size": "Taille",
"size-and-speed": "@:size et @.lower:speed",
"size-on-disk": "Taille sur le disque",
"size-on-this-datastore": "Taille sur cette banque de données",
"skip-ssl-check": "Ignorer la vérification SSL (certificat auto-signé)",
"skip-ssl-check-info": "Le certificat du serveur n'est pas vérifié. À utiliser pour les certificats auto-signés.",
"skipped": "Ignoré",
@@ -1345,6 +1393,7 @@
"success": "Succès",
"summary": "Résumé",
"support": "Support",
"supported": "Prise en charge",
"supported-image-formats": "Formats d'image supportés",
"suspend-blocked": "Mise en veille bloquée",
"suspend-storage-repository": "Espace de stockage en attente",
@@ -1360,6 +1409,8 @@
"table-selection:watch-out!": "Attention !",
"tags": "Tags",
"target": "Cible",
"target-network": "Réseau cible",
"target-sr": "SR cible",
"task": "Tâche",
"task-type": "Type de tâche",
"task:ended": "Terminée",
@@ -1448,6 +1499,7 @@
"unnamed": "Sans nom",
"unreachable-hosts": "Hôtes inaccessibles",
"unreachable-hosts-reload-page": "C'est fait. Rafraîchir la page",
"unsupported": "Non prise en charge",
"untitled": "Sans titre",
"untranslated-text-helper": "Par défaut, les textes non traduits seront affichés en anglais.",
"up-to-date": "À jour",
@@ -1466,17 +1518,47 @@
"users": "Utilisateurs",
"uuid": "UUID",
"v2v": "V2V",
"v2v:action:add-to-migration-list": "Ajouter à la liste de migration",
"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:connect-new-vmware-source": "Connecter une nouvelle source VMware",
"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: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: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: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: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é",
"v2v:prerequisite-ok": "Vérification des prérequis : vectura OK",
"v2v:remove-from-list-info": "Seule la liste de migration change : rien n’est supprimé sur la source ni dans XO.",
"v2v:saved-in-this-browser": "Enregistrée dans ce navigateur",
"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:status:cold": "Bascule effectuée",
"v2v:status:warm": "Synchronisée à chaud",
"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: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",
"value-by-default": "{value} par défaut",
"value-from": "Valeur issue de",
"vcpu": "vCPU",
"vcpu-at-startup": "vCPU au démarrage",
"vcpus": "vCPUs",
@@ -1525,6 +1607,7 @@
"virtualization-mode": "Mode de virtualisation",
"virtualization-type": "Type de virtualisation",
"vlan": "VLAN",
"vlan-id": "VLAN {id}",
"vlan-required": "Le VLAN est requis.",
"vm": "VM",
"vm-backup-failure-number-of-retries": "Nombre de tentatives en cas d'échec de la sauvegarde de la VM",

View File

@@ -8,6 +8,7 @@ const COLUMN_WIDTH = '4rem'
export type CheckboxCellBindings = {
selected: boolean
disabled?: boolean
onToggle: () => void
}
@@ -21,9 +22,10 @@ function renderHeadCheckbox(model: Ref<boolean | undefined>) {
})
}
function renderBodyCheckbox({ selected, onToggle }: CheckboxCellBindings) {
function renderBodyCheckbox({ selected, disabled, onToggle }: CheckboxCellBindings) {
return h(UiCheckbox, {
accent: 'brand',
disabled,
modelValue: selected,
'onUpdate:modelValue': onToggle,
})

View File

@@ -1,15 +1,142 @@
import type { XoV2vInventory, XoV2vVm } from '@vates/types'
import { createV2vInventory, createV2vVm } from '../../src/test/create-v2v-inventory.ts'
import {
V2V_DISK_MODE,
V2V_FIRMWARE,
V2V_VM_POWER_STATE,
type XoV2vDatacenter,
type XoV2vDatastore,
type XoV2vDisk,
type XoV2vHost,
type XoV2vInventory,
type XoV2vNic,
type XoV2vPortGroup,
type XoV2vVm,
} from '@vates/types'
import {
createV2vDatastore,
createV2vDisk,
createV2vInventory,
createV2vNic,
createV2vVm,
} from '../../src/test/create-v2v-inventory.ts'
const VCENTER_VM_COUNT = 148
const ESXI_VM_COUNT = 9
const SNAPSHOTS_VM_INDEX = 4
// Warm/cold come from real XO VMs, the inventory cannot mock them: to see `app-008` resumed, turn an XO VM of the
// destination pool with 2 VDIs and 2 VIFs into its partial import (`sourceId` is `<host>:<port>` for an ESXi
// source), one VDI paired on a snapshot delta, the other on the base disk:
// xe vm-param-set uuid=<vm> other-config:xo:v2v:sourceId=mock-vcenter-instance-uuid other-config:sourceVmId=vm-107 \
// other-config:sourceSnapshotId=2 blocked-operations:start='Esxi migration in progress...'
// xe vdi-param-set uuid=<1st vdi> other-config:esxi_diskPath=app-008/app-008-000001.vmdk
// xe vdi-param-set uuid=<2nd vdi> other-config:esxi_diskPath=app-008/app-008_1.vmdk
const WARM_VM_INDEX = 7
// One VM per disk case the VM selection step tells apart: each unsupported disk, and disks on two datastores. The
// last three are past the ESXi VMs, so only the vCenter shows them
const RDM_VM_INDEX = 5
const TWO_DATASTORES_VM_INDEX = 10
const INDEPENDENT_DISK_VM_INDEX = 11
const ENCRYPTED_DISK_VM_INDEX = 12
const DATACENTER_ID = 'datacenter-1' as XoV2vDatacenter['id']
const HOST_ID = 'host-1' as XoV2vHost['id']
const PORT_GROUP_ID = 'network-1' as XoV2vPortGroup['id']
const DATASTORES = [
{ id: 'datastore-11', name: 'ds-prod-01', capacity: 4 * 1024 ** 4, free: 1.1 * 1024 ** 4 },
{ id: 'datastore-12', name: 'ds-prod-02', capacity: 4 * 1024 ** 4, free: 1.1 * 1024 ** 4 },
{ id: 'datastore-13', name: 'ds-local-esx03', capacity: 1024 ** 4, free: 512 * 1024 ** 3 },
].map(({ id, ...datastore }) =>
createV2vDatastore({ ...datastore, id: id as XoV2vDatastore['id'], hostIds: [HOST_ID] })
)
const SNAPSHOT_COUNTS: Record<number, number> = { [SNAPSHOTS_VM_INDEX]: 1, [WARM_VM_INDEX]: 2 }
// VMware names the deltas of `dir/disk.vmdk` `dir/disk-000001.vmdk`, `dir/disk-000002.vmdk`… one per snapshot
function createChainPaths(directory: string, fileName: string, snapshotCount: number): string[] {
const deltaPaths = Array.from(
{ length: snapshotCount },
(_, deltaIndex) => `${directory}/${fileName}-${String(deltaIndex + 1).padStart(6, '0')}.vmdk`
)
return [`${directory}/${fileName}.vmdk`, ...deltaPaths]
}
function createDisks(index: number, name: string, snapshotCount: number): XoV2vDisk[] {
const datastoreId = DATASTORES[index % DATASTORES.length].id
const disk = createV2vDisk({
datastoreId,
capacity: (40 + (index % 5) * 20) * 1024 ** 3,
mode: index === INDEPENDENT_DISK_VM_INDEX ? V2V_DISK_MODE.INDEPENDENT_PERSISTENT : V2V_DISK_MODE.PERSISTENT,
isRdm: index === RDM_VM_INDEX,
isEncrypted: index === ENCRYPTED_DISK_VM_INDEX,
chainPaths: createChainPaths(name, name, snapshotCount),
})
const hasSecondDisk = index === TWO_DATASTORES_VM_INDEX || index === WARM_VM_INDEX
if (!hasSecondDisk) {
return [disk]
}
const secondDatastoreId =
index === TWO_DATASTORES_VM_INDEX ? DATASTORES[(index + 1) % DATASTORES.length].id : datastoreId
return [
disk,
createV2vDisk({
key: 'scsi0:1' as XoV2vDisk['key'],
label: 'Hard disk 2',
datastoreId: secondDatastoreId,
capacity: 200 * 1024 ** 3,
chainPaths: createChainPaths(name, `${name}_1`, snapshotCount),
}),
]
}
function createNics(index: number): XoV2vNic[] {
const nicCount = index === WARM_VM_INDEX ? 2 : 1
return Array.from({ length: nicCount }, (_, nicIndex) =>
createV2vNic({
key: `ethernet${nicIndex}` as XoV2vNic['key'],
label: `Network adapter ${nicIndex + 1}`,
portGroupId: PORT_GROUP_ID,
macAddress: `00:50:56:9a:0${nicIndex}:${index.toString(16).padStart(2, '0')}`,
})
)
}
// Varied on purpose: the list must show every power state, firmware and size combination
function createVm(index: number): XoV2vVm {
const name = `app-${String(index + 1).padStart(3, '0')}`
const snapshotCount = SNAPSHOT_COUNTS[index] ?? 0
const disks = createDisks(index, name, snapshotCount)
return createV2vVm({
id: `vm-${index + 100}` as XoV2vVm['id'],
instanceUuid: `vm-instance-${index + 100}`,
name: `app-${String(index + 1).padStart(3, '0')}`,
name,
powerState: index % 3 === 0 ? V2V_VM_POWER_STATE.POWERED_OFF : V2V_VM_POWER_STATE.POWERED_ON,
hostId: HOST_ID,
nCpus: 2 + (index % 4),
memory: (4 + (index % 4) * 4) * 1024 ** 3,
firmware: index % 2 === 0 ? V2V_FIRMWARE.UEFI : V2V_FIRMWARE.BIOS,
datastoreIds: [...new Set(disks.map(disk => disk.datastoreId))],
snapshotCount,
// The vmsd numbers snapshots from 1, the last taken is the current one
currentSnapshotId: snapshotCount > 0 ? String(snapshotCount) : undefined,
disks,
nics: createNics(index),
})
}
@@ -20,6 +147,10 @@ export function createMockInventory(host: string): XoV2vInventory {
product: isVcenter ? 'VMware vCenter Server' : 'VMware ESXi',
version: isVcenter ? '8.0.2' : '7.0.3',
instanceUuid: isVcenter ? 'mock-vcenter-instance-uuid' : undefined,
datacenters: [{ id: DATACENTER_ID, name: 'DC-Paris' }],
hosts: [{ id: HOST_ID, name: 'esx01.lab.local', datacenterId: DATACENTER_ID }],
datastores: DATASTORES,
portGroups: [{ id: PORT_GROUP_ID, name: 'PG-Prod', vlan: 10 }],
vms: Array.from({ length: isVcenter ? VCENTER_VM_COUNT : ESXI_VM_COUNT }, (_, index) => createVm(index)),
})
}

View File

@@ -14,6 +14,10 @@ A fixed priority, not a preference:
Testing a page is a different job from testing a component, and the component is the more valuable one. A component should not carry much complex logic — when it does, extract that into a composable or utility and test it there.
### Only the app's own behaviour
An XO 6 test covers what the app decides, never what `@xen-orchestra/web-core` does on its own: a side panel opening on a selection, a store re-expanding on a resize, a select opening its options all belong to the web-core tests. A web-core component may be rendered beside the code under test when the app's rule depends on it, but the assertion must be about that rule: a test that still passes once the app's code is removed is testing web-core and is deleted, not written. A state the app sets on a web-core component (`busy`, `disabled`, `selected`, a table `state`…) is asserted through that component's props (`getUiButton(wrapper, label).props('busy')`), never through the DOM it renders for it; a user action clicks the real `<button>` (`getButton(wrapper, label)`). A composable member that only forwards to a web-core store gets no test of its own. Selects: see [Testing components](./tests/testing-components.md#selects).
## Getting started
- [Assert behaviour, not implementation](./tests/assert-behaviour.md) — the one rule that outranks the others

File diff suppressed because one or more lines are too long

View File

@@ -35,7 +35,21 @@ beforeEach(() => {
})
```
Collections always read together share one mock module exporting each of their composables: `mock-xo-collections.ts` stands for the pool, host, SR, network, PBD, PIF and VM template collections. Each `vi.mock` of these modules points to it (`vi.mock` is hoisted, it cannot be wrapped in a helper), and `xoCollectionsMock.reset()` resets them all. Its pool collection also answers `hasPoolById` and `useGetPoolById` from `pools`, its host collection `getHostById` from `hosts`, its PBD collection `pbdsBySr` from `pbds`, and `arePoolsReady` (true after a reset) lets a test hold the pools as still loading.
`vmCollectionMock.areVmsReady` is true after a reset; set it to false to hold the collection as still loading.
Collections always read together share one mock module exporting each of their composables: `mock-xo-collections.ts` stands for the pool, host, SR, network, PBD, PIF and VM template collections. Each `vi.mock` of these modules points to it (`vi.mock` is hoisted, it cannot be wrapped in a helper), and `xoCollectionsMock.reset()` resets them all. Its pool collection also answers `getPoolById`, `hasPoolById` and `useGetPoolById` from `pools`, its host collection `getHostById` from `hosts`, its SR and network collections `getSrById` and `getNetworkById`, its PBD collection `pbdsBySr` from `pbds`, and `arePoolsReady`, `areSrsReady`, `areNetworksReady` and `areTemplatesReady` (true after a reset) let a test hold that collection as still loading. `seedV2vListPool` (`seed-v2v-list-pool.ts`) seeds the pool of a V2V list and what it offers.
The same goes for `mock-xo-v2v-vm-rows-collections.ts`, the VM, task and VM snapshot collections the V2V VM rows are built from: `v2vVmRowsCollectionsMock` holds `vms`, `tasks`, `snapshots` and their `are…Ready` flags (true after a reset; set one to false to hold that collection as still loading).
`mock-delete-modal.ts` stands for `useDeleteModal`, whose overlay a unit test cannot answer: `deleteModalMock.answer('onConfirm' | 'onCancel')` sets how the next confirmation is answered, `deleteModalMock.confirm()` runs the `onConfirm` event of the last confirmation for a caller that passes its events instead of awaiting the answer, `deleteModalMock.open` is the spy to read the props it was opened with, and `deleteModalMock.reset()` goes in `beforeEach`.
`mock-overlay.ts` stands for `useOverlay`, whose overlays a unit test cannot open: each call is recorded in order, as the options it was given (its `component`, its `events`) and the `open` spy it got back; `overlayMock.getOverlay<TOptions>(index = 0)` / `overlayMock.getOverlays<TOptions>()` read them back, `TOptions` typing the events for the payload they take, and `overlayMock.reset()` goes in `beforeEach`.
`mock-v2v-vm-actions.ts` stands for `use-v2v-vm-actions.composable.ts`, whose composables open the settings drawer or delete an import VM: each `v2vVmActionsMock.useV2v*VmActions` spy returns fresh fake actions of its kind (read them back from `mock.results`), and `v2vVmActionsMock.reset()` goes in `beforeEach`.
`mock-xo-vm-device-collections.ts` stands for the VBD, VDI and VIF collections: `vmDeviceCollectionsMock` holds `vbds`, `vdis`, `vifs` and their `are…Ready` flags (true after a reset), and its collections answer `getVbdsByIds` and `getVifsByIds`.
`mock-xo-routes.ts` stands for `useXoRoutes`, the XO 5 routes the app fetches: an XO 5 link reads `https://xo5.example.com/#<path>`.
## Network

View File

@@ -26,7 +26,11 @@ The exception is a **reactivity** test: it must keep reading through the mounted
## Unmounting
`src/test/setup.ts` calls `enableAutoUnmount(afterEach)`, so every wrapper — `mount` or `mountComposable` — is unmounted after the test that created it. Never unmount by hand, and never mount in `beforeAll`: that wrapper would be torn down after the first test.
`src/test/setup.ts` calls `enableAutoUnmount(afterEach)`, so every wrapper — `mount` or `mountComposable` — is unmounted after the test that created it. Never unmount by hand for cleanup, and never mount in `beforeAll`: that wrapper would be torn down after the first test. The one `wrapper.unmount()` allowed is the act of a test asserting what `onUnmounted` does.
## Local storage
`src/test/setup.ts` clears `localStorage` before each test, so every test starts with an empty storage. Never clear it again in a test file: store what a test needs in its own setup (e.g. `seedV2vMigrationList`).
## Shared helpers: `use<X>EnhancedData`

View File

@@ -61,7 +61,7 @@ expect(labelledValues).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8
Prefer this shape: one assertion covering every row a user sees, and it fails loudly when a value lands under the wrong label. Assert against **class names and semantic elements**, never a positional chain of child indexes.
Only the classes the markup already carries count: a web-core component's own (`.ui-head-bar .status`) or one a style rule of the component uses. Never add a class only for a test to find an element; query the component (`getComponent`) or the web-core class around it instead.
The class must exist for the component's own CSS: **never add a class only for a test**, and remove one whose CSS is gone. Without a styled class, query the component (`getComponent`), a styled class of a child component, or the text the user reads.
`src/test/find-rendered-values.ts` does exactly that reduction, so a card test does not re-roll it:
@@ -133,6 +133,25 @@ Where the line falls:
| data reaching the component (a name, an id, an IP, a tag) | the literal — it is the test's own input |
| a number the component formats (`'8 B'`, `'4 GiB'`, `'2'`) | the literal — deriving it would copy the template |
## Tables
A table is read **by header label**, never by column position: `src/test/find-table-cells.ts` looks the label up in `thead th`, so a column added, moved or hidden does not shift the assertions.
| Helper | Reads |
| -------------------------------------------------------- | --------------------------------------------------------------------- |
| `findTableHeaders(wrapper)` | the header labels, in order (`''` for a checkbox column) |
| `findTableColumn(wrapper, label)` | the text of each cell of that column, one per body row |
| `findTableColumnCells(wrapper, label)` | the cells of that column, to query a link, a tag or a checkbox inside |
| `findTableRows(wrapper)`, from `find-rendered-values.ts` | the cell texts of each body row, for a table read row by row |
```typescript
expect(findTableHeaders(wrapper)).toEqual(['', t('vm'), t('size')])
expect(findTableColumn(wrapper, t('size'))).toEqual(['80GiB', '120GiB'])
expect(findTableColumnCells(wrapper, t('vm'))[0].getComponent(UiLink).attributes('href')).toBe('/vm/vm-42/dashboard')
```
A label missing from the header throws, naming the label, rather than reading an empty column.
## Chart cards
A dashboard chart card loads its `VtsLinearChart` asynchronously, and the real chart cannot mount here: ECharts needs a canvas and a `ResizeObserver`. `src/test/linear-chart-stub.ts` stands in for it, with the props declared so they stay readable. `mountChartCard` from `src/test/mount-chart-card.ts` wires that stub in and defaults `loading` to `false`, so a card test only names its component and its stats type:

View File

@@ -1,7 +1,6 @@
import BackupJobsTable from '@/modules/backup-job/components/list/BackupJobsTable.vue'
import type { FrontAnyXoBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import type { useXoBackupLogCollection } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createMirrorBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createBackupLog } from '@/test/create-backup-log.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
@@ -21,11 +20,7 @@ const { areBackupLogsReady, getLastNBackupLogsByJobId } = vi.hoisted(() => ({
getLastNBackupLogsByJobId: vi.fn(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'), () => ({
useXoBackupLogCollection: (() => ({

View File

@@ -21,7 +21,6 @@ import type {
} from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createBackupLog } from '@/test/create-backup-log.ts'
import { createSchedule } from '@/test/create-schedule.ts'
@@ -36,11 +35,7 @@ const { schedulesByJobId, backupLogsByJobId } = vi.hoisted(() => ({
backupLogsByJobId: new Map<string, FrontXoBackupLog[]>(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'), async () => {
const { computed: vueComputed, ref: vueRef } = await import('vue')

View File

@@ -4,7 +4,6 @@ import type {
FrontXoSchedule,
useXoScheduleCollection,
} from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createBackupLog, createBackupLogTask } from '@/test/create-backup-log.ts'
import { createSchedule } from '@/test/create-schedule.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
@@ -20,11 +19,7 @@ const { schedules } = vi.hoisted(() => ({
schedules: [] as FrontXoSchedule[],
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'), () => ({
useXoScheduleCollection: (() => ({

View File

@@ -6,7 +6,6 @@ import type {
FrontXoSr,
useXoSrCollection,
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createSr } from '@/test/create-sr.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
@@ -24,11 +23,7 @@ const { srs } = vi.hoisted(() => ({
srs: [] as FrontXoSr[],
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
useXoSrCollection: (() => ({

View File

@@ -2,7 +2,6 @@ import BackupJobRunItem from '@/modules/backup-job/components/list/panel/card-it
import BackupJobLogsCard from '@/modules/backup-job/components/list/panel/cards/BackupJobLogsCard.vue'
import type { FrontXoBackupLog } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import type { useXoScheduleCollection } from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createBackupLog } from '@/test/create-backup-log.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
@@ -10,11 +9,7 @@ import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTi
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'), () => ({
useXoScheduleCollection: (() => ({

View File

@@ -1,7 +1,6 @@
import BackupJobSchedulesCard from '@/modules/backup-job/components/list/panel/cards/BackupJobSchedulesCard.vue'
import type { FrontXoSchedule } from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import { getScheduleNextRun } from '@/modules/schedule/utils/xo-schedule.util.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createSchedule } from '@/test/create-schedule.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
@@ -12,11 +11,7 @@ import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTi
import { formatDateTime } from '@core/utils/time.util.ts'
import { mount } from '@vue/test-utils'
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -5,9 +5,9 @@ import type { useXoHostCollection } from '@/modules/host/remote-resources/use-xo
import type { useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
import type { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createBackupLog, createBackupLogVmTask } from '@/test/create-backup-log.ts'
import { createVm } from '@/test/create-vm.ts'
import { findTableColumnCells } from '@/test/find-table-cells.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiLink from '@core/components/ui/link/UiLink.vue'
@@ -20,11 +20,7 @@ const { getBackupJobById, getVmById } = vi.hoisted(() => ({
getVmById: vi.fn(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'), () => ({
useXoBackupJobCollection: (() => ({ getBackupJobById })) as unknown as typeof useXoBackupJobCollection,
@@ -73,9 +69,7 @@ function mountTable(backupLog: FrontXoBackupLog) {
}
function findVmCell(wrapper: ReturnType<typeof mountTable>) {
const headers = wrapper.findAll('thead th').map(header => header.text())
return wrapper.findAll('tbody tr td')[headers.indexOf(t('vm'))]
return findTableColumnCells(wrapper, t('vm'))[0]
}
describe('VM', () => {

View File

@@ -49,7 +49,8 @@ const emit = defineEmits<{
const { t } = useI18n()
const { general, details, detailsTitle, hasMixedTypes, validateAndBuildPayloads } = useBulkEditBackupRepositoriesForm(brs)
const { general, details, detailsTitle, hasMixedTypes, validateAndBuildPayloads } =
useBulkEditBackupRepositoriesForm(brs)
async function handleConfirm() {
const payloads = await validateAndBuildPayloads()

View File

@@ -51,7 +51,10 @@
</template>
<script lang="ts" setup>
import { MASKED_SECRET, splitBackupRepositoryPath } from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import {
MASKED_SECRET,
splitBackupRepositoryPath,
} from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'

View File

@@ -65,7 +65,10 @@
</template>
<script lang="ts" setup>
import { MASKED_SECRET, splitBackupRepositoryPath } from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import {
MASKED_SECRET,
splitBackupRepositoryPath,
} from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'

View File

@@ -4,9 +4,9 @@ import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote
import { createBr } from '@/test/create-br.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
import { t } from '@/test/i18n.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import type { useRouteQuery } from '@core/composables/route-query.composable.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { type MaybeRefOrGetter, ref } from 'vue'
import type { Router } from 'vue-router'
@@ -16,8 +16,7 @@ type ForgetResults = PromiseSettledResult<void>[]
type MockedOverlay = { open: ReturnType<typeof vi.fn>; events: OverlayEvents }
const { overlays, run, selectedBrId } = vi.hoisted(() => ({
overlays: [] as MockedOverlay[],
const { run, selectedBrId } = vi.hoisted(() => ({
run: vi.fn(),
selectedBrId: { value: '' },
}))
@@ -36,21 +35,13 @@ vi.mock(import('@core/composables/route-query.composable.ts'), () => ({
useRouteQuery: (() => selectedBrId) as unknown as typeof useRouteQuery,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: (({ events }: Pick<MockedOverlay, 'events'>) => {
const overlay = { open: vi.fn(), events }
overlays.push(overlay)
return { open: overlay.open }
}) as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
let successHandled: Promise<void> | undefined
beforeEach(() => {
vi.restoreAllMocks()
overlays.length = 0
overlayMock.reset()
run.mockReset()
selectedBrId.value = ''
successHandled = undefined
@@ -71,7 +62,7 @@ function mountForget(brs: MaybeRefOrGetter<FrontXoBackupRepository[]>, { router
const result = mountComposable(() => useBackupRepositoryForget(brs), { router }).wrapper.vm
// The composable declares the single repository modal first, then the type to confirm one
const [forgetModal, typeToConfirmModal] = overlays
const [forgetModal, typeToConfirmModal] = overlayMock.getOverlays<MockedOverlay>()
return { result, forgetModal, typeToConfirmModal }
}
@@ -82,7 +73,7 @@ async function forgetAndConfirm(brs: MaybeRefOrGetter<FrontXoBackupRepository[]>
result.forgetBackupRepositories()
const openedModal = overlays.find(overlay => overlay.open.mock.calls.length > 0)
const openedModal = overlayMock.getOverlays<MockedOverlay>().find(overlay => overlay.open.mock.calls.length > 0)
// The events can be declared with the modal, or given when opening it
const openEvents: OverlayEvents | undefined = openedModal?.open.mock.lastCall?.[0]?.events

View File

@@ -6,9 +6,9 @@ import type {
import type { EditBackupRepositoryPayload } from '@/modules/backup-repository/jobs/xo-backup-repository-edit.job.ts'
import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote-resources/use-xo-backup-repository-collection.ts'
import { createBr } from '@/test/create-br.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { ref, toValue } from 'vue'
@@ -17,8 +17,7 @@ type MockedOverlay = {
events: { onConfirm: (payload: never) => Promise<unknown> }
}
const { overlays, useBulkEditJob, run } = vi.hoisted(() => ({
overlays: [] as MockedOverlay[],
const { useBulkEditJob, run } = vi.hoisted(() => ({
useBulkEditJob: vi.fn(),
run: vi.fn(),
}))
@@ -28,19 +27,11 @@ vi.mock(import('@/modules/backup-repository/jobs/xo-backup-repository-bulk-edit.
useXoBackupRepositoryBulkEditJob: useBulkEditJob as unknown as typeof useXoBackupRepositoryBulkEditJob,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: (({ events }: Pick<MockedOverlay, 'events'>) => {
const overlay = { open: vi.fn(), events }
overlays.push(overlay)
return { open: overlay.open }
}) as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
beforeEach(() => {
vi.restoreAllMocks()
overlays.length = 0
overlayMock.reset()
useBulkEditJob.mockReset()
run.mockReset()
@@ -71,7 +62,7 @@ function mountBulkEdit(brs: FrontXoBackupRepository[]) {
const result = mountComposable(() => useBulkEditBackupRepositories(() => brs)).wrapper.vm
// The composable declares the edit drawer of a single repository first, then the bulk edit one
const [editDrawer, bulkEditDrawer] = overlays
const [editDrawer, bulkEditDrawer] = overlayMock.getOverlays<MockedOverlay>()
return { result, editDrawer, bulkEditDrawer }
}

View File

@@ -5,9 +5,9 @@ import type {
} from '@/modules/backup-repository/jobs/xo-backup-repository-edit.job.ts'
import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote-resources/use-xo-backup-repository-collection.ts'
import { createBr } from '@/test/create-br.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { type Ref, ref } from 'vue'
@@ -18,20 +18,16 @@ type OverlayOptions = {
}
}
const { useEditJob, run, useOverlayMock, open } = vi.hoisted(() => ({
const { useEditJob, run } = vi.hoisted(() => ({
useEditJob: vi.fn(),
run: vi.fn(),
useOverlayMock: vi.fn(),
open: vi.fn(),
}))
vi.mock(import('@/modules/backup-repository/jobs/xo-backup-repository-edit.job.ts'), () => ({
useXoBackupRepositoryEditJob: useEditJob as unknown as typeof useXoBackupRepositoryEditJob,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: useOverlayMock as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
const PAYLOAD: EditBackupRepositoryPayload = {
name: 'Weekly backups',
@@ -43,8 +39,7 @@ const PAYLOAD: EditBackupRepositoryPayload = {
beforeEach(() => {
useEditJob.mockReset()
run.mockReset()
useOverlayMock.mockReset()
open.mockReset()
overlayMock.reset()
silenceConsoleError()
@@ -54,8 +49,6 @@ beforeEach(() => {
isRunning: ref(false),
errorMessage: ref(undefined),
})
useOverlayMock.mockReturnValue({ open, abort: vi.fn() })
})
function mountEditBackupRepository(br: FrontXoBackupRepository = createBr()) {
@@ -63,7 +56,7 @@ function mountEditBackupRepository(br: FrontXoBackupRepository = createBr()) {
}
function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
return overlayMock.getOverlay<OverlayOptions>()
}
function getJobArgs() {
@@ -108,7 +101,7 @@ describe('editBackupRepository', () => {
mountEditBackupRepository(br).editBackupRepository()
expect(open).toHaveBeenCalledWith({ props: expect.objectContaining({ br }) })
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({ props: expect.objectContaining({ br }) })
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -3,9 +3,9 @@ import type {
NewBackupRepositoryPayload,
useXoBackupRepositoryCreateJob,
} from '@/modules/backup-repository/jobs/xo-backup-repository-create.job.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import type { Ref } from 'vue'
@@ -16,20 +16,16 @@ type OverlayOptions = {
}
}
const { useCreateJob, run, useOverlayMock, open } = vi.hoisted(() => ({
const { useCreateJob, run } = vi.hoisted(() => ({
useCreateJob: vi.fn(),
run: vi.fn(),
useOverlayMock: vi.fn(),
open: vi.fn(),
}))
vi.mock(import('@/modules/backup-repository/jobs/xo-backup-repository-create.job.ts'), () => ({
useXoBackupRepositoryCreateJob: useCreateJob as unknown as typeof useXoBackupRepositoryCreateJob,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: useOverlayMock as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
const PAYLOAD: NewBackupRepositoryPayload = {
name: 'Nightly backups',
@@ -39,13 +35,11 @@ const PAYLOAD: NewBackupRepositoryPayload = {
beforeEach(() => {
useCreateJob.mockReset()
run.mockReset()
useOverlayMock.mockReset()
open.mockReset()
overlayMock.reset()
silenceConsoleError()
useCreateJob.mockReturnValue({ run })
useOverlayMock.mockReturnValue({ open, abort: vi.fn() })
})
function mountNewBackupRepository() {
@@ -53,7 +47,7 @@ function mountNewBackupRepository() {
}
function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
return overlayMock.getOverlay<OverlayOptions>()
}
function getJobPayload() {
@@ -69,7 +63,7 @@ it('has no payload before the drawer is confirmed', () => {
it('opens the drawer', () => {
mountNewBackupRepository().openNewBackupRepositoryDrawer()
expect(open).toHaveBeenCalledOnce()
expect(overlayMock.getOverlay().open).toHaveBeenCalledOnce()
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -119,7 +119,8 @@ export function useBulkEditBackupRepositoriesForm(brs: FrontXoBackupRepository[]
return undefined
}
const detailsChanges = detailsForm === undefined ? undefined : omitMixedValues(detailsForm.formData, t('mixed-value'))
const detailsChanges =
detailsForm === undefined ? undefined : omitMixedValues(detailsForm.formData, t('mixed-value'))
return new Map(initialBrs.map(initialBr => [initialBr.br.id, buildPayload(initialBr, detailsChanges)]))
}

View File

@@ -2,6 +2,7 @@ import PluginActions from '@/modules/plugin/components/actions/PluginActions.vue
import PluginHeader from '@/modules/plugin/components/PluginHeader.vue'
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
import { createPlugin, PLUGIN_CONFIGURATION_SCHEMA } from '@/test/create-plugin.ts'
import { findAccentedTags } from '@/test/find-accented-labels.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
@@ -13,16 +14,6 @@ function mountHeader(plugin: FrontXoPlugin = createPlugin()) {
})
}
function findTags(wrapper: ReturnType<typeof mountHeader>) {
return wrapper.findAll('.ui-head-bar .ui-tag').map(tag => ({
text: tag.text(),
accent: tag
.classes()
.find(className => className.startsWith('accent--'))
?.replace('accent--', ''),
}))
}
it('shows the name of the plugin in the head bar', () => {
const wrapper = mountHeader(createPlugin())
@@ -46,28 +37,28 @@ it('shows the version and the configuration status of a configured plugin', () =
createPlugin({ version: '2.1.0', configurationSchema: PLUGIN_CONFIGURATION_SCHEMA, configured: true })
)
expect(findTags(wrapper)).toEqual([
{ text: t('version-number', { version: '2.1.0' }), accent: 'neutral' },
{ text: t('status:configured'), accent: 'success' },
expect(findAccentedTags(wrapper)).toEqual([
{ label: t('version-number', { version: '2.1.0' }), accent: 'neutral' },
{ label: t('status:configured'), accent: 'success' },
])
})
it('warns about a plugin which is not configured', () => {
const wrapper = mountHeader(createPlugin({ configurationSchema: PLUGIN_CONFIGURATION_SCHEMA, configured: false }))
expect(findTags(wrapper).at(-1)).toEqual({ text: t('status:not-configured'), accent: 'warning' })
expect(findAccentedTags(wrapper).at(-1)).toEqual({ label: t('status:not-configured'), accent: 'warning' })
})
it('shows no configuration status for a plugin which is not configurable', () => {
const wrapper = mountHeader(createPlugin({ version: '2.1.0', configurationSchema: undefined }))
expect(findTags(wrapper)).toEqual([{ text: t('version-number', { version: '2.1.0' }), accent: 'neutral' }])
expect(findAccentedTags(wrapper)).toEqual([{ label: t('version-number', { version: '2.1.0' }), accent: 'neutral' }])
})
it('shows no version for a plugin without version', () => {
const wrapper = mountHeader(createPlugin({ version: undefined, configurationSchema: undefined }))
expect(findTags(wrapper)).toEqual([])
expect(findAccentedTags(wrapper)).toEqual([])
})
it('displays the actions of the plugin', () => {

View File

@@ -3,9 +3,9 @@ import type { useXoPluginConfigureJob } from '@/modules/plugin/jobs/xo-plugin-co
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
import { createJobResult } from '@/test/create-job-result.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import type { Ref } from 'vue'
@@ -17,30 +17,24 @@ type OverlayOptions = {
}
}
const { useJob, run, useOverlayMock, open, drawer } = vi.hoisted(() => ({
const { useJob, run, drawer } = vi.hoisted(() => ({
useJob: vi.fn(),
run: vi.fn(),
useOverlayMock: vi.fn(),
open: vi.fn(),
drawer: vi.fn(),
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: useOverlayMock as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
const PAYLOAD = { to: 'admin@example.org' }
beforeEach(() => {
useJob.mockReset()
run.mockReset()
useOverlayMock.mockReset()
open.mockReset()
overlayMock.reset()
silenceConsoleError()
useJob.mockReturnValue(createJobResult(run))
useOverlayMock.mockReturnValue({ open, abort: vi.fn() })
})
function mountDrawerJob(plugin: FrontXoPlugin = createPlugin()) {
@@ -50,7 +44,7 @@ function mountDrawerJob(plugin: FrontXoPlugin = createPlugin()) {
}
function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
return overlayMock.getOverlay<OverlayOptions>()
}
function getJobArgs() {
@@ -87,7 +81,7 @@ describe('openDrawer', () => {
mountDrawerJob(plugin).openDrawer()
expect(getOverlayOptions().component).toBe(drawer)
expect(open).toHaveBeenCalledWith({ props: expect.objectContaining({ plugin }) })
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({ props: expect.objectContaining({ plugin }) })
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -30,17 +30,19 @@
</template>
<script setup lang="ts">
import { useXoServerCollection } from '@/modules/server/remote-resources/use-xo-server-collection.ts'
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 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 type { XoServer } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { serverId } = defineProps<{
serverId: XoServer['id']
serverId: FrontXoServer['id']
}>()
const emit = defineEmits<{

View File

@@ -1,3 +1,4 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { SR_SCOPE_TYPE, type SrScope } from '@core/types/storage-repository.type.ts'
import type { RouteLocationRaw } from 'vue-router'
@@ -6,6 +7,10 @@ export function isSrWritable(sr: FrontXoSr) {
return sr.content_type !== 'iso' && sr.size > 0
}
export function isWritableSrOfPool(sr: FrontXoSr, poolId: FrontXoPool['id'] | undefined) {
return sr.$pool === poolId && isSrWritable(sr)
}
export function getSrPageLocation(sr: FrontXoSr, scope: SrScope): RouteLocationRaw {
return {
name: '/sr/[id]',

View File

@@ -0,0 +1,20 @@
import { isWritableSrOfPool } from '@/modules/storage-repository/utils/xo-sr.util.ts'
import { OTHER_POOL_ID } from '@/test/create-pool.ts'
import { createSr } from '@/test/create-sr.ts'
describe('isWritableSrOfPool', () => {
const sr = createSr()
it('accepts a writable SR of the pool', () => {
expect(isWritableSrOfPool(sr, sr.$pool)).toBe(true)
})
it.each([
['an ISO SR', createSr({ content_type: 'iso' }), sr.$pool],
['an SR without size', createSr({ size: 0 }), sr.$pool],
['an SR of another pool', sr, OTHER_POOL_ID],
['any SR while the pool is unknown', sr, undefined],
])('rejects %s', (_, candidate, poolId) => {
expect(isWritableSrOfPool(candidate, poolId)).toBe(false)
})
})

View File

@@ -1,10 +1,16 @@
import V2vDestinationPoolCard from '@/modules/v2v/components/destination/V2vDestinationPoolCard.vue'
import V2vPoolOption from '@/modules/v2v/components/destination/V2vPoolOption.vue'
import { createPool } from '@/test/create-pool.ts'
import { createSelectBindings } from '@/test/create-select-bindings.ts'
import { findCardTitle } from '@/test/find-card-title.ts'
import { findSelectStubLabels } from '@/test/find-select-stub-labels.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mountWithSelect } from '@/test/mount-with-select.ts'
import { openSelect } from '@/test/select.ts'
import { useFormSelect } from '@core/packages/form-select'
import { mount } from '@vue/test-utils'
import { h } from 'vue'
const POOL_SELECT_BINDINGS = createSelectBindings('Destination pool')
@@ -22,3 +28,17 @@ it('shows its title and that a migration goes to one pool', () => {
it('lets the user choose the pool', () => {
expect(findSelectStubLabels(mountCard())).toEqual([POOL_SELECT_BINDINGS.label])
})
it('shows each pool option with its name and resource counts', async () => {
const pool = createPool()
const counts = { hosts: 1, srs: 4, networks: 6 }
const wrapper = mountWithSelect(() => {
const { id } = useFormSelect([pool], { option: { label: 'name_label', properties: () => ({ counts }) } })
return () => h(V2vDestinationPoolCard, { poolSelectBindings: { ...POOL_SELECT_BINDINGS, id } })
})
await openSelect(wrapper)
expect(wrapper.getComponent(V2vPoolOption).props()).toEqual({ label: pool.name_label, counts })
})

View File

@@ -9,14 +9,11 @@
:network-select-bindings="form.networkSelectBindings"
:template-select-bindings="form.templateSelectBindings"
/>
<div class="actions">
<UiButton accent="brand" variant="secondary" size="medium" @click="back()">
{{ t('action:back') }}
</UiButton>
<V2vWizardStepActions @back="back()">
<UiButton type="submit" accent="brand" variant="primary" size="medium">
{{ existingList !== undefined ? t('action:continue-with-existing-list') : t('action:continue') }}
</UiButton>
</div>
</V2vWizardStepActions>
</VtsForm>
</template>
@@ -24,6 +21,7 @@
import V2vDestinationDefaultsCard from '@/modules/v2v/components/destination/V2vDestinationDefaultsCard.vue'
import V2vDestinationPoolCard from '@/modules/v2v/components/destination/V2vDestinationPoolCard.vue'
import V2vExistingListInfo from '@/modules/v2v/components/destination/V2vExistingListInfo.vue'
import V2vWizardStepActions from '@/modules/v2v/components/wizard/V2vWizardStepActions.vue'
import { useV2vDestinationStep } from '@/modules/v2v/composables/use-v2v-destination-step.composable.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
@@ -39,11 +37,5 @@ const { form, existingList, submit, back } = useV2vDestinationStep()
display: flex;
flex-direction: column;
gap: 1.6rem;
.actions {
display: flex;
justify-content: flex-end;
gap: 0.8rem;
}
}
</style>

View File

@@ -1,14 +1,16 @@
import V2vPoolOption from '@/modules/v2v/components/destination/V2vPoolOption.vue'
import type { V2vPoolResourceCounts } from '@/modules/v2v/utils/v2v-destination.util.ts'
import { findParts } from '@/test/find-parts.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountPoolOption(counts?: V2vPoolResourceCounts) {
return mount(V2vPoolOption, { props: { label: 'diskor', counts }, global: createGlobalTestConfig() })
}
it('shows the pool name with its host, SR and network counts', () => {
const wrapper = mount(V2vPoolOption, {
props: { label: 'diskor', counts: { hosts: 1, srs: 4, networks: 6 } },
global: createGlobalTestConfig(),
})
const wrapper = mountPoolOption({ hosts: 1, srs: 4, networks: 6 })
expect(wrapper.get('.label').text()).toBe('diskor')
expect(findParts(wrapper, '.counts')).toEqual([
@@ -19,7 +21,7 @@ it('shows the pool name with its host, SR and network counts', () => {
})
it('shows only the pool name while its counts are unknown', () => {
const wrapper = mount(V2vPoolOption, { props: { label: 'diskor' }, global: createGlobalTestConfig() })
const wrapper = mountPoolOption()
expect(wrapper.find('.counts').exists()).toBe(false)
})

View File

@@ -1,16 +1,17 @@
<template>
<span class="v2v-pool-option">
<span class="label">{{ label }}</span>
<span v-if="counts !== undefined" class="typo-body-regular-small counts interpunct-list">
<InlineParts v-if="counts !== undefined" class="typo-body-regular-small counts">
<span>{{ t('n-hosts', { n: counts.hosts }) }}</span>
<span>{{ t('n-srs', { n: counts.srs }) }}</span>
<span>{{ t('n-networks', { n: counts.networks }) }}</span>
</span>
</InlineParts>
</span>
</template>
<script lang="ts" setup>
import type { V2vPoolResourceCounts } from '@/modules/v2v/utils/v2v-destination.util.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import { useI18n } from 'vue-i18n'
defineProps<{

View File

@@ -0,0 +1,210 @@
import V2vVmSettingsDrawer from '@/modules/v2v/components/drawer/V2vVmSettingsDrawer.vue'
import V2vDiskMappingTable from '@/modules/v2v/components/mapping/V2vDiskMappingTable.vue'
import V2vNicMappingTable from '@/modules/v2v/components/mapping/V2vNicMappingTable.vue'
import V2vVmOptionsFields from '@/modules/v2v/components/mapping/V2vVmOptionsFields.vue'
import type { V2vMigrationListVm } from '@/modules/v2v/types/v2v-migration-list.type.ts'
import { cancelDrawer, findDrawerButtonLabels, submitDrawer } from '@/test/drawer.ts'
import { findTitles } from '@/test/find-titles.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import {
mountV2vVmMappingComponent,
V2V_FULLY_OVERRIDDEN_LIST_VM,
V2V_LIST_VM_OVERRIDES,
} from '@/test/mount-v2v-vm-mapping.ts'
import {
V2V_DATA_DISK,
V2V_DEBIAN_TEMPLATE,
V2V_DISK_OVERRIDES,
V2V_DISK_ROWS,
V2V_LAN_NIC,
V2V_MAPPED_VM,
V2V_NIC_OVERRIDES,
V2V_NIC_ROWS,
V2V_POOL_OFFERS,
V2V_SSD_SR,
V2V_SYSTEM_DISK,
V2V_TARGET_OPTIONS,
V2V_VLAN_NETWORK,
createV2vOfferedTarget,
} from '@/test/v2v-mapping-fixtures.ts'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import type { XoV2vVm } from '@vates/types'
import { mount, type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/host/remote-resources/use-xo-host-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/pbd/remote-resources/use-xo-pbd-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')
)
// The tables and the fields render what the drawer hands them, each covered by its own test: here only the wiring
// matters
function mountDrawer({ vm = V2V_MAPPED_VM, listVm }: { vm?: XoV2vVm; listVm?: V2vMigrationListVm } = {}) {
return mountV2vVmMappingComponent(
(list, router, provide) =>
mount(V2vVmSettingsDrawer, {
props: { vm, list },
global: createGlobalTestConfig({
router,
provide,
stubs: { V2vDiskMappingTable: true, V2vNicMappingTable: true, V2vVmOptionsFields: true },
}),
attachTo: document.body,
}),
{ listVm }
)
}
function getOptionsFields(wrapper: VueWrapper) {
return wrapper.getComponent(V2vVmOptionsFields)
}
beforeEach(() => {
xoCollectionsMock.reset()
})
it('names the VM in its title', async () => {
const wrapper = await mountDrawer()
expect(wrapper.text()).toContain(t('action:edit-vm-settings', { name: V2V_MAPPED_VM.name }))
})
it('maps the disks, then the network adapters, then sets the options', async () => {
const wrapper = await mountDrawer()
expect(findTitles(wrapper)).toEqual([t('disks'), t('network-adapters'), t('options')])
})
it.each([
['disks', { ...V2V_MAPPED_VM, disks: [] }, [t('network-adapters'), t('options')]],
['network adapters', { ...V2V_MAPPED_VM, nics: [] }, [t('disks'), t('options')]],
])('leaves out the %s of a VM without any', async (_, vm, titles) => {
expect(findTitles(await mountDrawer({ vm }))).toEqual(titles)
})
it('maps the disks and the NICs of the VM on the targets of the pool of the list', async () => {
const wrapper = await mountDrawer()
const diskTable = wrapper.getComponent(V2vDiskMappingTable)
expect(diskTable.props()).toMatchObject({ rows: V2V_DISK_ROWS, loading: false })
expect(diskTable.props('srOptions').map(option => option.id)).toEqual(V2V_POOL_OFFERS.srs.map(sr => sr.id))
expect(wrapper.getComponent(V2vNicMappingTable).props()).toEqual({
rows: V2V_NIC_ROWS,
networkOptions: V2V_TARGET_OPTIONS.networks,
loading: false,
})
})
it('shows the options of the VM', async () => {
const wrapper = await mountDrawer({ listVm: V2V_FULLY_OVERRIDDEN_LIST_VM })
expect(getOptionsFields(wrapper).props()).toEqual({
keepMac: false,
template: createV2vOfferedTarget(V2V_DEBIAN_TEMPLATE.id, 'overridden'),
templateOptions: V2V_TARGET_OPTIONS.templates,
tags: ['web'],
loading: false,
})
})
it('offers to cancel or to save', async () => {
const wrapper = await mountDrawer()
expect(findDrawerButtonLabels(wrapper)).toEqual([t('cancel'), t('action:save')])
})
it('shows the targets loading and cannot be saved while the pool offers load', async () => {
xoCollectionsMock.areSrsReady.value = false
const wrapper = await mountDrawer()
expect([
wrapper.getComponent(V2vDiskMappingTable).props('loading'),
wrapper.getComponent(V2vNicMappingTable).props('loading'),
getOptionsFields(wrapper).props('loading'),
wrapper.getComponent(VtsOverlayConfirmButton).props('disabled'),
]).toEqual([true, true, true, true])
})
it('can be saved once the pool offers are loaded', async () => {
const wrapper = await mountDrawer()
expect(wrapper.getComponent(VtsOverlayConfirmButton).props('disabled')).toBe(false)
})
it.each([
[
'the SR of a disk',
(wrapper: VueWrapper) =>
wrapper.getComponent(V2vDiskMappingTable).vm.$emit('changeSr', V2V_SYSTEM_DISK.key, V2V_SSD_SR.id),
{ ...V2V_LIST_VM_OVERRIDES, disks: { ...V2V_DISK_OVERRIDES, [V2V_SYSTEM_DISK.key]: V2V_SSD_SR.id } },
],
[
'the network of a NIC',
(wrapper: VueWrapper) =>
wrapper.getComponent(V2vNicMappingTable).vm.$emit('changeNetwork', V2V_LAN_NIC.key, V2V_VLAN_NETWORK.id),
{ ...V2V_LIST_VM_OVERRIDES, nics: { ...V2V_NIC_OVERRIDES, [V2V_LAN_NIC.key]: V2V_VLAN_NETWORK.id } },
],
[
'the MAC addresses kept',
(wrapper: VueWrapper) => getOptionsFields(wrapper).vm.$emit('changeKeepMac', false),
{ ...V2V_LIST_VM_OVERRIDES, keepMac: false },
],
[
'the template',
(wrapper: VueWrapper) => getOptionsFields(wrapper).vm.$emit('changeTemplate', V2V_DEBIAN_TEMPLATE.id),
{ ...V2V_LIST_VM_OVERRIDES, template: V2V_DEBIAN_TEMPLATE.id },
],
[
'the tags',
(wrapper: VueWrapper) => getOptionsFields(wrapper).vm.$emit('changeTags', ['prod']),
{ ...V2V_LIST_VM_OVERRIDES, tags: ['prod'] },
],
[
'the SR of a disk reset',
(wrapper: VueWrapper) =>
wrapper.getComponent(V2vDiskMappingTable).vm.$emit('changeSr', V2V_DATA_DISK.key, undefined),
{ nics: V2V_NIC_OVERRIDES },
],
])('confirms the overrides of the VM once saved after a change of %s', async (_, change, overrides) => {
const wrapper = await mountDrawer()
change(wrapper)
await submitDrawer(wrapper)
expect(wrapper.emitted('confirm')).toStrictEqual([[overrides]])
})
it.each([
['from the cancel button', cancelDrawer],
['once dismissed', (wrapper: VueWrapper) => wrapper.getComponent(UiDrawer).vm.$emit('dismiss')],
])('cancels %s', async (_, cancel) => {
const wrapper = await mountDrawer()
await cancel(wrapper)
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('confirm')).toBeUndefined()
})

View File

@@ -0,0 +1,111 @@
<template>
<UiDrawer class="v2v-vm-settings-drawer" @confirm="emit('confirm', toOverrides())" @dismiss="emit('cancel')">
<template #title>
{{ t('action:edit-vm-settings', { name: vm.name }) }}
</template>
<template #content>
<div class="sections">
<section v-if="diskRows.length > 0" class="section">
<UiTitle>{{ t('disks') }}</UiTitle>
<V2vDiskMappingTable
:rows="diskRows"
:sr-options
:loading="!areOffersReady"
@change-sr="(key, srId) => setDiskSr(key, srId)"
/>
</section>
<section v-if="nicRows.length > 0" class="section">
<UiTitle>{{ t('network-adapters') }}</UiTitle>
<V2vNicMappingTable
:rows="nicRows"
:network-options
:loading="!areOffersReady"
@change-network="(key, networkId) => setNicNetwork(key, networkId)"
/>
</section>
<section class="section">
<UiTitle>{{ t('options') }}</UiTitle>
<V2vVmOptionsFields
:keep-mac
:template
:template-options
:tags
:loading="!areOffersReady"
@change-keep-mac="value => setKeepMac(value)"
@change-template="templateId => setTemplate(templateId)"
@change-tags="value => setTags(value)"
/>
</section>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton :disabled="!areOffersReady">
{{ t('action:save') }}
</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script lang="ts" setup>
import V2vDiskMappingTable from '@/modules/v2v/components/mapping/V2vDiskMappingTable.vue'
import V2vNicMappingTable from '@/modules/v2v/components/mapping/V2vNicMappingTable.vue'
import V2vVmOptionsFields from '@/modules/v2v/components/mapping/V2vVmOptionsFields.vue'
import { useV2vVmMappingDraft } from '@/modules/v2v/composables/use-v2v-vm-mapping-draft.composable.ts'
import type { V2vVmOverrides } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import type { V2vMigrationList } from '@/modules/v2v/types/v2v-migration-list.type.ts'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
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 { useI18n } from 'vue-i18n'
const { vm, list } = defineProps<{
vm: XoV2vVm
list: V2vMigrationList
}>()
const emit = defineEmits<{
confirm: [overrides: V2vVmOverrides]
cancel: []
}>()
const { t } = useI18n()
const {
diskRows,
nicRows,
template,
srOptions,
networkOptions,
templateOptions,
areOffersReady,
keepMac,
tags,
setDiskSr,
setNicNetwork,
setTemplate,
setKeepMac,
setTags,
toOverrides,
} = useV2vVmMappingDraft(() => vm, { list: () => list })
</script>
<style lang="postcss" scoped>
.v2v-vm-settings-drawer {
.sections {
display: flex;
flex-direction: column;
gap: 4.8rem;
}
.section {
display: flex;
flex-direction: column;
gap: 1.6rem;
}
}
</style>

View File

@@ -0,0 +1,51 @@
import V2vActiveMappingCard from '@/modules/v2v/components/mapping/V2vActiveMappingCard.vue'
import { DISKOR_POOL_NAME } from '@/test/create-pool.ts'
import { findAccentedInfos } from '@/test/find-accented-labels.ts'
import { findParts } from '@/test/find-parts.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { describeV2vMappingCardLayout } from '@/test/v2v-mapping-card.ts'
import { V2V_ACTIVE_DISK_ROWS, V2V_ACTIVE_NIC_ROWS } from '@/test/v2v-mapping-fixtures.ts'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { mount, type VueWrapper } from '@vue/test-utils'
function mountCard({ diskRows = V2V_ACTIVE_DISK_ROWS, nicRows = V2V_ACTIVE_NIC_ROWS, loading = false } = {}) {
return mount(V2vActiveMappingCard, {
props: { poolName: DISKOR_POOL_NAME, diskRows, nicRows, loading },
global: createGlobalTestConfig(),
})
}
describeV2vMappingCardLayout({ mountCard, title: t('active-mapping') })
// Each device: its source parts as its key, its destination as its value
function findDevices(wrapper: VueWrapper) {
return wrapper.findAll('.v2v-mapping-card-section .vts-card-row-key-value').map(row => ({
sourceParts: findParts(row, '.key .inline-parts'),
destination: row.get('.value').text(),
}))
}
it('shows its title, locked', () => {
const title = mountCard().getComponent(UiPanelCardTitle)
expect([title.props('label'), title.props('icon')]).toEqual([t('active-mapping'), 'fa:lock'])
})
it('tells the mapping is locked since the first sync', () => {
expect(findAccentedInfos(mountCard())).toEqual([{ label: t('v2v:locked-since-first-sync'), accent: 'info' }])
})
it('names the pool the VM was synced to', () => {
expect(findCardLabelledValues(mountCard())[t('pool')]).toBe(DISKOR_POOL_NAME)
})
it('shows each source device with its details, then its destination, empty for an unpaired one', () => {
expect(findDevices(mountCard())).toEqual(
[...V2V_ACTIVE_DISK_ROWS, ...V2V_ACTIVE_NIC_ROWS].map(row => ({
sourceParts: row.sourceParts,
destination: row.destination ?? '',
}))
)
})

View File

@@ -0,0 +1,58 @@
<template>
<UiPanelCard class="v2v-active-mapping-card">
<UiPanelCardTitle size="medium" icon="fa:lock" :label="t('active-mapping')" />
<UiInfo accent="info" wrap>{{ t('v2v:locked-since-first-sync') }}</UiInfo>
<VtsCardRowKeyValue>
<template #key>{{ t('pool') }}</template>
<template #value>{{ poolName }}</template>
</VtsCardRowKeyValue>
<template v-if="loading">
<VtsDivider type="stretch" />
<VtsStateHero format="card" type="busy" size="extra-small" horizontal />
</template>
<template v-else>
<V2vMappingCardSection
v-for="section of sections"
:key="section.label"
:label="section.label"
:rows="section.rows"
>
<template #key="{ row }">
<InlineParts>
<span v-for="part of row.sourceParts" :key="part">{{ part }}</span>
</InlineParts>
</template>
<template #value="{ row }">{{ row.destination }}</template>
</V2vMappingCardSection>
</template>
</UiPanelCard>
</template>
<script lang="ts" setup>
import V2vMappingCardSection from '@/modules/v2v/components/mapping/V2vMappingCardSection.vue'
import type { V2vActiveMappingRow } from '@/modules/v2v/types/v2v-mapping.type.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
// A resumed VM has no drawer: the source details of its devices are shown here, beside their locked destination
const { diskRows, nicRows } = defineProps<{
poolName: string
diskRows: V2vActiveMappingRow[]
nicRows: V2vActiveMappingRow[]
loading: boolean
}>()
const { t } = useI18n()
const sections = computed(() => [
{ label: t('disks'), rows: diskRows },
{ label: t('network-adapters'), rows: nicRows },
])
</script>

View File

@@ -0,0 +1,45 @@
import V2vDestinationMappingCard from '@/modules/v2v/components/mapping/V2vDestinationMappingCard.vue'
import { DISKOR_POOL_NAME } from '@/test/create-pool.ts'
import { findAllGenericComponents } from '@/test/find-generic-components.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { describeV2vMappingCardLayout } from '@/test/v2v-mapping-card.ts'
import { V2V_DISK_ROWS, V2V_NIC_ROWS, V2V_TARGET_OPTIONS } from '@/test/v2v-mapping-fixtures.ts'
import { mount, type VueWrapper } from '@vue/test-utils'
function mountCard({ diskRows = V2V_DISK_ROWS, nicRows = V2V_NIC_ROWS, loading = false } = {}) {
return mount(V2vDestinationMappingCard, {
props: {
poolName: DISKOR_POOL_NAME,
diskRows,
nicRows,
srOptions: V2V_TARGET_OPTIONS.srs,
networkOptions: V2V_TARGET_OPTIONS.networks,
loading,
},
global: createGlobalTestConfig(),
})
}
describeV2vMappingCardLayout({ mountCard, title: t('destination') })
function findTargetValues(wrapper: VueWrapper) {
return findAllGenericComponents(wrapper, 'V2vTargetValue').map(value => value.props())
}
it('names the pool the list sets, then each disk and each network adapter', () => {
expect(Object.keys(findCardLabelledValues(mountCard()))).toEqual([
t('pool'),
...V2V_DISK_ROWS.map(row => row.label),
...V2V_NIC_ROWS.map(row => row.label),
])
expect(findCardLabelledValues(mountCard())[t('pool')]).toBe(DISKOR_POOL_NAME)
})
it('maps each disk to its SR, then each network adapter to its network', () => {
expect(findTargetValues(mountCard())).toEqual([
...V2V_DISK_ROWS.map(row => ({ target: row.sr, options: V2V_TARGET_OPTIONS.srs })),
...V2V_NIC_ROWS.map(row => ({ target: row.network, options: V2V_TARGET_OPTIONS.networks })),
])
})

View File

@@ -0,0 +1,54 @@
<template>
<UiPanelCard class="v2v-destination-mapping-card">
<UiPanelCardTitle size="medium" :label="t('destination')" />
<VtsCardRowKeyValue>
<template #key>{{ t('pool') }}</template>
<template #value>{{ poolName }}</template>
</VtsCardRowKeyValue>
<template v-if="loading">
<VtsDivider type="stretch" />
<VtsStateHero format="card" type="busy" size="extra-small" horizontal />
</template>
<template v-else>
<V2vMappingCardSection :label="t('disks')" :rows="diskRows">
<template #key="{ row }">{{ row.label }}</template>
<template #value="{ row }">
<V2vTargetValue :target="row.sr" :options="srOptions" />
</template>
</V2vMappingCardSection>
<V2vMappingCardSection :label="t('network-adapters')" :rows="nicRows">
<template #key="{ row }">{{ row.label }}</template>
<template #value="{ row }">
<V2vTargetValue :target="row.network" :options="networkOptions" />
</template>
</V2vMappingCardSection>
</template>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import V2vMappingCardSection from '@/modules/v2v/components/mapping/V2vMappingCardSection.vue'
import V2vTargetValue from '@/modules/v2v/components/mapping/V2vTargetValue.vue'
import type { V2vDiskMappingRow, V2vNicMappingRow, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { useI18n } from 'vue-i18n'
// The mapping of a VM as the side panel shows it: the settings drawer edits it. Until the pool offers load, a target
// could only be named by its id
defineProps<{
poolName: string
diskRows: V2vDiskMappingRow[]
nicRows: V2vNicMappingRow[]
srOptions: V2vTargetOption<FrontXoSr['id']>[]
networkOptions: V2vTargetOption<FrontXoNetwork['id']>[]
loading: boolean
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,55 @@
import V2vDestinationVmCard from '@/modules/v2v/components/mapping/V2vDestinationVmCard.vue'
import type { V2vDestinationVm } from '@/modules/v2v/composables/use-v2v-resumed-vm.composable.ts'
import { DISKOR_POOL_NAME } from '@/test/create-pool.ts'
import { findAccentedInfos } from '@/test/find-accented-labels.ts'
import { findPanelCardTitle } from '@/test/find-card-title.ts'
import { findParts } from '@/test/find-parts.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { V2V_RESUMED_IMPORT_VM } from '@/test/v2v-mapping-fixtures.ts'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { objectIcon } from '@core/icons'
import { V2V_PARTIAL_IMPORT_TAG } from '@vates/types'
import { mount } from '@vue/test-utils'
const VM: V2vDestinationVm = {
id: V2V_RESUMED_IMPORT_VM.id,
name: 'app-03',
icon: objectIcon('vm', 'halted'),
powerLabel: 'Halted',
}
function mountCard() {
return mount(V2vDestinationVmCard, {
props: { vm: VM, poolName: DISKOR_POOL_NAME },
global: createGlobalTestConfig(),
})
}
it('shows its title', () => {
expect(findPanelCardTitle(mountCard())).toBe(t('destination-vm'))
})
it('links the VM to its page, with the icon of its power state', () => {
const link = mountCard().getComponent(UiLink)
expect([link.text(), link.attributes('href'), link.props('icon')]).toEqual([
'app-03',
`/vm/${V2V_RESUMED_IMPORT_VM.id}/dashboard`,
objectIcon('vm', 'halted'),
])
})
it('lays out the pool of the VM, its power state and its blocked start', () => {
expect(findParts(mountCard(), '.inline-parts')).toEqual([
t('pool-named', { name: DISKOR_POOL_NAME }),
'Halted',
t('v2v:start-blocked-partial'),
])
})
it('tells the VM is excluded from backups until the cutover', () => {
expect(findAccentedInfos(mountCard())).toEqual([
{ label: t('v2v:no-bak-info', { tag: V2V_PARTIAL_IMPORT_TAG }), accent: 'info' },
])
})

View File

@@ -0,0 +1,52 @@
<template>
<UiPanelCard class="v2v-destination-vm-card">
<UiPanelCardTitle size="medium" :label="t('destination-vm')" />
<div class="content">
<div class="vm">
<UiLink size="medium" :icon="vm.icon" :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
{{ vm.name }}
</UiLink>
<InlineParts class="typo-body-regular-small">
<span>{{ t('pool-named', { name: poolName }) }}</span>
<span>{{ vm.powerLabel }}</span>
<span>{{ t('v2v:start-blocked-partial') }}</span>
</InlineParts>
</div>
<UiInfo accent="info" wrap>{{ t('v2v:no-bak-info', { tag: V2V_PARTIAL_IMPORT_TAG }) }}</UiInfo>
</div>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { V2vDestinationVm } from '@/modules/v2v/composables/use-v2v-resumed-vm.composable.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { V2V_PARTIAL_IMPORT_TAG } from '@vates/types'
import { useI18n } from 'vue-i18n'
defineProps<{
vm: V2vDestinationVm
poolName: string
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-destination-vm-card {
.content {
display: flex;
flex-direction: column;
gap: 1.6rem;
}
.vm {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,45 @@
import V2vDiskMappingTable from '@/modules/v2v/components/mapping/V2vDiskMappingTable.vue'
import { findTableColumn, findTableHeaders } from '@/test/find-table-cells.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { V2V_DISK_ROWS, V2V_TARGET_OPTIONS } from '@/test/v2v-mapping-fixtures.ts'
import { describeV2vTargetCells } from '@/test/v2v-target-cells.ts'
import { mount } from '@vue/test-utils'
function mountTable() {
return mount(V2vDiskMappingTable, {
props: { rows: V2V_DISK_ROWS, srOptions: V2V_TARGET_OPTIONS.srs, loading: true },
global: createGlobalTestConfig(),
})
}
it('shows the disk, its datastore, its size, its target SR and where it comes from, in this order', () => {
expect(findTableHeaders(mountTable())).toEqual([
t('disk'),
t('datastore'),
t('size'),
t('target-sr'),
t('value-from'),
])
})
it('names each disk and its datastore', () => {
const wrapper = mountTable()
expect([findTableColumn(wrapper, t('disk')), findTableColumn(wrapper, t('datastore'))]).toEqual([
['Hard disk 1', 'Hard disk 2'],
['ds-prod-01', 'ds-prod-01'],
])
})
it('shows the size of each disk', () => {
expect(findTableColumn(mountTable(), t('size'))).toEqual(['80GiB', '80GiB'])
})
describeV2vTargetCells({
mountTable,
targetHeader: t('target-sr'),
changeEvent: 'changeSr',
rows: V2V_DISK_ROWS.map(({ key, sr }) => ({ key, target: sr })),
options: V2V_TARGET_OPTIONS.srs,
})

View File

@@ -0,0 +1,63 @@
<template>
<VtsTable>
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="row of rows" :key="row.key">
<BodyCells :item="row" />
</VtsRow>
</tbody>
</VtsTable>
</template>
<script lang="ts" setup>
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { renderV2vTargetCells } from '@/modules/v2v/components/mapping/v2v-target-cells.ts'
import type { V2vDiskMappingRow, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import { renderSizeCell } from '@/shared/utils/size-cell.util.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useNumberColumn } from '@core/tables/column-definitions/number-column.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import { useI18n } from 'vue-i18n'
const { srOptions, loading } = defineProps<{
rows: V2vDiskMappingRow[]
srOptions: V2vTargetOption<FrontXoSr['id']>[]
loading: boolean
}>()
const emit = defineEmits<{
changeSr: [key: V2vDiskMappingRow['key'], srId: FrontXoSr['id'] | undefined]
}>()
const useDiskMappingColumns = defineColumns(() => {
const { t } = useI18n()
return {
disk: useTextColumn({ headerLabel: () => t('disk') }),
datastore: useTextColumn({ headerLabel: () => t('datastore') }),
size: useNumberColumn({ headerLabel: () => t('size') }),
target: useTextColumn({ headerLabel: () => t('target-sr') }),
origin: useTextColumn({ headerLabel: () => t('value-from') }),
}
})
const { HeadCells, BodyCells } = useDiskMappingColumns({
body: (row: V2vDiskMappingRow) => ({
disk: renderer => renderer(row.label),
datastore: renderer => renderer(row.datastore),
size: renderer => renderSizeCell(row.size, renderer),
...renderV2vTargetCells(() => ({
target: row.sr,
options: srOptions,
loading,
onChange: srId => emit('changeSr', row.key, srId),
})),
}),
})
</script>

View File

@@ -0,0 +1,36 @@
import V2vMappingCardSection from '@/modules/v2v/components/mapping/V2vMappingCardSection.vue'
import { findPanelCardTitles } from '@/test/find-card-title.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { countDividers } from '@/test/v2v-mapping-card.ts'
import { mount } from '@vue/test-utils'
const ROWS = [
{ key: 'disk-1', source: 'Hard disk 1', target: 'sr-a' },
{ key: 'disk-2', source: 'Hard disk 2', target: 'sr-b' },
]
function mountSection(rows = ROWS) {
return mount(V2vMappingCardSection<(typeof ROWS)[number]>, {
props: { label: 'Disks', rows },
slots: {
key: `<template #key="{ row }">{{ row.source }}</template>`,
value: `<template #value="{ row }">{{ row.target }}</template>`,
},
global: createGlobalTestConfig(),
})
}
it('sets itself apart, under its title', () => {
const wrapper = mountSection()
expect([countDividers(wrapper), findPanelCardTitles(wrapper)]).toEqual([1, ['Disks']])
})
it('shows each row as the key and the value it renders', () => {
expect(findCardLabelledValues(mountSection())).toEqual({ 'Hard disk 1': 'sr-a', 'Hard disk 2': 'sr-b' })
})
it('shows nothing, not even its divider, without rows', () => {
expect(mountSection([]).html()).toBe('<!--v-if-->')
})

View File

@@ -0,0 +1,41 @@
<template>
<template v-if="rows.length > 0">
<VtsDivider type="stretch" />
<div class="v2v-mapping-card-section">
<UiPanelCardTitle size="small" :label />
<VtsCardRowKeyValue v-for="row of rows" :key="row.key">
<template #key>
<slot name="key" :row />
</template>
<template #value>
<slot name="value" :row />
</template>
</VtsCardRowKeyValue>
</div>
</template>
</template>
<script generic="TRow extends { key: string }" lang="ts" setup>
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
// A section of a mapping card: one row per device of a kind, without the section for a VM without any
defineProps<{
label: string
rows: TRow[]
}>()
defineSlots<{
key(props: { row: TRow }): any
value(props: { row: TRow }): any
}>()
</script>
<style lang="postcss" scoped>
.v2v-mapping-card-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<V2vVmKindActions v-slot="{ mainAction, moreActions }" :vm :update-vm @remove="id => emit('remove', id)">
<VtsActionItems :actions="getV2vVmMenuActions(mainAction, moreActions)" />
</V2vVmKindActions>
</template>
<script lang="ts" setup>
import V2vVmKindActions from '@/modules/v2v/components/panel/V2vVmKindActions.vue'
import type { V2vMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import type { V2vVmMappingContext } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import { getV2vVmMenuActions } from '@/modules/v2v/utils/v2v-vm-actions.util.ts'
import VtsActionItems from '@core/components/menu/VtsActionItems.vue'
import type { XoV2vVm } from '@vates/types'
defineProps<{
vm: V2vMappingStepVm
updateVm: V2vVmMappingContext['updateVm']
}>()
const emit = defineEmits<{
remove: [id: XoV2vVm['id']]
}>()
</script>

View File

@@ -0,0 +1,203 @@
import V2vMappingStep from '@/modules/v2v/components/mapping/V2vMappingStep.vue'
import V2vMappingTable from '@/modules/v2v/components/mapping/V2vMappingTable.vue'
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 { readV2vStoredListVm } from '@/test/mount-v2v-vm-mapping.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'
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('@core/composables/modals/use-delete-modal.ts'), () => import('@/test/mock-delete-modal.ts'))
// 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(
setup =>
mountOnV2vWizard(
(router, provide) =>
mount(V2vMappingStep, {
global: createGlobalTestConfig({
router,
provide,
stubs: {
V2vMappingTable: true,
V2vVmSidePanel: true,
},
}),
}),
setup
),
{ vm }
)
return { wrapper, router }
}
function getTable(wrapper: VueWrapper) {
return wrapper.getComponent(V2vMappingTable)
}
function getVmPanel(wrapper: VueWrapper) {
return wrapper.getComponent(V2vVmSidePanel)
}
beforeEach(() => {
v2vVmRowsCollectionsMock.reset()
xoCollectionsMock.reset()
deleteModalMock.reset()
deleteModalMock.answer('onConfirm')
})
describe('table', () => {
it('lists the VMs of the list', async () => {
const { wrapper } = await mountStep()
expect(
getTable(wrapper)
.props('rows')
.map(row => row.vm.id)
).toEqual([V2V_WEB_VM.id, V2V_DB_VM.id, V2V_GONE_VM_ID])
})
it('selects a VM of the table', async () => {
const { wrapper, router } = await mountStep()
getTable(wrapper).vm.$emit('select', V2V_DB_VM.id)
await flushPromises()
expect(findV2vRouteQuery(router).vm).toBe(V2V_DB_VM.id)
})
it('shows the step busy, without table, while the VMs load', async () => {
v2vVmRowsCollectionsMock.areVmsReady.value = false
const { wrapper } = await mountStep(V2V_WEB_VM.id)
expect(wrapper.getComponent(VtsStateHero).props('type')).toBe('busy')
expect(wrapper.findComponent(V2vMappingTable).exists()).toBe(false)
})
})
describe('VM panel', () => {
it('shows the selected VM in its panel', async () => {
const { wrapper } = await mountStep(V2V_DB_VM.id)
expect(getVmPanel(wrapper).props('vm')?.id).toBe(V2V_DB_VM.id)
})
it.each([
['without a VM in the route', undefined],
['for a VM out of the list', 'vm-unknown'],
])('shows the panel without VM %s', async (_, vm) => {
const { wrapper } = await mountStep(vm)
expect(getVmPanel(wrapper).props('vm')).toBeUndefined()
})
it('closes, selecting no VM', async () => {
const { wrapper, router } = await mountStep(V2V_DB_VM.id)
getVmPanel(wrapper).vm.$emit('close')
await flushPromises()
expect(findV2vRouteQuery(router).vm).toBeUndefined()
expect(getVmPanel(wrapper).props('vm')).toBeUndefined()
})
it('lays the panel out as the side panel of the step, beside the table', async () => {
const layout = (await mountStep()).wrapper.getComponent(VtsContentSidePanel)
expect(layout.findComponent(V2vVmSidePanel).exists()).toBe(true)
expect(layout.findComponent(V2vMappingTable).exists()).toBe(true)
})
})
// The table and the VM panel edit the settings of a VM and remove a VM of the list, the selected one then closed
describe('list changes', () => {
it.each([
['the table', (wrapper: VueWrapper) => getTable(wrapper).props('updateVm')],
['the VM panel', (wrapper: VueWrapper) => getVmPanel(wrapper).props('updateVm')],
])('stores the settings of a VM in the list from %s', async (_, getUpdateVm) => {
const { wrapper } = await mountStep(V2V_DB_VM.id)
getUpdateVm(wrapper)(V2V_WEB_VM.id, vm => ({ ...vm, keepMac: false }))
expect(readV2vStoredListVm()).toEqual({ id: V2V_WEB_VM.id, keepMac: false })
})
it.each([
['the table', getTable],
['the VM panel', getVmPanel],
])('removes the selected VM of the list from %s, selecting no VM', async (_, getRemover) => {
const { wrapper, router } = await mountStep(V2V_DB_VM.id)
getRemover(wrapper).vm.$emit('remove', V2V_DB_VM.id)
await flushPromises()
expect(readV2vMigrationListVmIds()).toEqual([V2V_WEB_VM.id, V2V_GONE_VM_ID])
expect(findV2vRouteQuery(router).vm).toBeUndefined()
})
})
describe('navigation', () => {
it('goes back to the VMs', async () => {
const { wrapper, router } = await mountStep(V2V_WEB_VM.id)
await getButton(wrapper, t('action:back')).trigger('click')
await flushPromises()
expect(findV2vRouteQuery(router).step).toBe('vms')
})
it('goes on to the review, even with an incomplete mapping', async () => {
const { wrapper, router } = await mountStep(V2V_WEB_VM.id)
xoCollectionsMock.srs.value = []
await flushPromises()
const nextButton = getUiButton(wrapper, t('action:next-review'))
await nextButton.trigger('click')
await flushPromises()
expect(nextButton.props('disabled')).toBeFalsy()
expect(findV2vRouteQuery(router).step).toBe('review')
})
})

View File

@@ -0,0 +1,41 @@
<template>
<VtsContentSidePanel>
<div class="v2v-mapping-step">
<VtsStateHero v-if="isBusy" format="page" type="busy" size="medium" />
<UiCard v-else>
<V2vMappingTable :rows :update-vm @select="id => select(id)" @remove="id => remove(id)" />
</UiCard>
<V2vWizardStepActions @back="back()">
<UiButton accent="brand" variant="primary" size="medium" @click="next()">
{{ t('action:next-review') }}
</UiButton>
</V2vWizardStepActions>
</div>
<V2vVmSidePanel :vm="selectedVm" :update-vm @close="close()" @remove="id => remove(id)" />
</VtsContentSidePanel>
</template>
<script lang="ts" setup>
import V2vMappingTable from '@/modules/v2v/components/mapping/V2vMappingTable.vue'
import V2vVmSidePanel from '@/modules/v2v/components/panel/V2vVmSidePanel.vue'
import V2vWizardStepActions from '@/modules/v2v/components/wizard/V2vWizardStepActions.vue'
import { useV2vMappingStep } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { isBusy, rows, selectedVm, updateVm, select, close, remove, back, next } = useV2vMappingStep()
</script>
<style lang="postcss" scoped>
.v2v-mapping-step {
display: flex;
flex-direction: column;
gap: 1.6rem;
padding: 1.6rem;
}
</style>

View File

@@ -0,0 +1,159 @@
import V2vMappingTable from '@/modules/v2v/components/mapping/V2vMappingTable.vue'
import V2vVmKindActions from '@/modules/v2v/components/panel/V2vVmKindActions.vue'
import type { V2vMappingStepRow } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { getV2vVmMenuActions } from '@/modules/v2v/utils/v2v-vm-actions.util.ts'
import {
V2V_NEW_STEP_VM,
V2V_NOT_FOUND_STEP_VM,
createV2vMappingStepRow,
createV2vResumedMappingStepVm,
} from '@/test/create-v2v-mapping-step-vm.ts'
import { findAccentedInfos, findAccentedTags } from '@/test/find-accented-labels.ts'
import { findParts } from '@/test/find-parts.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 { v2vVmActionsMock } from '@/test/mock-v2v-vm-actions.ts'
import { getV2vVmKindActionList } from '@/test/v2v-vm-kind-actions.ts'
import VtsActionItems from '@core/components/menu/VtsActionItems.vue'
import VtsActionsMenu from '@core/components/menu/VtsActionsMenu.vue'
import VtsActionCell from '@core/components/table/cells/VtsActionCell.vue'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import type { XoV2vVm } from '@vates/types'
import { mount, type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'),
() => import('@/test/mock-v2v-vm-actions.ts')
)
beforeEach(() => {
v2vVmActionsMock.reset()
})
// The synced VM is the mapped one, resumed: its row needs an id of its own
const RESUMED_VM = createV2vResumedMappingStepVm({ id: 'vm-resumed' as XoV2vVm['id'] })
// The item labels are the table input: read back as given
const ROWS: V2vMappingStepRow[] = [
createV2vMappingStepRow(V2V_NEW_STEP_VM, {
kind: { label: 'New', accent: 'neutral' },
mappingParts: ['2 disks → sr-a', '1 NIC → net'],
check: { label: 'Mapping incomplete', accent: 'danger' },
}),
createV2vMappingStepRow(RESUMED_VM, {
kind: { label: 'Resume', accent: 'info' },
mappingParts: ['Delta from last sync', 'mapping locked'],
check: { label: 'Ready', accent: 'success' },
isSelected: true,
}),
createV2vMappingStepRow(V2V_NOT_FOUND_STEP_VM, {
kind: undefined,
mappingParts: [],
check: { label: 'Not found in the source', accent: 'danger' },
}),
]
const updateVm = vi.fn()
function mountTable(rows = ROWS) {
return mount(V2vMappingTable, { props: { rows, updateVm }, global: createGlobalTestConfig() })
}
function getActionCell(wrapper: VueWrapper, rowIndex: number) {
return wrapper.findAllComponents(VtsActionCell)[rowIndex]
}
// The menu of a row renders its items once opened
async function openRowMenu(wrapper: VueWrapper, rowIndex: number) {
await getActionCell(wrapper, rowIndex).getComponent(VtsActionsMenu).getComponent(UiButtonIcon).trigger('click')
return wrapper.getComponent(VtsActionItems)
}
it('heads the columns of the VMs of the list', () => {
expect(findTableHeaders(mountTable())).toEqual([t('vm'), t('type'), t('mapping'), t('checks'), ''])
})
it('lists the VMs in the given order, by name', () => {
expect(findTableColumn(mountTable(), t('vm'))).toEqual([
V2V_NEW_STEP_VM.heading.name,
RESUMED_VM.heading.name,
V2V_NOT_FOUND_STEP_VM.heading.name,
])
})
it('tags each VM with its kind, none for a VM not found in the source, and tells its check, accented', () => {
const wrapper = mountTable()
expect(findAccentedTags(wrapper)).toEqual([ROWS[0].item.kind, ROWS[1].item.kind])
expect(findAccentedInfos(wrapper)).toEqual(ROWS.map(row => row.item.check))
})
it('sums the mapping of each VM up in parts, none for a VM without', () => {
const mappingCells = findTableColumnCells(mountTable(), t('mapping'))
expect(mappingCells.map(cell => findParts(cell, '.inline-parts'))).toEqual(ROWS.map(row => row.item.mappingParts))
})
it('marks the row of the selected VM alone', () => {
expect(
mountTable()
.findAllComponents(VtsRow)
.map(row => row.props('selected'))
).toEqual([false, true, false])
})
it('pages the VMs', () => {
expect(mountTable().getComponent(VtsTable).props('paginationBindings')?.total).toBe(ROWS.length)
})
it('tells an empty list', () => {
expect(mountTable([]).getComponent(VtsTable).props('state')?.message).toBe(t('v2v:no-vm-in-list'))
})
it('emits the selection of the VM of a row from its eye', async () => {
const wrapper = mountTable()
await getActionCell(wrapper, 1).getComponent(UiButtonIcon).trigger('click')
expect(wrapper.emitted('select')).toEqual([[RESUMED_VM.id]])
})
describe('row menu', () => {
it.each([
['a new VM', 0, v2vVmActionsMock.useV2vNewVmActions],
['a resumed VM', 1, v2vVmActionsMock.useV2vResumedVmActions],
] as const)('leads with the settings edit of %s, the other actions set apart', async (_, rowIndex, useActions) => {
const wrapper = mountTable()
const items = await openRowMenu(wrapper, rowIndex)
const { mainAction, moreActions } = getV2vVmKindActionList(useActions)
expect(items.props('actions')).toEqual(getV2vVmMenuActions(mainAction, moreActions))
})
it('offers the actions of a VM not found in the source, without separator', async () => {
const items = await openRowMenu(mountTable(), 2)
expect(items.props('actions')).toEqual(getV2vVmKindActionList(v2vVmActionsMock.useV2vNotFoundVmActions).moreActions)
})
it('runs the actions of the VM of the row, stored through the list', async () => {
const wrapper = mountTable()
await openRowMenu(wrapper, 0)
expect(wrapper.getComponent(V2vVmKindActions).props()).toEqual({ vm: V2V_NEW_STEP_VM, updateVm })
})
it('emits the removal of the VM of a row, for the step to confirm it', async () => {
const wrapper = mountTable()
await openRowMenu(wrapper, 2)
v2vVmActionsMock.useV2vNotFoundVmActions.mock.lastCall?.[1].remove(V2V_NOT_FOUND_STEP_VM.id)
expect(wrapper.emitted('remove')).toEqual([[V2V_NOT_FOUND_STEP_VM.id]])
})
})

View File

@@ -0,0 +1,86 @@
<template>
<div class="v2v-mapping-table">
<UiTitle>{{ t('selected-vms') }}</UiTitle>
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="row of pageRows" :key="row.vm.id" :selected="row.item.isSelected">
<BodyCells :item="row" />
</VtsRow>
</tbody>
</VtsTable>
</div>
</template>
<script lang="ts" setup>
import V2vMappingRowMenu from '@/modules/v2v/components/mapping/V2vMappingRowMenu.vue'
import type { V2vMappingStepRow } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import type { V2vVmMappingContext } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import { renderInlinePartsCell } from '@/shared/utils/inline-parts-cell.util.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { usePagination } from '@core/composables/pagination.composable.ts'
import { useTableState } from '@core/composables/table-state.composable.ts'
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useActionColumn } from '@core/tables/column-definitions/action-column.ts'
import { useInfoColumn } from '@core/tables/column-definitions/info-column.ts'
import { useTagColumn } from '@core/tables/column-definitions/tag-column.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import type { XoV2vVm } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rows, updateVm } = defineProps<{
rows: V2vMappingStepRow[]
updateVm: V2vVmMappingContext['updateVm']
}>()
const emit = defineEmits<{
select: [id: XoV2vVm['id']]
remove: [id: XoV2vVm['id']]
}>()
const { t } = useI18n()
const { pageRecords: pageRows, paginationBindings } = usePagination('v2v-mapping-vms', () => rows)
const state = useTableState({
empty: () => (rows.length === 0 ? t('v2v:no-vm-in-list') : false),
})
const useMappingColumns = defineColumns(() => ({
vm: useTextColumn({ headerLabel: () => t('vm') }),
kind: useTagColumn({ headerLabel: () => t('type') }),
mapping: useTextColumn({ headerLabel: () => t('mapping') }),
check: useInfoColumn({ headerLabel: () => t('checks') }),
actions: useActionColumn({}),
}))
// The kind of a VM changes once the import VM of a resumed VM is deleted: its row menu reads the actions of its kind
const { HeadCells, BodyCells } = useMappingColumns({
body: (row: V2vMappingStepRow) => ({
vm: renderer => renderer(row.item.name),
kind: renderer => renderer(row.item.kind?.label ?? [], row.item.kind?.accent),
mapping: () => renderInlinePartsCell(row.item.mappingParts),
check: renderer => renderer(row.item.check.label, row.item.check.accent),
actions: renderer =>
renderer({
onClick: () => emit('select', row.vm.id),
component: V2vMappingRowMenu,
props: { vm: row.vm, updateVm, onRemove: (id: XoV2vVm['id']) => emit('remove', id) },
}),
}),
})
</script>
<style lang="postcss" scoped>
.v2v-mapping-table {
display: flex;
flex-direction: column;
gap: 2.4rem;
}
</style>

View File

@@ -0,0 +1,50 @@
import V2vNextRunCard from '@/modules/v2v/components/mapping/V2vNextRunCard.vue'
import type { V2vNextTransfer } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import { findAccentedInfos } from '@/test/find-accented-labels.ts'
import { findPanelCardTitle } from '@/test/find-card-title.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { formatDateTime } from '@core/utils/time.util.ts'
import { mount } from '@vue/test-utils'
const LAST_SYNC_AT = Date.UTC(2026, 9, 2, 14, 40)
const REMINDER = { label: t('v2v:next-run-reminder'), accent: 'info' }
// An explicit `lastSyncAt: undefined` stands for a VM without last sync
function mountCard(props: { transfer?: V2vNextTransfer; lastSyncAt?: number } = {}) {
return mount(V2vNextRunCard, {
props: { transfer: 'delta', lastSyncAt: LAST_SYNC_AT, ...props },
global: createGlobalTestConfig(),
})
}
it('shows its title', () => {
expect(findPanelCardTitle(mountCard())).toBe(t('next-run'))
})
it.each([
['delta', t('delta-transfer')],
['full', t('full-transfer')],
] as const)('tells the %s transfer of the next run, and when the VM was last synced', (transfer, label) => {
expect(findCardLabelledValues(mountCard({ transfer }))).toEqual({
[t('type')]: label,
[t('last-sync')]: formatDateTime(LAST_SYNC_AT),
})
})
it('leaves the last sync empty for a VM without one', () => {
expect(findCardLabelledValues(mountCard({ lastSyncAt: undefined }))[t('last-sync')]).toBe('')
})
it('reminds to sync shortly before the cutover', () => {
expect(findAccentedInfos(mountCard())).toEqual([REMINDER])
})
it('warns a full transfer comes from a source snapshot changed since the last sync', () => {
expect(findAccentedInfos(mountCard({ transfer: 'full' }))).toEqual([
{ label: t('v2v:source-snapshot-changed'), accent: 'warning' },
REMINDER,
])
})

View File

@@ -0,0 +1,50 @@
<template>
<UiPanelCard class="v2v-next-run-card">
<UiPanelCardTitle size="medium" :label="t('next-run')" />
<div class="content">
<UiInfo v-if="transfer === 'full'" accent="warning" wrap>{{ t('v2v:source-snapshot-changed') }}</UiInfo>
<VtsCardRowKeyValue>
<template #key>{{ t('type') }}</template>
<template #value>{{ nextTransferLabels[transfer] }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('last-sync') }}</template>
<template #value>{{ lastSync }}</template>
</VtsCardRowKeyValue>
<UiInfo accent="info" wrap>{{ t('v2v:next-run-reminder') }}</UiInfo>
</div>
</UiPanelCard>
</template>
<script lang="ts" setup>
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import type { V2vNextTransfer } from '@/modules/v2v/types/v2v-vm-row.type.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { formatDateTime } from '@core/utils/time.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { lastSyncAt } = defineProps<{
transfer: V2vNextTransfer
lastSyncAt?: number
}>()
const { t } = useI18n()
const { nextTransferLabels } = useV2vVmLabels()
const lastSync = computed(() => (lastSyncAt === undefined ? undefined : formatDateTime(lastSyncAt)))
</script>
<style lang="postcss" scoped>
.v2v-next-run-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,52 @@
import V2vNicMappingTable from '@/modules/v2v/components/mapping/V2vNicMappingTable.vue'
import { findParts } from '@/test/find-parts.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 { V2V_NIC_ROWS, V2V_TARGET_OPTIONS } from '@/test/v2v-mapping-fixtures.ts'
import { describeV2vTargetCells } from '@/test/v2v-target-cells.ts'
import { mount } from '@vue/test-utils'
function mountTable() {
return mount(V2vNicMappingTable, {
props: {
rows: V2V_NIC_ROWS,
networkOptions: V2V_TARGET_OPTIONS.networks,
loading: true,
},
global: createGlobalTestConfig(),
})
}
it('shows the adapter, its port group, its MAC address, its target network and where it comes from, in this order', () => {
expect(findTableHeaders(mountTable())).toEqual([
t('adapter'),
t('port-group'),
t('mac-address'),
t('target-network'),
t('value-from'),
])
})
it('names each adapter with its MAC address', () => {
const wrapper = mountTable()
expect([findTableColumn(wrapper, t('adapter')), findTableColumn(wrapper, t('mac-address'))]).toEqual([
['Network adapter 1', 'Network adapter 2'],
['00:50:56:ab:cd:01', '00:50:56:00:00:02'],
])
})
it('lays out the port group of each adapter, empty without one', () => {
const cells = findTableColumnCells(mountTable(), t('port-group'))
expect(cells.map(cell => findParts(cell, '.inline-parts'))).toEqual(V2V_NIC_ROWS.map(row => row.portGroupParts))
})
describeV2vTargetCells({
mountTable,
targetHeader: t('target-network'),
changeEvent: 'changeNetwork',
rows: V2V_NIC_ROWS.map(({ key, network }) => ({ key, target: network })),
options: V2V_TARGET_OPTIONS.networks,
})

View File

@@ -0,0 +1,62 @@
<template>
<VtsTable>
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="row of rows" :key="row.key">
<BodyCells :item="row" />
</VtsRow>
</tbody>
</VtsTable>
</template>
<script lang="ts" setup>
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { renderV2vTargetCells } from '@/modules/v2v/components/mapping/v2v-target-cells.ts'
import type { V2vNicMappingRow, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import { renderInlinePartsCell } from '@/shared/utils/inline-parts-cell.util.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import { useI18n } from 'vue-i18n'
const { networkOptions, loading } = defineProps<{
rows: V2vNicMappingRow[]
networkOptions: V2vTargetOption<FrontXoNetwork['id']>[]
loading: boolean
}>()
const emit = defineEmits<{
changeNetwork: [key: V2vNicMappingRow['key'], networkId: FrontXoNetwork['id'] | undefined]
}>()
const useNicMappingColumns = defineColumns(() => {
const { t } = useI18n()
return {
adapter: useTextColumn({ headerLabel: () => t('adapter') }),
portGroup: useTextColumn({ headerLabel: () => t('port-group') }),
mac: useTextColumn({ headerLabel: () => t('mac-address') }),
target: useTextColumn({ headerLabel: () => t('target-network') }),
origin: useTextColumn({ headerLabel: () => t('value-from') }),
}
})
const { HeadCells, BodyCells } = useNicMappingColumns({
body: (row: V2vNicMappingRow) => ({
adapter: renderer => renderer(row.label),
portGroup: () => renderInlinePartsCell(row.portGroupParts),
mac: renderer => renderer(row.macAddress),
...renderV2vTargetCells(() => ({
target: row.network,
options: networkOptions,
loading,
onChange: networkId => emit('changeNetwork', row.key, networkId),
})),
}),
})
</script>

View File

@@ -0,0 +1,17 @@
import V2vTargetSelect from '@/modules/v2v/components/mapping/V2vTargetSelect.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { openSelect, pickSelectOption } from '@/test/select.ts'
import { V2V_SSD_SR, V2V_TARGET_OPTIONS } from '@/test/v2v-mapping-fixtures.ts'
import { mount } from '@vue/test-utils'
it('emits the id of the target picked', async () => {
const wrapper = mount(V2vTargetSelect, {
props: { options: V2V_TARGET_OPTIONS.srs, modelValue: V2V_TARGET_OPTIONS.srs[0].id },
global: createGlobalTestConfig(),
})
await openSelect(wrapper)
await pickSelectOption(wrapper, V2V_SSD_SR.name_label)
expect(wrapper.emitted('update:modelValue')).toEqual([[V2V_SSD_SR.id]])
})

View File

@@ -0,0 +1,23 @@
<template>
<VtsSelect :id accent="brand" />
</template>
<script lang="ts" setup generic="TId extends string">
import type { V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import { useFormSelect } from '@core/packages/form-select'
const { options, loading } = defineProps<{
options: V2vTargetOption<TId>[]
loading?: boolean
}>()
const model = defineModel<TId>()
const { id } = useFormSelect(() => options, {
model,
loading: () => loading,
searchable: true,
option: { value: 'id' },
})
</script>

View File

@@ -0,0 +1,23 @@
import V2vTargetValue from '@/modules/v2v/components/mapping/V2vTargetValue.vue'
import V2vValueOrigin from '@/modules/v2v/components/mapping/V2vValueOrigin.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { V2V_SSD_SR, V2V_TARGET_OPTIONS, createV2vOfferedTarget } from '@/test/v2v-mapping-fixtures.ts'
import { mount } from '@vue/test-utils'
const TARGET = createV2vOfferedTarget(V2V_SSD_SR.id, 'overridden')
function mountValue(options = V2V_TARGET_OPTIONS.srs) {
return mount(V2vTargetValue, { props: { target: TARGET, options }, global: createGlobalTestConfig() })
}
it('names the target, and where it comes from, without a reset', () => {
const wrapper = mountValue()
expect(wrapper.text()).toBe(`${V2V_SSD_SR.name_label} ${t('overridden')}`)
expect(wrapper.getComponent(V2vValueOrigin).props()).toEqual({ origin: 'overridden', readonly: true })
})
it('names a target the pool does not offer, or not yet, by its id', () => {
expect(mountValue([]).text()).toBe(`${V2V_SSD_SR.id} ${t('overridden')}`)
})

View File

@@ -0,0 +1,29 @@
<template>
<div class="v2v-target-value">
{{ label }}
<V2vValueOrigin :origin="target.origin" readonly />
</div>
</template>
<script generic="TId extends string" lang="ts" setup>
import V2vValueOrigin from '@/modules/v2v/components/mapping/V2vValueOrigin.vue'
import type { V2vResolvedTarget, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import { getV2vTargetOptionLabel } from '@/modules/v2v/utils/v2v-mapping.util.ts'
import { computed } from 'vue'
const { target, options } = defineProps<{
target: V2vResolvedTarget<TId>
options: V2vTargetOption<TId>[]
}>()
const label = computed(() => getV2vTargetOptionLabel(options, target.value))
</script>
<style lang="postcss" scoped>
.v2v-target-value {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem;
}
</style>

View File

@@ -0,0 +1,34 @@
import V2vValueOrigin from '@/modules/v2v/components/mapping/V2vValueOrigin.vue'
import type { V2vTargetOrigin } from '@/modules/v2v/types/v2v-mapping.type.ts'
import { findAccentedTags } from '@/test/find-accented-labels.ts'
import { findButton, getButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountOrigin(origin: V2vTargetOrigin, { readonly = false } = {}) {
return mount(V2vValueOrigin, { props: { origin, readonly }, global: createGlobalTestConfig() })
}
it.each([
['default', 'global-default', 'neutral'],
['overridden', 'overridden', 'warning'],
] as const)('tags a %s value', (origin, labelKey, accent) => {
expect(findAccentedTags(mountOrigin(origin))).toEqual([{ label: t(labelKey), accent }])
})
it('offers no reset of a default value', () => {
expect(findButton(mountOrigin('default'), t('action:reset-to-default'))).toBeUndefined()
})
it('resets an overridden value', async () => {
const wrapper = mountOrigin('overridden')
await getButton(wrapper, t('action:reset-to-default')).trigger('click')
expect(wrapper.emitted('reset')).toEqual([[]])
})
it('offers no reset of a read-only overridden value', () => {
expect(findButton(mountOrigin('overridden', { readonly: true }), t('action:reset-to-default'))).toBeUndefined()
})

View File

@@ -0,0 +1,46 @@
<template>
<div class="v2v-value-origin">
<UiTag :accent="tag.accent" variant="secondary">{{ tag.label }}</UiTag>
<UiButton
v-if="origin === 'overridden' && !readonly"
accent="brand"
variant="tertiary"
size="small"
@click="emit('reset')"
>
{{ t('action:reset-to-default') }}
</UiButton>
</div>
</template>
<script lang="ts" setup>
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import type { V2vTargetOrigin } from '@/modules/v2v/types/v2v-mapping.type.ts'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { origin } = defineProps<{
origin: V2vTargetOrigin
readonly?: boolean
}>()
const emit = defineEmits<{
reset: []
}>()
const { t } = useI18n()
const { getOriginTag } = useV2vVmLabels()
const tag = computed(() => getOriginTag(origin))
</script>
<style lang="postcss" scoped>
.v2v-value-origin {
display: flex;
align-items: center;
gap: 0.8rem;
}
</style>

View File

@@ -0,0 +1,23 @@
import V2vVmChecksCard from '@/modules/v2v/components/mapping/V2vVmChecksCard.vue'
import { findAccentedInfos } from '@/test/find-accented-labels.ts'
import { findPanelCardTitle } from '@/test/find-card-title.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
const CHECKS = [
{ label: 'No unsupported disk', accent: 'success' },
{ label: '2 snapshots on the source VM', accent: 'warning' },
] as const
function mountCard() {
return mount(V2vVmChecksCard, { props: { checks: [...CHECKS] }, global: createGlobalTestConfig() })
}
it('shows its title', () => {
expect(findPanelCardTitle(mountCard())).toBe(t('checks'))
})
it('tells each check with its accent, in order', () => {
expect(findAccentedInfos(mountCard())).toEqual(CHECKS)
})

View File

@@ -0,0 +1,32 @@
<template>
<UiPanelCard class="v2v-vm-checks-card">
<UiPanelCardTitle size="medium" :label="t('checks')" />
<div class="content">
<UiInfo v-for="check of checks" :key="check.label" :accent="check.accent" wrap>{{ check.label }}</UiInfo>
</div>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { V2vAccentedLabel } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import UiInfo, { type InfoAccent } from '@core/components/ui/info/UiInfo.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
checks: V2vAccentedLabel<InfoAccent>[]
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-vm-checks-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,93 @@
import V2vValueOrigin from '@/modules/v2v/components/mapping/V2vValueOrigin.vue'
import V2vVmOptionsFields from '@/modules/v2v/components/mapping/V2vVmOptionsFields.vue'
import { findAccentedInfos } from '@/test/find-accented-labels.ts'
import { getButton } from '@/test/get-button.ts'
import { getCheckbox } from '@/test/get-checkbox.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { V2V_DEBIAN_TEMPLATE, V2V_TARGET_OPTIONS, createV2vOfferedTarget } from '@/test/v2v-mapping-fixtures.ts'
import { getV2vTargetSelect } from '@/test/v2v-target-cells.ts'
import VtsTagsInput from '@core/components/tags-input/VtsTagsInput.vue'
import UiCheckbox from '@core/components/ui/checkbox/UiCheckbox.vue'
import { V2V_PARTIAL_IMPORT_TAG } from '@vates/types'
import { mount } from '@vue/test-utils'
const TEMPLATE = createV2vOfferedTarget(V2V_DEBIAN_TEMPLATE.id, 'overridden')
const TAGS = ['migrated-from:vmware', 'web']
function mountFields(keepMac = true) {
return mount(V2vVmOptionsFields, {
props: {
keepMac,
template: TEMPLATE,
templateOptions: V2V_TARGET_OPTIONS.templates,
tags: TAGS,
loading: true,
},
global: createGlobalTestConfig(),
})
}
describe('keep MAC addresses', () => {
it('shows whether the MAC addresses are kept', () => {
expect(mountFields().getComponent(UiCheckbox).props('modelValue')).toBe(true)
})
it.each([true, false])('emits the choice of %s', async keepMac => {
const wrapper = mountFields(!keepMac)
await getCheckbox(wrapper, t('keep-mac-addresses')).setValue(keepMac)
expect(wrapper.emitted('changeKeepMac')).toEqual([[keepMac]])
})
})
describe('template', () => {
it('shows the template among the offered ones, and where it comes from', () => {
const wrapper = mountFields()
expect(getV2vTargetSelect(wrapper).props()).toEqual({
modelValue: TEMPLATE.value,
options: V2V_TARGET_OPTIONS.templates,
loading: true,
})
expect(wrapper.getComponent(V2vValueOrigin).props('origin')).toBe(TEMPLATE.origin)
})
it('emits the template chosen', () => {
const wrapper = mountFields()
getV2vTargetSelect(wrapper).vm.$emit('update:modelValue', V2V_TARGET_OPTIONS.templates[0].id)
expect(wrapper.emitted('changeTemplate')).toEqual([[V2V_TARGET_OPTIONS.templates[0].id]])
})
it('emits the reset of the template', async () => {
const wrapper = mountFields()
await getButton(wrapper, t('action:reset-to-default')).trigger('click')
expect(wrapper.emitted('changeTemplate')).toEqual([[undefined]])
})
})
describe('tags', () => {
it('edits the tags of the destination VM', () => {
expect(mountFields().getComponent(VtsTagsInput).props('modelValue')).toEqual(TAGS)
})
it('emits the tags edited', () => {
const wrapper = mountFields()
wrapper.getComponent(VtsTagsInput).vm.$emit('update:modelValue', ['web'])
expect(wrapper.emitted('changeTags')).toEqual([[['web']]])
})
it('tells how the tags and the backups go together until the cutover', () => {
expect(findAccentedInfos(mountFields())).toEqual([
{ label: t('v2v:tags-backup-info', { tag: V2V_PARTIAL_IMPORT_TAG }), accent: 'info' },
])
})
})

View File

@@ -0,0 +1,72 @@
<template>
<div class="v2v-vm-options-fields">
<UiCheckbox
:model-value="keepMac"
accent="brand"
@update:model-value="value => emit('changeKeepMac', value === true)"
>
{{ t('keep-mac-addresses') }}
</UiCheckbox>
<VtsInputWrapper :label="t('template')">
<div class="template">
<V2vTargetSelect
:model-value="template.value"
:options="templateOptions"
:loading
@update:model-value="templateId => emit('changeTemplate', templateId)"
/>
<V2vValueOrigin :origin="template.origin" @reset="emit('changeTemplate', undefined)" />
</div>
</VtsInputWrapper>
<VtsInputWrapper
:label="t('tags')"
:message="{ content: t('v2v:tags-backup-info', { tag: V2V_PARTIAL_IMPORT_TAG }), accent: 'info' }"
wrap-message
>
<VtsTagsInput :model-value="tags" accent="brand" @update:model-value="value => emit('changeTags', value)" />
</VtsInputWrapper>
</div>
</template>
<script lang="ts" setup>
import V2vTargetSelect from '@/modules/v2v/components/mapping/V2vTargetSelect.vue'
import V2vValueOrigin from '@/modules/v2v/components/mapping/V2vValueOrigin.vue'
import type { V2vResolvedTarget, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import type { FrontXoVmTemplate } from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsTagsInput from '@core/components/tags-input/VtsTagsInput.vue'
import UiCheckbox from '@core/components/ui/checkbox/UiCheckbox.vue'
import { V2V_PARTIAL_IMPORT_TAG } from '@vates/types'
import { useI18n } from 'vue-i18n'
defineProps<{
keepMac: boolean
template: V2vResolvedTarget<FrontXoVmTemplate['id']>
templateOptions: V2vTargetOption<FrontXoVmTemplate['id']>[]
tags: string[]
loading: boolean
}>()
const emit = defineEmits<{
changeKeepMac: [value: boolean]
changeTemplate: [templateId: FrontXoVmTemplate['id'] | undefined]
changeTags: [tags: string[]]
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-vm-options-fields {
display: flex;
flex-direction: column;
gap: 2.4rem;
.template {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,40 @@
import V2vVmSourceCard from '@/modules/v2v/components/mapping/V2vVmSourceCard.vue'
import { createV2vVm } from '@/test/create-v2v-inventory.ts'
import { findPanelCardTitle } from '@/test/find-card-title.ts'
import { findParts } from '@/test/find-parts.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { V2V_MAPPED_VM } from '@/test/v2v-mapping-fixtures.ts'
import { mount } from '@vue/test-utils'
const VM = createV2vVm({
...V2V_MAPPED_VM,
description: 'Internal API backend',
memory: 12.5 * 1024 ** 3,
snapshotCount: 2,
})
function mountCard() {
return mount(V2vVmSourceCard, { props: { vm: VM, hostName: 'esx01' }, global: createGlobalTestConfig() })
}
it('shows its title', () => {
expect(findPanelCardTitle(mountCard())).toBe(t('source'))
})
it('shows the facts of the source VM', () => {
expect(findCardLabelledValues(mountCard())).toMatchObject({
[t('power-state')]: t('power-state-on-host', { power: t('vm:status:running'), host: 'esx01' }),
[t('cpu')]: t('n-vcpus', 4),
[t('memory')]: '12.5 GiB',
[t('boot-firmware')]: 'uefi',
[t('secure-boot')]: t('disabled'),
[t('snapshots')]: '2',
[t('description')]: 'Internal API backend',
})
})
it('lays out the count and the total size of the disks', () => {
expect(findParts(mountCard(), '.inline-parts')).toEqual(['2', '160 GiB'])
})

View File

@@ -0,0 +1,76 @@
<template>
<UiPanelCard class="v2v-vm-source-card">
<UiPanelCardTitle size="medium" :label="t('source')" />
<div class="content">
<VtsCardRowKeyValue>
<template #key>{{ t('power-state') }}</template>
<template #value>{{ getPowerLabel(vm.powerState, hostName) }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('cpu') }}</template>
<template #value>{{ t('n-vcpus', vm.nCpus) }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('memory') }}</template>
<template #value>{{ formatSize(vm.memory, 1) }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('boot-firmware') }}</template>
<template #value>{{ vm.firmware }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('secure-boot') }}</template>
<template #value>
<VtsStatus :status="vm.secureBoot" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('disks') }}</template>
<template #value>
<InlineParts>
<span v-for="part of getDisksParts(vm.disks)" :key="part">{{ part }}</span>
</InlineParts>
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('snapshots') }}</template>
<template #value>{{ getSnapshotCountLabel(vm.snapshotCount) }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('description') }}</template>
<template #value>{{ vm.description }}</template>
</VtsCardRowKeyValue>
</div>
</UiPanelCard>
</template>
<script lang="ts" setup>
import { useV2vVmLabels } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { formatSize } from '@core/utils/size.util.ts'
import type { XoV2vVm } from '@vates/types'
import { useI18n } from 'vue-i18n'
defineProps<{
vm: XoV2vVm
hostName?: string
}>()
const { t } = useI18n()
const { getPowerLabel, getDisksParts, getSnapshotCountLabel } = useV2vVmLabels()
</script>
<style lang="postcss" scoped>
.v2v-vm-source-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,32 @@
import V2vTargetSelect from '@/modules/v2v/components/mapping/V2vTargetSelect.vue'
import V2vValueOrigin from '@/modules/v2v/components/mapping/V2vValueOrigin.vue'
import type { V2vResolvedTarget, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import { renderBodyCell } from '@core/tables/helpers/render-body-cell.ts'
import { h } from 'vue'
export type V2vTargetCellsConfig<TId extends string> = {
target: V2vResolvedTarget<TId>
options: V2vTargetOption<TId>[]
loading: boolean
// `undefined` resets the target to its default
onChange: (id: TId | undefined) => void
}
// The last two cells of a mapping table row: the target, then where its value comes from. The config is a getter,
// read at render time: a table line keeps its cells while its row changes
export function renderV2vTargetCells<TId extends string>(getConfig: () => V2vTargetCellsConfig<TId>) {
return {
target: () => {
const { target, options, loading, onChange } = getConfig()
return renderBodyCell(() =>
h(V2vTargetSelect<TId>, { modelValue: target.value, options, loading, 'onUpdate:modelValue': onChange })
)
},
origin: () => {
const { target, onChange } = getConfig()
return renderBodyCell(() => h(V2vValueOrigin, { origin: target.origin, onReset: () => onChange(undefined) }))
},
}
}

View File

@@ -0,0 +1,34 @@
import V2vNewVmActions from '@/modules/v2v/components/panel/V2vNewVmActions.vue'
import { createV2vNewMappingStepVm } from '@/test/create-v2v-mapping-step-vm.ts'
import { v2vVmActionsMock } from '@/test/mock-v2v-vm-actions.ts'
import { mountV2vVmKindActions, describeV2vVmKindActions } from '@/test/v2v-vm-kind-actions.ts'
vi.mock(
import('@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'),
() => import('@/test/mock-v2v-vm-actions.ts')
)
beforeEach(() => {
v2vVmActionsMock.reset()
})
const VM = createV2vNewMappingStepVm()
function mountActions(updateVm = vi.fn()) {
return mountV2vVmKindActions(V2vNewVmActions, { vm: VM, updateVm })
}
it('stores the settings of its VM through the list', () => {
const updateVm = vi.fn()
mountActions(updateVm)
expect(v2vVmActionsMock.useV2vNewVmActions.mock.lastCall?.[1].updateVm).toBe(updateVm)
})
describeV2vVmKindActions({
mountActions: () => mountActions(),
useActions: v2vVmActionsMock.useV2vNewVmActions,
vm: VM,
isRemovable: true,
})

View File

@@ -0,0 +1,25 @@
<template>
<slot :main-action="editAction" :more-actions />
</template>
<script lang="ts" setup>
import type { V2vNewMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { useV2vNewVmActions, type V2vVmActionList } from '@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'
import type { V2vVmMappingContext } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import type { XoV2vVm } from '@vates/types'
const { vm, updateVm } = defineProps<{
vm: V2vNewMappingStepVm
updateVm: V2vVmMappingContext['updateVm']
}>()
const emit = defineEmits<{
remove: [id: XoV2vVm['id']]
}>()
defineSlots<{
default(props: V2vVmActionList): any
}>()
const { editAction, moreActions } = useV2vNewVmActions(() => vm, { updateVm, remove: id => emit('remove', id) })
</script>

View File

@@ -0,0 +1,122 @@
import V2vDestinationMappingCard from '@/modules/v2v/components/mapping/V2vDestinationMappingCard.vue'
import V2vVmChecksCard from '@/modules/v2v/components/mapping/V2vVmChecksCard.vue'
import V2vVmSourceCard from '@/modules/v2v/components/mapping/V2vVmSourceCard.vue'
import V2vNewVmCards from '@/modules/v2v/components/panel/V2vNewVmCards.vue'
import V2vVmOptionsCard from '@/modules/v2v/components/panel/V2vVmOptionsCard.vue'
import { createV2vHost } from '@/test/create-v2v-inventory.ts'
import { createV2vNewMappingStepVm } 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 { mountV2vVmMappingComponent } from '@/test/mount-v2v-vm-mapping.ts'
import {
V2V_DEFAULT_TEMPLATE,
V2V_DISK_ROWS,
V2V_MIGRATED_VM_TAGS,
V2V_NIC_ROWS,
V2V_POOL_OFFERS,
V2V_TARGET_OPTIONS,
createV2vOfferedTarget,
} 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'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/host/remote-resources/use-xo-host-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/pbd/remote-resources/use-xo-pbd-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')
)
const LIST_ISSUE = { label: 'Import in progress', accent: 'danger' } as const
// The cards render what the container hands them, each covered by its own test: here only the wiring matters
function mountCards() {
return mountV2vVmMappingComponent((list, router, provide) => {
const vm = createV2vNewMappingStepVm({ list, listIssue: LIST_ISSUE })
return {
vm,
wrapper: mount(V2vNewVmCards, {
props: { vm },
global: createGlobalTestConfig({
router,
provide,
stubs: {
V2vVmSourceCard: true,
V2vVmChecksCard: true,
V2vDestinationMappingCard: true,
V2vVmOptionsCard: true,
},
}),
}),
}
})
}
beforeEach(() => {
xoCollectionsMock.reset()
})
it('shows the source VM on its host', async () => {
const { vm, wrapper } = await mountCards()
expect(wrapper.getComponent(V2vVmSourceCard).props()).toEqual({ vm: vm.vm, hostName: createV2vHost().name })
})
it('tells the checks of the source VM, then its issue in the list', async () => {
const { wrapper } = await mountCards()
const checks = wrapper.getComponent(V2vVmChecksCard).props('checks')
expect([checks.length, checks.at(-1)]).toEqual([3, LIST_ISSUE])
})
it('shows the mapping of the disks and the NICs on the pool of the list, read only', async () => {
const { wrapper } = await mountCards()
const card = wrapper.getComponent(V2vDestinationMappingCard)
expect(card.props()).toMatchObject({
poolName: V2V_LIST_POOL.name_label,
diskRows: V2V_DISK_ROWS,
nicRows: V2V_NIC_ROWS,
networkOptions: V2V_TARGET_OPTIONS.networks,
loading: false,
})
expect(card.props('srOptions').map(option => option.id)).toEqual(V2V_POOL_OFFERS.srs.map(sr => sr.id))
})
it('shows the options of the VM', async () => {
const { wrapper } = await mountCards()
expect(wrapper.getComponent(V2vVmOptionsCard).props()).toEqual({
keepMac: true,
template: createV2vOfferedTarget(V2V_DEFAULT_TEMPLATE.id),
templateOptions: V2V_TARGET_OPTIONS.templates,
tags: V2V_MIGRATED_VM_TAGS,
})
})
it('shows the targets loading while the pool offers load', async () => {
xoCollectionsMock.areTemplatesReady.value = false
const { wrapper } = await mountCards()
expect(wrapper.getComponent(V2vDestinationMappingCard).props('loading')).toBe(true)
})

View File

@@ -0,0 +1,34 @@
<template>
<V2vVmSourceCard :vm="vm.vm" :host-name />
<V2vVmChecksCard :checks />
<V2vDestinationMappingCard :pool-name :disk-rows :nic-rows :sr-options :network-options :loading="!areOffersReady" />
<V2vVmOptionsCard :keep-mac :template :template-options :tags />
</template>
<script lang="ts" setup>
import V2vDestinationMappingCard from '@/modules/v2v/components/mapping/V2vDestinationMappingCard.vue'
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 type { V2vNewMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { useV2vVmMapping } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
const { vm } = defineProps<{
vm: V2vNewMappingStepVm
}>()
const {
poolName,
hostName,
checks,
diskRows,
nicRows,
template,
srOptions,
networkOptions,
templateOptions,
areOffersReady,
keepMac,
tags,
} = useV2vVmMapping(() => vm.vm, { list: () => vm.list, listIssue: () => vm.listIssue })
</script>

View File

@@ -0,0 +1,22 @@
import V2vNotFoundVmActions from '@/modules/v2v/components/panel/V2vNotFoundVmActions.vue'
import { createV2vNotFoundMappingStepVm } from '@/test/create-v2v-mapping-step-vm.ts'
import { v2vVmActionsMock } from '@/test/mock-v2v-vm-actions.ts'
import { mountV2vVmKindActions, describeV2vVmKindActions } from '@/test/v2v-vm-kind-actions.ts'
vi.mock(
import('@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'),
() => import('@/test/mock-v2v-vm-actions.ts')
)
beforeEach(() => {
v2vVmActionsMock.reset()
})
const VM = createV2vNotFoundMappingStepVm()
describeV2vVmKindActions({
mountActions: () => mountV2vVmKindActions(V2vNotFoundVmActions, { vm: VM }),
useActions: v2vVmActionsMock.useV2vNotFoundVmActions,
vm: VM,
isRemovable: true,
})

View File

@@ -0,0 +1,26 @@
<template>
<slot :more-actions />
</template>
<script lang="ts" setup>
import type { V2vNotFoundMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import {
useV2vNotFoundVmActions,
type V2vVmActionList,
} from '@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'
import type { XoV2vVm } from '@vates/types'
const { vm } = defineProps<{
vm: V2vNotFoundMappingStepVm
}>()
const emit = defineEmits<{
remove: [id: XoV2vVm['id']]
}>()
defineSlots<{
default(props: V2vVmActionList): any
}>()
const { moreActions } = useV2vNotFoundVmActions(() => vm, { remove: id => emit('remove', id) })
</script>

View File

@@ -0,0 +1,22 @@
import V2vResumedVmActions from '@/modules/v2v/components/panel/V2vResumedVmActions.vue'
import { createV2vResumedMappingStepVm } from '@/test/create-v2v-mapping-step-vm.ts'
import { v2vVmActionsMock } from '@/test/mock-v2v-vm-actions.ts'
import { mountV2vVmKindActions, describeV2vVmKindActions } from '@/test/v2v-vm-kind-actions.ts'
vi.mock(
import('@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'),
() => import('@/test/mock-v2v-vm-actions.ts')
)
beforeEach(() => {
v2vVmActionsMock.reset()
})
const VM = createV2vResumedMappingStepVm()
describeV2vVmKindActions({
mountActions: () => mountV2vVmKindActions(V2vResumedVmActions, { vm: VM }),
useActions: v2vVmActionsMock.useV2vResumedVmActions,
vm: VM,
isRemovable: false,
})

View File

@@ -0,0 +1,21 @@
<template>
<slot :main-action="editAction" :more-actions />
</template>
<script lang="ts" setup>
import type { V2vResumedMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import {
useV2vResumedVmActions,
type V2vVmActionList,
} from '@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'
const { vm } = defineProps<{
vm: V2vResumedMappingStepVm
}>()
defineSlots<{
default(props: V2vVmActionList): any
}>()
const { editAction, moreActions } = useV2vResumedVmActions(() => vm)
</script>

View File

@@ -0,0 +1,101 @@
import V2vActiveMappingCard from '@/modules/v2v/components/mapping/V2vActiveMappingCard.vue'
import V2vDestinationVmCard from '@/modules/v2v/components/mapping/V2vDestinationVmCard.vue'
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 { seedV2vResumedVmCollections } from '@/test/seed-v2v-resumed-vm.ts'
import {
V2V_ACTIVE_DISK_ROWS,
V2V_ACTIVE_NIC_ROWS,
V2V_MAPPING_INVENTORY,
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'),
() => 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/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')
)
const VM = createV2vResumedMappingStepVm()
// The cards render what the container hands them, each covered by its own test: here only the wiring matters
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 },
}),
}),
{ step: 'mapping', source: { inventory: V2V_MAPPING_INVENTORY } }
)
return wrapper
}
beforeEach(() => {
xoCollectionsMock.reset()
vmDeviceCollectionsMock.reset()
})
it('shows the import VM on the pool it was synced to', async () => {
const card = (await mountCards()).getComponent(V2vDestinationVmCard)
expect([card.props('vm').id, card.props('poolName')]).toEqual([V2V_RESUMED_IMPORT_VM.id, V2V_LIST_POOL.name_label])
})
it('tells the next run, since the last sync', async () => {
const card = (await mountCards()).getComponent(V2vNextRunCard)
expect(card.props()).toEqual({ transfer: 'delta', lastSyncAt: VM.lastSyncAt })
})
it('shows the mapping of the first sync, locked on the pool', async () => {
const card = (await mountCards()).getComponent(V2vActiveMappingCard)
expect(card.props()).toEqual({
poolName: V2V_LIST_POOL.name_label,
diskRows: V2V_ACTIVE_DISK_ROWS,
nicRows: V2V_ACTIVE_NIC_ROWS,
loading: false,
})
})
it('shows the active mapping loading while the devices of the import VM load', async () => {
vmDeviceCollectionsMock.areVdisReady.value = false
const card = (await mountCards()).getComponent(V2vActiveMappingCard)
expect(card.props('loading')).toBe(true)
})

View File

@@ -0,0 +1,27 @@
<template>
<V2vDestinationVmCard :vm="destinationVm" :pool-name />
<V2vNextRunCard :transfer="nextTransfer" :last-sync-at="vm.lastSyncAt" />
<V2vActiveMappingCard
:pool-name
:disk-rows="activeDiskRows"
:nic-rows="activeNicRows"
:loading="!isActiveMappingReady"
/>
</template>
<script lang="ts" setup>
import V2vActiveMappingCard from '@/modules/v2v/components/mapping/V2vActiveMappingCard.vue'
import V2vDestinationVmCard from '@/modules/v2v/components/mapping/V2vDestinationVmCard.vue'
import V2vNextRunCard from '@/modules/v2v/components/mapping/V2vNextRunCard.vue'
import type { V2vResumedMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { useV2vResumedVm } from '@/modules/v2v/composables/use-v2v-resumed-vm.composable.ts'
const { vm } = defineProps<{
vm: V2vResumedMappingStepVm
}>()
const { poolName, destinationVm, activeDiskRows, activeNicRows, isActiveMappingReady, nextTransfer } = useV2vResumedVm(
() => vm.vm,
() => vm.importVm
)
</script>

View File

@@ -0,0 +1,65 @@
import V2vVmActions from '@/modules/v2v/components/panel/V2vVmActions.vue'
import { createActionItem } from '@/test/create-action-item.ts'
import { findTooltipContent } from '@/test/find-tooltip-content.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import type { LeafActionItem } from '@core/components/menu/VtsActionsMenu.vue'
import VtsActionsMenu from '@core/components/menu/VtsActionsMenu.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import { mount } from '@vue/test-utils'
const MORE_ACTIONS = [createActionItem({ label: 'Remove from list', accent: 'danger' })]
function mountActions(mainAction: Partial<LeafActionItem> | null = {}) {
return mount(V2vVmActions, {
props: {
mainAction: mainAction === null ? undefined : createActionItem({ label: 'Edit settings', ...mainAction }),
moreActions: MORE_ACTIONS,
},
global: createGlobalTestConfig(),
})
}
function getMainButton(wrapper: ReturnType<typeof mountActions>) {
return wrapper.getComponent(UiButton)
}
it('offers the main action as a button', () => {
const button = getMainButton(mountActions({ icon: 'action:edit' }))
expect([button.text(), button.props('leftIcon'), button.props('variant')]).toEqual([
'Edit settings',
'action:edit',
'primary',
])
})
it('runs the main action on click', async () => {
const onClick = vi.fn()
await getMainButton(mountActions({ onClick })).trigger('click')
expect(onClick).toHaveBeenCalledOnce()
})
it('disables the main action, with the reason as tooltip, when it cannot run', async () => {
const button = getMainButton(mountActions({ disabled: true, hint: 'Locked since the first sync' }))
expect(button.props('disabled')).toBe(true)
expect(await findTooltipContent(button.element)).toBe('Locked since the first sync')
})
it('has no tooltip when the main action can run', async () => {
expect(await findTooltipContent(getMainButton(mountActions()).element)).toBe(false)
})
it('shows the main action busy while it runs', () => {
expect(getMainButton(mountActions({ busy: true })).props('busy')).toBe(true)
})
it('offers the other actions in its menu', () => {
expect(mountActions().getComponent(VtsActionsMenu).props('actions')).toEqual(MORE_ACTIONS)
})
it('has no button without a main action', () => {
expect(mountActions(null).findComponent(UiButton).exists()).toBe(false)
})

View File

@@ -0,0 +1,26 @@
<template>
<UiButton
v-if="mainAction"
v-tooltip="mainAction.hint ?? false"
size="medium"
variant="primary"
accent="brand"
:disabled="mainAction.disabled"
:left-icon="mainAction.icon"
:busy="mainAction.busy"
@click="mainAction.onClick()"
>
{{ mainAction.label }}
</UiButton>
<VtsActionsMenu size="medium" :actions="moreActions" />
</template>
<script lang="ts" setup>
import type { V2vVmActionList } from '@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'
import VtsActionsMenu from '@core/components/menu/VtsActionsMenu.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
// A VM gone from the source has no main action, only the ones of the menu
defineProps<V2vVmActionList>()
</script>

View File

@@ -0,0 +1,39 @@
import V2vVmHeadingCard from '@/modules/v2v/components/panel/V2vVmHeadingCard.vue'
import type { V2vMappingVmHeading } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { createV2vNewMappingStepVm } from '@/test/create-v2v-mapping-step-vm.ts'
import { createV2vMappingVmHeading } from '@/test/create-v2v-mapping-vm-heading.ts'
import { findAccentedTags } from '@/test/find-accented-labels.ts'
import { findParts } from '@/test/find-parts.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import type { XoV2vVm } from '@vates/types'
import { mount } from '@vue/test-utils'
const VM = createV2vNewMappingStepVm()
function mountCard({ id = VM.id, heading = VM.heading }: { id?: XoV2vVm['id']; heading?: V2vMappingVmHeading } = {}) {
return mount(V2vVmHeadingCard, { props: { id, heading }, global: createGlobalTestConfig() })
}
it('titles the card with a VM icon, the name of the VM and its copyable id, without a link', () => {
const title = mountCard().getComponent(UiPanelCardTitle)
expect(title.props()).toMatchObject({ label: VM.heading.name, id: VM.id, icon: 'object:vm', to: undefined })
})
it('tags the VM with its status, then locates it on the source', () => {
const wrapper = mountCard()
expect(Object.keys(findCardLabelledValues(wrapper))).toEqual([t('status'), t('location')])
expect(findAccentedTags(wrapper)).toEqual([VM.heading.statusTag])
expect(findParts(wrapper, '.inline-parts')).toEqual(VM.heading.locationParts)
})
it('leaves out the status of a VM without one', () => {
const wrapper = mountCard({ heading: createV2vMappingVmHeading({ statusTag: undefined }) })
expect(Object.keys(findCardLabelledValues(wrapper))).toEqual([t('location')])
expect(findAccentedTags(wrapper)).toEqual([])
})

View File

@@ -0,0 +1,50 @@
<template>
<UiPanelCard class="v2v-vm-heading-card">
<UiPanelCardTitle :id :label="heading.name" icon="object:vm" size="medium" />
<div class="content">
<VtsCardRowKeyValue v-if="heading.statusTag">
<template #key>{{ t('status') }}</template>
<template #value>
<UiTag :accent="heading.statusTag.accent" variant="secondary">{{ heading.statusTag.label }}</UiTag>
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('location') }}</template>
<template #value>
<InlineParts separator="chevron">
<span v-for="part of heading.locationParts" :key="part">{{ part }}</span>
</InlineParts>
</template>
</VtsCardRowKeyValue>
</div>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { V2vMappingVmHeading } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import { useI18n } from 'vue-i18n'
// A source VM has no page in XO to link to; its power state is not part of its heading, hence the plain VM icon
defineProps<{
// Not `XoV2vVm['id']`: Vue resolves the branded type to a runtime `Object` check and warns on the string id
id: string
heading: V2vMappingVmHeading
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-vm-heading-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,71 @@
import V2vNewVmActions from '@/modules/v2v/components/panel/V2vNewVmActions.vue'
import V2vNotFoundVmActions from '@/modules/v2v/components/panel/V2vNotFoundVmActions.vue'
import V2vResumedVmActions from '@/modules/v2v/components/panel/V2vResumedVmActions.vue'
import V2vVmKindActions from '@/modules/v2v/components/panel/V2vVmKindActions.vue'
import type { V2vMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM } from '@/test/create-v2v-mapping-step-vm.ts'
import { findShownComponents } from '@/test/find-shown-components.ts'
import { v2vVmActionsMock } from '@/test/mock-v2v-vm-actions.ts'
import { mountV2vVmKindActions, findV2vVmSlotActions, getV2vVmKindActionList } from '@/test/v2v-vm-kind-actions.ts'
import { type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'),
() => import('@/test/mock-v2v-vm-actions.ts')
)
beforeEach(() => {
v2vVmActionsMock.reset()
})
const updateVm = vi.fn()
function mountKindActions(vm: V2vMappingStepVm) {
return mountV2vVmKindActions(V2vVmKindActions, { vm, updateVm })
}
const KIND_ACTIONS = [V2vNewVmActions, V2vResumedVmActions, V2vNotFoundVmActions]
function findKindActions(wrapper: VueWrapper) {
return findShownComponents(wrapper, KIND_ACTIONS)
}
it.each([
['a new VM, stored through the list', V2V_NEW_STEP_VM, V2vNewVmActions, { vm: V2V_NEW_STEP_VM, updateVm }],
['a resumed VM', V2V_RESUMED_STEP_VM, V2vResumedVmActions, { vm: V2V_RESUMED_STEP_VM }],
['a VM not found in the source', V2V_NOT_FOUND_STEP_VM, V2vNotFoundVmActions, { vm: V2V_NOT_FOUND_STEP_VM }],
] as const)('runs the actions of %s', (_, vm, kindActions, props) => {
const wrapper = mountKindActions(vm)
expect(findKindActions(wrapper)).toEqual([kindActions])
expect(wrapper.getComponent(kindActions).props()).toEqual(props)
})
it.each([
['a new VM', V2V_NEW_STEP_VM, v2vVmActionsMock.useV2vNewVmActions],
['a resumed VM', V2V_RESUMED_STEP_VM, v2vVmActionsMock.useV2vResumedVmActions],
['a VM not found in the source', V2V_NOT_FOUND_STEP_VM, v2vVmActionsMock.useV2vNotFoundVmActions],
] as const)('hands its slot the actions of %s', (_, vm, useActions) => {
const wrapper = mountKindActions(vm)
expect(findV2vVmSlotActions(wrapper)).toEqual(getV2vVmKindActionList(useActions))
})
it('switches to the actions of the new VM a resumed one becomes once its import VM is deleted', async () => {
const wrapper = mountKindActions(V2V_RESUMED_STEP_VM)
await wrapper.setProps({ vm: V2V_NEW_STEP_VM })
expect(findKindActions(wrapper)).toEqual([V2vNewVmActions])
})
it.each([
['a new VM', V2V_NEW_STEP_VM, V2vNewVmActions],
['a VM not found in the source', V2V_NOT_FOUND_STEP_VM, V2vNotFoundVmActions],
] as const)('emits the removal of %s', async (_, vm, kindActions) => {
const wrapper = mountKindActions(vm)
await wrapper.getComponent(kindActions).vm.$emit('remove', vm.id)
expect(wrapper.emitted('remove')).toEqual([[vm.id]])
})

View File

@@ -0,0 +1,36 @@
<template>
<V2vNewVmActions v-if="vm.kind === 'new'" v-slot="actions" :vm :update-vm @remove="id => emit('remove', id)">
<slot v-bind="actions" />
</V2vNewVmActions>
<V2vResumedVmActions v-else-if="vm.kind === 'resume'" v-slot="actions" :vm>
<slot v-bind="actions" />
</V2vResumedVmActions>
<V2vNotFoundVmActions v-else v-slot="actions" :vm @remove="id => emit('remove', id)">
<slot v-bind="actions" />
</V2vNotFoundVmActions>
</template>
<script lang="ts" setup>
import V2vNewVmActions from '@/modules/v2v/components/panel/V2vNewVmActions.vue'
import V2vNotFoundVmActions from '@/modules/v2v/components/panel/V2vNotFoundVmActions.vue'
import V2vResumedVmActions from '@/modules/v2v/components/panel/V2vResumedVmActions.vue'
import type { V2vMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import type { V2vVmActionList } from '@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'
import type { V2vVmMappingContext } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import type { XoV2vVm } from '@vates/types'
// The actions of the kind of the VM, for the caller to lay out: the side panel header and a table row menu read the
// same ones. The kind of a VM changes once the import VM of a resumed VM is deleted: its actions then switch with it
defineProps<{
vm: V2vMappingStepVm
updateVm: V2vVmMappingContext['updateVm']
}>()
const emit = defineEmits<{
remove: [id: XoV2vVm['id']]
}>()
defineSlots<{
default(props: V2vVmActionList): any
}>()
</script>

View File

@@ -0,0 +1,60 @@
import V2vVmOptionsCard from '@/modules/v2v/components/panel/V2vVmOptionsCard.vue'
import { findPanelCardTitle } from '@/test/find-card-title.ts'
import { getGenericComponent } from '@/test/find-generic-components.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import {
V2V_DEBIAN_TEMPLATE,
V2V_MIGRATED_VM_TAGS,
V2V_TARGET_OPTIONS,
createV2vOfferedTarget,
} from '@/test/v2v-mapping-fixtures.ts'
import VtsTag from '@core/components/tag/VtsTag.vue'
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
const TEMPLATE = createV2vOfferedTarget(V2V_DEBIAN_TEMPLATE.id, 'overridden')
function mountCard(props: Partial<ComponentProps<typeof V2vVmOptionsCard>> = {}) {
return mount(V2vVmOptionsCard, {
props: {
keepMac: true,
template: TEMPLATE,
templateOptions: V2V_TARGET_OPTIONS.templates,
tags: V2V_MIGRATED_VM_TAGS,
...props,
},
global: createGlobalTestConfig(),
})
}
it('shows its title', () => {
expect(findPanelCardTitle(mountCard())).toBe(t('options'))
})
it.each([
[true, 'enabled'],
[false, 'disabled'],
] as const)('tells whether the MAC addresses are kept (%s)', (keepMac, statusKey) => {
expect(findCardLabelledValues(mountCard({ keepMac }))[t('keep-mac-addresses')]).toBe(t(statusKey))
})
it('names the template among the offered ones, and where it comes from', () => {
expect(getGenericComponent(mountCard(), 'V2vTargetValue').props()).toEqual({
target: TEMPLATE,
options: V2V_TARGET_OPTIONS.templates,
})
})
it('lists the tags of the VM', () => {
const tags = mountCard({ tags: ['web', 'prod'] })
.findAllComponents(VtsTag)
.map(tag => tag.props('value'))
expect(tags).toEqual(['web', 'prod'])
})
it('lists no tag for a VM without any', () => {
expect(mountCard({ tags: [] }).findAllComponents(VtsTag)).toEqual([])
})

View File

@@ -0,0 +1,59 @@
<template>
<UiPanelCard class="v2v-vm-options-card">
<UiPanelCardTitle size="medium" :label="t('options')" />
<div class="content">
<VtsCardRowKeyValue>
<template #key>{{ t('keep-mac-addresses') }}</template>
<template #value>
<VtsStatus :status="keepMac" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('template') }}</template>
<template #value>
<V2vTargetValue :target="template" :options="templateOptions" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('tags') }}</template>
<template #value>
<UiTagsList v-if="tags.length > 0">
<VtsTag v-for="tag of tags" :key="tag" :value="tag" />
</UiTagsList>
</template>
</VtsCardRowKeyValue>
</div>
</UiPanelCard>
</template>
<script lang="ts" setup>
import V2vTargetValue from '@/modules/v2v/components/mapping/V2vTargetValue.vue'
import type { V2vResolvedTarget, V2vTargetOption } from '@/modules/v2v/types/v2v-mapping.type.ts'
import type { FrontXoVmTemplate } from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
keepMac: boolean
template: V2vResolvedTarget<FrontXoVmTemplate['id']>
templateOptions: V2vTargetOption<FrontXoVmTemplate['id']>[]
tags: string[]
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-vm-options-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,156 @@
import V2vNewVmCards from '@/modules/v2v/components/panel/V2vNewVmCards.vue'
import V2vResumedVmCards from '@/modules/v2v/components/panel/V2vResumedVmCards.vue'
import V2vVmActions from '@/modules/v2v/components/panel/V2vVmActions.vue'
import V2vVmHeadingCard from '@/modules/v2v/components/panel/V2vVmHeadingCard.vue'
import V2vVmKindActions from '@/modules/v2v/components/panel/V2vVmKindActions.vue'
import V2vVmSidePanel from '@/modules/v2v/components/panel/V2vVmSidePanel.vue'
import type { V2vMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM } from '@/test/create-v2v-mapping-step-vm.ts'
import { findShownComponents } from '@/test/find-shown-components.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { v2vVmActionsMock } from '@/test/mock-v2v-vm-actions.ts'
import { getV2vVmKindActionList } from '@/test/v2v-vm-kind-actions.ts'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { mount, type VueWrapper } from '@vue/test-utils'
vi.mock(
import('@/modules/v2v/composables/use-v2v-vm-actions.composable.ts'),
() => import('@/test/mock-v2v-vm-actions.ts')
)
beforeEach(() => {
v2vVmActionsMock.reset()
})
const updateVm = vi.fn()
// The actions and the cards of each kind are covered by their own tests: here only which ones the panel shows matters
function mountPanel(vm: V2vMappingStepVm | undefined) {
return mount(V2vVmSidePanel, {
props: { vm, updateVm },
global: createGlobalTestConfig({
stubs: {
V2vNewVmCards: true,
V2vResumedVmCards: true,
V2vVmHeadingCard: true,
},
}),
})
}
const KIND_COMPONENTS = [V2vNewVmCards, V2vResumedVmCards]
function findKindComponents(wrapper: VueWrapper) {
return findShownComponents(wrapper, KIND_COMPONENTS)
}
function findKindActionsVm(wrapper: VueWrapper) {
return wrapper.getComponent(V2vVmKindActions).props('vm')
}
function findNotFoundHero(wrapper: VueWrapper) {
return wrapper.findAllComponents(VtsStateHero).find(hero => hero.props('type') === 'not-found')
}
it.each([
['holds a selection, for the side panel to show it and offer to close it', V2V_NEW_STEP_VM, true],
['holds no selection without VM, for the side panel to show its empty state', undefined, false],
])('%s', (_, vm, hasSelection) => {
expect(mountPanel(vm).getComponent(VtsSidePanel).props('hasSelection')).toBe(hasSelection)
})
it('shows neither heading, cards nor actions without VM', () => {
const wrapper = mountPanel(undefined)
expect(wrapper.findComponent(V2vVmHeadingCard).exists()).toBe(false)
expect(findKindComponents(wrapper)).toEqual([])
expect(wrapper.findComponent(V2vVmKindActions).exists()).toBe(false)
})
it.each([
['a new VM', V2V_NEW_STEP_VM],
['a resumed VM', V2V_RESUMED_STEP_VM],
['a VM not found in the source', V2V_NOT_FOUND_STEP_VM],
])('heads the body of %s with its heading, first', (_, vm) => {
const wrapper = mountPanel(vm)
const headingCard = wrapper.getComponent(V2vVmHeadingCard)
expect(headingCard.props()).toEqual({ id: vm.id, heading: vm.heading })
expect(wrapper.get('.ui-panel > .content').element.firstElementChild).toBe(headingCard.element)
})
it('closes on request', async () => {
const wrapper = mountPanel(V2V_NEW_STEP_VM)
await wrapper.getComponent(VtsSidePanel).vm.$emit('close')
expect(wrapper.emitted('close')).toEqual([[]])
})
it('lays the actions of the VM out as a main button and a menu', () => {
const wrapper = mountPanel(V2V_NEW_STEP_VM)
expect(wrapper.getComponent(V2vVmActions).props()).toEqual(
getV2vVmKindActionList(v2vVmActionsMock.useV2vNewVmActions)
)
})
describe('new VM', () => {
it('heads the panel with the actions of a new VM, stored through the list', () => {
expect(mountPanel(V2V_NEW_STEP_VM).getComponent(V2vVmKindActions).props()).toEqual({
vm: V2V_NEW_STEP_VM,
updateVm,
})
})
it('shows the mapping of the VM', () => {
const wrapper = mountPanel(V2V_NEW_STEP_VM)
expect(wrapper.getComponent(V2vNewVmCards).props()).toEqual({ vm: V2V_NEW_STEP_VM })
expect(findKindComponents(wrapper)).toEqual([V2vNewVmCards])
})
})
describe('resumed VM', () => {
it('heads the panel with the actions of a resumed VM, and shows its first sync', () => {
const wrapper = mountPanel(V2V_RESUMED_STEP_VM)
expect([findKindActionsVm(wrapper), wrapper.getComponent(V2vResumedVmCards).props('vm')]).toEqual([
V2V_RESUMED_STEP_VM,
V2V_RESUMED_STEP_VM,
])
expect(findKindComponents(wrapper)).toEqual([V2vResumedVmCards])
})
it('switches to the cards of the new VM it becomes once its import VM is deleted', async () => {
const wrapper = mountPanel(V2V_RESUMED_STEP_VM)
await wrapper.setProps({ vm: V2V_NEW_STEP_VM })
expect(findKindComponents(wrapper)).toEqual([V2vNewVmCards])
})
})
describe('VM not found in the source', () => {
it('tells the VM is not found in the source, with its actions', () => {
const wrapper = mountPanel(V2V_NOT_FOUND_STEP_VM)
expect(findNotFoundHero(wrapper)?.text()).toBe(t('v2v:not-found-in-source'))
expect(findKindActionsVm(wrapper)).toEqual(V2V_NOT_FOUND_STEP_VM)
expect(findKindComponents(wrapper)).toEqual([])
})
it('has no not-found hero for a VM of the source', () => {
expect(findNotFoundHero(mountPanel(V2V_NEW_STEP_VM))).toBeUndefined()
})
})
it('emits the removal of its VM', async () => {
const wrapper = mountPanel(V2V_NEW_STEP_VM)
await wrapper.getComponent(V2vVmKindActions).vm.$emit('remove', V2V_NEW_STEP_VM.id)
expect(wrapper.emitted('remove')).toEqual([[V2V_NEW_STEP_VM.id]])
})

View File

@@ -0,0 +1,45 @@
<template>
<VtsSidePanel :has-selection="!!vm" @close="emit('close')">
<template v-if="vm" #actions>
<V2vVmKindActions v-slot="actions" :vm :update-vm @remove="id => emit('remove', id)">
<V2vVmActions v-bind="actions" />
</V2vVmKindActions>
</template>
<template v-if="vm" #default>
<V2vVmHeadingCard :id="vm.id" :heading="vm.heading" />
<V2vNewVmCards v-if="vm.kind === 'new'" :vm />
<V2vResumedVmCards v-else-if="vm.kind === 'resume'" :vm />
<VtsStateHero v-else format="panel" type="not-found" size="medium">
{{ t('v2v:not-found-in-source') }}
</VtsStateHero>
</template>
</VtsSidePanel>
</template>
<script lang="ts" setup>
import V2vNewVmCards from '@/modules/v2v/components/panel/V2vNewVmCards.vue'
import V2vResumedVmCards from '@/modules/v2v/components/panel/V2vResumedVmCards.vue'
import V2vVmActions from '@/modules/v2v/components/panel/V2vVmActions.vue'
import V2vVmHeadingCard from '@/modules/v2v/components/panel/V2vVmHeadingCard.vue'
import V2vVmKindActions from '@/modules/v2v/components/panel/V2vVmKindActions.vue'
import type { V2vMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import type { V2vVmMappingContext } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import type { XoV2vVm } from '@vates/types'
import { useI18n } from 'vue-i18n'
// Each kind has its own actions and cards, for each runs its own composables: the kind of the VM changes once the
// import VM of a resumed VM is deleted, and the panel then shows the new VM it became
defineProps<{
vm?: V2vMappingStepVm
updateVm: V2vVmMappingContext['updateVm']
}>()
const emit = defineEmits<{
close: []
remove: [id: XoV2vVm['id']]
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,31 @@
import V2vListPairSummary from '@/modules/v2v/components/selected-vms/V2vListPairSummary.vue'
import type { V2vListPair } from '@/modules/v2v/composables/use-v2v-selected-vms.composable.ts'
import { createV2vListPair } from '@/test/create-v2v-selected-vms.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
const LIST = createV2vListPair()
function mountListPair(props: V2vListPair = LIST) {
return mount(V2vListPairSummary, { props, global: createGlobalTestConfig() })
}
it('names the source and the destination of the list', () => {
expect(findCardLabelledValues(mountListPair())).toEqual({
[t('source')]: LIST.sourceHost,
[t('destination')]: LIST.poolName,
})
})
it('leaves the values empty while unknown', () => {
expect(findCardLabelledValues(mountListPair({}))).toEqual({
[t('source')]: '',
[t('destination')]: '',
})
})
it('explains that another source or pool has its own list', () => {
expect(mountListPair().get('.note').text()).toBe(t('v2v:list-pair-info'))
})

View File

@@ -0,0 +1,35 @@
<template>
<div class="v2v-list-pair-summary">
<VtsCardRowKeyValue>
<template #key>{{ t('source') }}</template>
<template #value>{{ sourceHost }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('destination') }}</template>
<template #value>{{ poolName }}</template>
</VtsCardRowKeyValue>
<p class="note typo-body-regular-small">{{ t('v2v:list-pair-info') }}</p>
</div>
</template>
<script lang="ts" setup>
import type { V2vListPair } from '@/modules/v2v/composables/use-v2v-selected-vms.composable.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import { useI18n } from 'vue-i18n'
defineProps<V2vListPair>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.v2v-list-pair-summary {
display: flex;
flex-direction: column;
gap: 0.4rem;
.note {
color: var(--color-neutral-txt-secondary);
}
}
</style>

View File

@@ -0,0 +1,63 @@
import V2vSelectedVmItem from '@/modules/v2v/components/selected-vms/V2vSelectedVmItem.vue'
import type { V2vSelectedVm } from '@/modules/v2v/composables/use-v2v-selected-vms.composable.ts'
import { createV2vSelectedVm } from '@/test/create-v2v-selected-vms.ts'
import { createV2vWizardRouter } from '@/test/create-v2v-wizard-router.ts'
import { findAccentedInfos, findAccentedTags } from '@/test/find-accented-labels.ts'
import { findParts } from '@/test/find-parts.ts'
import { getButton } from '@/test/get-button.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 ITEM = createV2vSelectedVm()
async function mountItem(item: V2vSelectedVm = ITEM) {
const router = await createV2vWizardRouter('vms')
return mount(V2vSelectedVmItem, { props: { item }, global: createGlobalTestConfig({ router }) })
}
it('links the name of the VM to its mapping', async () => {
const link = (await mountItem()).getComponent(UiLink)
expect(link.text()).toBe(ITEM.name)
expect(link.attributes('href')).toBe(`/admin/v2v/migrate?step=mapping&vm=${ITEM.id}`)
})
it('tags the VM with its kind', async () => {
const kind = { label: 'Resume', accent: 'info' } as const
expect(findAccentedTags(await mountItem(createV2vSelectedVm({ kind })))).toEqual([kind])
})
it('has no kind tag without a kind', async () => {
expect(findAccentedTags(await mountItem(createV2vSelectedVm({ kind: undefined })))).toEqual([])
})
it('lays out its mapping', async () => {
expect(findParts(await mountItem(), '.mapping')).toEqual(ITEM.mappingParts)
})
it('has no mapping line without mapping parts', async () => {
expect((await mountItem(createV2vSelectedVm({ mappingParts: [] }))).find('.mapping').exists()).toBe(false)
})
it('tells its check with its accent', async () => {
const check = { label: 'Import in progress', accent: 'danger' } as const
expect(findAccentedInfos(await mountItem(createV2vSelectedVm({ check })))).toEqual([check])
})
// The router alone marks every mapping link as current: they only differ by their query
it('does not mark its link as the current page', async () => {
expect((await mountItem()).get('a').attributes('aria-current')).toBe('false')
})
it('removes the VM from the list', async () => {
const wrapper = await mountItem()
await getButton(wrapper, t('action:remove-name-from-list', { name: ITEM.name })).trigger('click')
expect(wrapper.emitted('remove')).toEqual([[ITEM.id]])
})

View File

@@ -0,0 +1,64 @@
<template>
<UiPanelCard class="v2v-selected-vm-item">
<UiPanelCardTitle size="small">
<!-- The router alone marks every mapping link as current: they only differ by their query -->
<template #label>
<UiLink size="small" :to="item.mappingRoute" :aria-current="false">
{{ item.name }}
</UiLink>
</template>
<template v-if="item.kind" #action>
<UiTag :accent="item.kind.accent" variant="secondary">{{ item.kind.label }}</UiTag>
</template>
<template #more-actions>
<UiButtonIcon
v-tooltip="removeLabel"
icon="action:close-cancel-clear"
accent="brand"
size="small"
:aria-label="removeLabel"
@click="emit('remove', item.id)"
/>
</template>
</UiPanelCardTitle>
<InlineParts v-if="item.mappingParts.length > 0" class="mapping typo-body-regular-small">
<span v-for="part of item.mappingParts" :key="part">{{ part }}</span>
</InlineParts>
<UiInfo :accent="item.check.accent" size="small">{{ item.check.label }}</UiInfo>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { V2vSelectedVm } from '@/modules/v2v/composables/use-v2v-selected-vms.composable.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { XoV2vVm } from '@vates/types'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { item } = defineProps<{
item: V2vSelectedVm
}>()
const emit = defineEmits<{
remove: [id: XoV2vVm['id']]
}>()
const { t } = useI18n()
const removeLabel = computed(() => t('action:remove-name-from-list', { name: item.name }))
</script>
<style lang="postcss" scoped>
.v2v-selected-vm-item {
.mapping {
color: var(--color-neutral-txt-secondary);
}
}
</style>

View File

@@ -0,0 +1,152 @@
import V2vListPairSummary from '@/modules/v2v/components/selected-vms/V2vListPairSummary.vue'
import V2vSelectedVmItem from '@/modules/v2v/components/selected-vms/V2vSelectedVmItem.vue'
import V2vSelectedVmsPanel from '@/modules/v2v/components/selected-vms/V2vSelectedVmsPanel.vue'
import type { V2vAccentedLabel } from '@/modules/v2v/composables/use-v2v-vm-labels.composable.ts'
import { createV2vListPair, createV2vSelectedVm } from '@/test/create-v2v-selected-vms.ts'
import { findAccentedTags } from '@/test/find-accented-labels.ts'
import { findParts } from '@/test/find-parts.ts'
import { findButton, getButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import type { TagAccent } from '@core/components/ui/tag/UiTag.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
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 READINESS_TAGS: V2vAccentedLabel<TagAccent>[] = [
{ label: '1 ready', accent: 'success' },
{ label: '1 blocking', accent: 'danger' },
]
const LIST_PAIR = createV2vListPair()
const EMPTY = {
items: [],
count: 0,
isEmpty: true,
readinessTags: [],
listPair: LIST_PAIR,
}
function mountPanel(props: Partial<ComponentProps<typeof V2vSelectedVmsPanel>> = {}) {
return mount(V2vSelectedVmsPanel, {
props: {
items: ITEMS,
count: ITEMS.length,
isEmpty: false,
isBusy: false,
readinessTags: READINESS_TAGS,
listPair: LIST_PAIR,
...props,
},
global: createGlobalTestConfig(),
})
}
// The actions of a side panel sit in its header, above its cards
function getHeaderClearButton(wrapper: VueWrapper) {
return getButton(wrapper.getComponent(VtsSidePanel).get('.ui-panel > .header'), t('action:clear'))
}
function getSummaryCard(wrapper: VueWrapper) {
return wrapper.getComponent(UiPanelCard)
}
function findStateHeroTypes(wrapper: VueWrapper) {
return wrapper.findAllComponents(VtsStateHero).map(stateHero => stateHero.props('type'))
}
describe('with VMs', () => {
// Its VMs open it like a selection, but are none to clear: it only closes where closing collapses it
it('is the persistent side panel of the page, holding its VMs as a selection', () => {
expect(mountPanel().getComponent(VtsSidePanel).props()).toMatchObject({ hasSelection: true, persistent: true })
})
it('titles its summary card with the selected VMs and their count', () => {
const title = getSummaryCard(mountPanel()).getComponent(UiPanelCardTitle)
expect(title.props('label')).toBe(t('selected-vms'))
expect(title.getComponent(UiCounter).text()).toBe('2')
})
it('tells where the list is kept', () => {
expect(findParts(getSummaryCard(mountPanel()), '.storage-info')).toEqual([
t('v2v:saved-in-this-browser'),
t('v2v:sent-to-xo-at-launch'),
])
})
it('summarizes the source and pool of the list', () => {
expect(getSummaryCard(mountPanel()).getComponent(V2vListPairSummary).props()).toEqual(LIST_PAIR)
})
it('tags the readiness of the VMs', () => {
expect(findAccentedTags(getSummaryCard(mountPanel()))).toEqual(READINESS_TAGS)
})
it('shows one card per VM after the summary, in order', () => {
const wrapper = mountPanel()
const items = wrapper.findAllComponents(V2vSelectedVmItem)
expect(items.map(item => item.props('item'))).toEqual(ITEMS)
expect(wrapper.findAllComponents(UiPanelCard)).toHaveLength(1 + ITEMS.length)
})
it('removes the VM of an item', () => {
const wrapper = mountPanel()
wrapper.findAllComponents(V2vSelectedVmItem)[1].vm.$emit('remove', ITEMS[1].id)
expect(wrapper.emitted('remove')).toEqual([[ITEMS[1].id]])
})
it('clears the list from its actions', async () => {
const wrapper = mountPanel()
await getHeaderClearButton(wrapper).trigger('click')
expect(wrapper.emitted('clear')).toEqual([[]])
})
it('has no state hero', () => {
expect(findStateHeroTypes(mountPanel())).toEqual([])
})
})
describe('busy', () => {
it('shows the loading state instead of the VMs, their readiness and the clear', () => {
const wrapper = mountPanel({ isBusy: true })
expect(findStateHeroTypes(wrapper)).toEqual(['busy'])
expect(wrapper.findAllComponents(V2vSelectedVmItem)).toEqual([])
expect(findAccentedTags(getSummaryCard(wrapper))).toEqual([])
expect(findButton(wrapper, t('action:clear'))).toBeUndefined()
})
it('still titles the selected VMs with their count and summarizes the source and pool of the list', () => {
const wrapper = mountPanel({ isBusy: true })
expect(getSummaryCard(wrapper).getComponent(UiCounter).text()).toBe('2')
expect(getSummaryCard(wrapper).getComponent(V2vListPairSummary).props()).toEqual(LIST_PAIR)
})
})
describe('empty', () => {
it('holds no selection', () => {
expect(mountPanel(EMPTY).getComponent(VtsSidePanel).props('hasSelection')).toBe(false)
})
it('offers no clear', () => {
expect(findButton(mountPanel(EMPTY), t('action:clear'))).toBeUndefined()
})
})

View File

@@ -0,0 +1,73 @@
<template>
<VtsSidePanel class="v2v-selected-vms-panel" :has-selection="!isEmpty" persistent>
<template v-if="areItemsShown" #actions>
<UiButton
accent="brand"
variant="tertiary"
size="medium"
left-icon="action:close-cancel-clear"
@click="emit('clear')"
>
{{ t('action:clear') }}
</UiButton>
</template>
<UiPanelCard>
<UiPanelCardTitle
size="medium"
icon="fa:list"
:label="t('selected-vms')"
:counter="{ value: count, accent: 'brand' }"
/>
<InlineParts class="storage-info typo-body-regular-small">
<span>{{ t('v2v:saved-in-this-browser') }}</span>
<span>{{ t('v2v:sent-to-xo-at-launch') }}</span>
</InlineParts>
<V2vListPairSummary v-bind="listPair" />
<UiTagsList v-if="areItemsShown">
<UiTag v-for="tag of readinessTags" :key="tag.label" :accent="tag.accent" variant="secondary">
{{ tag.label }}
</UiTag>
</UiTagsList>
</UiPanelCard>
<VtsStateHero v-if="isBusy" format="panel" type="busy" size="small" />
<template v-else>
<V2vSelectedVmItem v-for="item of items" :key="item.id" :item @remove="id => emit('remove', id)" />
</template>
</VtsSidePanel>
</template>
<script lang="ts" setup>
import V2vListPairSummary from '@/modules/v2v/components/selected-vms/V2vListPairSummary.vue'
import V2vSelectedVmItem from '@/modules/v2v/components/selected-vms/V2vSelectedVmItem.vue'
import type { V2vSelectedVmsPanelProps } from '@/modules/v2v/composables/use-v2v-selected-vms.composable.ts'
import InlineParts from '@/shared/components/InlineParts.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import type { XoV2vVm } from '@vates/types'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { isEmpty, isBusy } = defineProps<V2vSelectedVmsPanelProps>()
const emit = defineEmits<{
remove: [id: XoV2vVm['id']]
clear: []
}>()
const { t } = useI18n()
const areItemsShown = computed(() => !isEmpty && !isBusy)
</script>
<style lang="postcss" scoped>
.v2v-selected-vms-panel {
.storage-info {
color: var(--color-neutral-txt-secondary);
}
}
</style>

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