mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(xo6/VM): migrate action (#10615)
See XO-1758 The migration drawer has two steps: 1. Select the destination host (and optionally the migration network). 2. Map the VM disks to SRs and the VM interfaces to networks on the destination.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="vts-error-list">
|
||||
<VtsIcon name="status:danger-circle" size="medium" class="icon" />
|
||||
<ul class="list typo-body-regular-small">
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
|
||||
defineSlots<{
|
||||
default(): any
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vts-error-list {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
|
||||
.list {
|
||||
list-style: disc;
|
||||
padding-inline-start: 1.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -81,6 +81,11 @@ const textMappings: Record<VmBlockedOperations, BlockedOperationText> = {
|
||||
message: t('vm-protected-deletion'),
|
||||
subMessage: t('vm-disable-delete-protection'),
|
||||
},
|
||||
migrate_send: {
|
||||
title: t('migration-blocked'),
|
||||
message: t('vm-protected-migration'),
|
||||
subMessage: t('vm-disable-migration-protection'),
|
||||
},
|
||||
}
|
||||
|
||||
const modalTexts = useMapper(() => blockedOperation, textMappings, 'clean_shutdown')
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="vts-flag-legend">
|
||||
<VtsIcon name="fa:circle" size="current" class="dot" />
|
||||
<span class="typo-body-regular-small">
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
|
||||
defineSlots<{
|
||||
default(): any
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vts-flag-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
color: var(--color-info-txt-base);
|
||||
|
||||
.dot {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<tr class="vts-row" :class="{ selected }">
|
||||
<tr class="vts-row" :class="{ selected, error }">
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
selected?: boolean
|
||||
error?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -16,6 +17,10 @@ defineProps<{
|
||||
--ui-table-cell-background-color: var(--color-brand-background-selected);
|
||||
}
|
||||
|
||||
&.error {
|
||||
--ui-table-cell-background-color: var(--color-danger-background-selected);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
:deep(th),
|
||||
:deep(td) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<UiTableCell align="center" class="vts-flag-cell">
|
||||
<VtsIcon v-if="flagged" name="fa:circle" size="current" class="dot" />
|
||||
</UiTableCell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
|
||||
|
||||
defineProps<{
|
||||
flagged: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vts-flag-cell {
|
||||
width: 4rem;
|
||||
|
||||
.dot {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-info-txt-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "تنازلي",
|
||||
"description": "الوصف",
|
||||
"destination-sr": "SR الوجهة",
|
||||
"destination-sr-mandatory": "SR الوجهة إلزامي.",
|
||||
"device": "الجهاز",
|
||||
"device-config": "تكوين الجهاز",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -343,7 +343,6 @@
|
||||
"descending": "sestupně",
|
||||
"description": "Popis",
|
||||
"destination-sr": "Cílový repozitář úložiště",
|
||||
"destination-sr-mandatory": "Je třeba určit cílový repozitář úložiště.",
|
||||
"device": "Zařízení",
|
||||
"device-config": "Nastavení zařízení",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "faldende",
|
||||
"description": "Beskrivelse",
|
||||
"destination-sr": "Destinations-SR",
|
||||
"destination-sr-mandatory": "Destinations-SR er obligatorisk.",
|
||||
"device": "Enhed",
|
||||
"device-config": "Enhedskonfiguration",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
"action:export-table-to": "Export table to {type}",
|
||||
"action:export-vdi": "Export a VDI",
|
||||
"action:export-vm": "Export VM | Export VMs",
|
||||
"action:fill-empty-networks": "Fill empty networks",
|
||||
"action:fill-empty-srs": "Fill empty SRs",
|
||||
"action:filter": "Filter",
|
||||
"action:force-migrate-on-sr": "Force migrate on SR",
|
||||
"action:force-reboot": "Force reboot",
|
||||
@@ -133,6 +135,8 @@
|
||||
"action:reboot": "Reboot",
|
||||
"action:reboot-host": "@:action:reboot host",
|
||||
"action:reformat": "Reformat",
|
||||
"action:reset-all-networks": "Reset all networks",
|
||||
"action:reset-all-srs": "Reset all SRs",
|
||||
"action:reset-to-default": "Reset to default",
|
||||
"action:restart-toolstack": "Restart toolstack",
|
||||
"action:resume": "Resume",
|
||||
@@ -201,6 +205,8 @@
|
||||
"all-done!": "All done!",
|
||||
"all-good!": "All good!",
|
||||
"all-quiet-launchpad": "All quiet on the launchpad",
|
||||
"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",
|
||||
"allow": "Allow",
|
||||
"allow-self-signed-ssl": "You may need to allow self-signed SSL certificates in your browser",
|
||||
@@ -376,8 +382,11 @@
|
||||
"deploy-xoa-status:waiting": "Waiting for XOA to respond…",
|
||||
"descending": "descending",
|
||||
"description": "Description",
|
||||
"destination-host": "Destination host",
|
||||
"destination-network": "Destination network",
|
||||
"destination-network-required": "Destination network is required.",
|
||||
"destination-sr": "Destination SR",
|
||||
"destination-sr-mandatory": "Destination SR is mandatory.",
|
||||
"destination-sr-required": "Destination SR is required.",
|
||||
"details": "Details",
|
||||
"device": "Device",
|
||||
"device-config": "Device config",
|
||||
@@ -461,6 +470,10 @@
|
||||
"format": "Format",
|
||||
"free-space": "Free space",
|
||||
"free-space-on-sr": "Free space on SR",
|
||||
"from-device": "From device",
|
||||
"from-network": "From network",
|
||||
"from-sr": "From SR",
|
||||
"from-vlan": "From VLAN",
|
||||
"from-vm": "From VM",
|
||||
"full": "Full",
|
||||
"full-copy": "Full copy",
|
||||
@@ -680,7 +693,7 @@
|
||||
"job:vdi-export:format-required": "VDI format export is required",
|
||||
"job:vdi-export:missing-vdi": "No vdi to export",
|
||||
"job:vdi-migrate:in-progress": "Migrate in progress…",
|
||||
"job:vdi-migrate:missing-sr": "Destination SR is required",
|
||||
"job:vdi-migrate:missing-sr": "@:destination-sr-required",
|
||||
"job:vdi-migrate:missing-vdi": "No VDI selected",
|
||||
"job:vif-connect:missing-vif": "No VIFs to connect",
|
||||
"job:vif-connect:vif-already-connected": "VIF is already connected",
|
||||
@@ -708,9 +721,11 @@
|
||||
"job:vm-force-shutdown:in-progress": "Force shutdown in progress…",
|
||||
"job:vm-force-shutdown:missing-vm": "No VMs to force shutdown",
|
||||
"job:vm-migrate:bad-power-state": "VM must be running",
|
||||
"job:vm-migrate:blocked-operation": "VM migration is blocked",
|
||||
"job:vm-migrate:in-progress": "Migrate in progress…",
|
||||
"job:vm-migrate:missing-host": "Destination host must be provided",
|
||||
"job:vm-migrate:missing-vm": "No VMs to migrate",
|
||||
"job:vm-migrate:paused": "VM must not be paused",
|
||||
"job:vm-not-running": "VM must be running",
|
||||
"job:vm-pause:bad-power-state": "VM must be running",
|
||||
"job:vm-pause:blocked-operation": "VM pause is blocked",
|
||||
@@ -801,12 +816,15 @@
|
||||
"memory-usage": "Memory usage",
|
||||
"merge-backups-synchronously": "Merge backups synchronously",
|
||||
"message": "Message",
|
||||
"migration-blocked": "Migration Blocked",
|
||||
"migration-compression": "Migration compression",
|
||||
"migration-network": "Migration network",
|
||||
"minimum-dynamic-memory": "Minimum dynamic memory",
|
||||
"minimum-static-memory": "Minimum static memory",
|
||||
"missing-patches": "Missing patches",
|
||||
"missing-translations?": "Missing or incorrect translations?",
|
||||
"mixed-value": "[Mixed]",
|
||||
"modal:action:vm-force-migrate": "@:action:force-migrate VM",
|
||||
"modal:action:vm-force-reboot": "@:action:force-reboot VM",
|
||||
"modal:action:vm-force-shutdown": "@:action:force-shutdown VM",
|
||||
"modal:action:vm-reboot": "@:action:reboot VM",
|
||||
@@ -954,6 +972,9 @@
|
||||
"no-custom-field-detected": "No custom fields detected",
|
||||
"no-data": "No data",
|
||||
"no-data-to-calculate": "No data to calculate",
|
||||
"no-default-network-configured": "No default network is configured.",
|
||||
"no-empty-network-to-fill": "No empty network to fill",
|
||||
"no-empty-sr-to-fill": "No empty SR to fill",
|
||||
"no-group-attached": "No group attached",
|
||||
"no-host-attached": "No hosts attached",
|
||||
"no-host-detected": "No hosts detected",
|
||||
@@ -1007,6 +1028,7 @@
|
||||
"operation:force-reboot": "Force rebooting",
|
||||
"operation:force-shutdown": "Force shutting down",
|
||||
"operation:import": "Importing",
|
||||
"operation:migrate": "Migrating",
|
||||
"operation:pause": "Pausing",
|
||||
"operation:resume": "Resuming",
|
||||
"operation:shutdown": "Shutting down",
|
||||
@@ -1198,6 +1220,7 @@
|
||||
"select-destination-host": "Select a destination host",
|
||||
"select-format": "Select format",
|
||||
"select-host": "Select host",
|
||||
"select-pool": "Select a pool",
|
||||
"select-preset": "Select a preset",
|
||||
"select-to-see-details": "Click on {icon} to see details",
|
||||
"selected-vms-in-execution": "Some selected VMs are running",
|
||||
@@ -1337,6 +1360,9 @@
|
||||
"this-vm-cant-be-migrated": "This VM can't be migrated",
|
||||
"timeout": "Timeout",
|
||||
"timezone": "Timezone",
|
||||
"to-network": "To network",
|
||||
"to-sr": "To SR",
|
||||
"to-vlan": "To VLAN",
|
||||
"toolstack-uptime": "Toolstack uptime",
|
||||
"top-#": "Top {n}",
|
||||
"topology": "Topology",
|
||||
@@ -1414,6 +1440,7 @@
|
||||
"vdi-connect-title": "You are about to connect 1 VDI | You are about to connect {n} VDIs",
|
||||
"vdi-delete-warning": "This will permanently delete the selected VDI and all data stored on it. This action cannot be undone.",
|
||||
"vdi-description": "VDI description",
|
||||
"vdi-destination-sr-required": "{name}: @:destination-sr-required",
|
||||
"vdi-detach-info": "This will detach the selected VDI from the VM. The disk will not be deleted and can be reattached later.",
|
||||
"vdi-detach-title": "You are about to detach 1 VDI | You are about to detach {n} VDIs",
|
||||
"vdi-disconnect-info": "The VM will no longer have access to the VDI. The disk remains attached and you can reconnect later.",
|
||||
@@ -1427,6 +1454,7 @@
|
||||
"vdi-throughput": "VDI throughput",
|
||||
"vdis": "VDIs",
|
||||
"vdis-allocated-space": "VDIs allocated space",
|
||||
"vdis-must-be-migrated": "VDIs must be migrated.",
|
||||
"version": "Version",
|
||||
"version-number": "Version {version}",
|
||||
"vga": "VGA",
|
||||
@@ -1436,6 +1464,7 @@
|
||||
"vif": "VIF",
|
||||
"vif-connect-title": "You are about to connect 1 VIF | You are about to connect {n} VIFs",
|
||||
"vif-connected": "VIF is connected",
|
||||
"vif-destination-network-required": "{name}: @:destination-network-required",
|
||||
"vif-device": "VIF #{device}",
|
||||
"vif-disconnect-info": "The VM will no longer have access to the network. The VIF remains attached and you can reconnect it later.",
|
||||
"vif-disconnect-title": "You are about to disconnect 1 VIF | You are about to disconnect {n} VIFs",
|
||||
@@ -1443,6 +1472,7 @@
|
||||
"vif-required": "VIF is required.",
|
||||
"vif-status": "VIF status",
|
||||
"vifs": "VIFs",
|
||||
"vifs-must-be-reassigned": "VIFs must be reassigned.",
|
||||
"viridian": "Viridian",
|
||||
"virtual-tpm": "Virtual TPM (VTPM)",
|
||||
"virtualization-boot-settings": "Virtualization & boot settings",
|
||||
@@ -1455,6 +1485,7 @@
|
||||
"vm-delete-warning": "This will permanently delete the VM, its snapshots and its disks. Disks attached to another VM are kept. This action cannot be undone.",
|
||||
"vm-disable-blocked-protection": "To proceed, you can disable the protection in the VM’s configuration.",
|
||||
"vm-disable-delete-protection": "To remove it, please edit its configuration and disable the deletion protection.",
|
||||
"vm-disable-migration-protection": "To proceed, please edit its configuration and disable the migration protection.",
|
||||
"vm-is-running": "The VM is running",
|
||||
"vm-limit-topology": "VM limit topology",
|
||||
"vm-management": "VM management",
|
||||
@@ -1462,10 +1493,13 @@
|
||||
"vm-name-required": "VM name is required.",
|
||||
"vm-not-found": "@:vm @.lower:not-found",
|
||||
"vm-not-running": "VM not running",
|
||||
"vm-not-running-intra-pool-nothing-to-migrate": "The VM is not running: within its pool, there is no active RAM to migrate. As long as no disk moves to another SR, there is nothing to migrate.",
|
||||
"vm-not-running-no-disk-nothing-to-migrate": "Nothing to migrate: the VM is not running and has no disk.",
|
||||
"vm-off": "This virtual machine is powered off.",
|
||||
"vm-protected-deletion": "This VM is currently protected against deletion.",
|
||||
"vm-protected-force-reboot": "This VM is protected against force reboot.",
|
||||
"vm-protected-force-shutdown": "This VM is protected against force shutdown.",
|
||||
"vm-protected-migration": "This VM is protected against migration.",
|
||||
"vm-protected-pause": "This VM is protected against pause.",
|
||||
"vm-protected-reboot": "This VM is protected against reboot.",
|
||||
"vm-protected-shutdown": "This VM is protected against shutdown.",
|
||||
@@ -1526,5 +1560,6 @@
|
||||
"xoa-ssh-password-confirm-different": "SSH password confirmation is different",
|
||||
"xva": "XVA",
|
||||
"you-are-currently-on": "You are currently on: {0}",
|
||||
"your-default-network-is": "Your default network is {name}",
|
||||
"zstd": "ZSTD"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"descending": "descendente",
|
||||
"description": "Descripción",
|
||||
"destination-sr": "SR destino",
|
||||
"destination-sr-mandatory": "El SR de destino es obligatorio.",
|
||||
"device": "Dispositivo",
|
||||
"device-config": "Configuración del aparato",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "نزولی",
|
||||
"description": "توصیف",
|
||||
"destination-sr": "SR مقصد",
|
||||
"destination-sr-mandatory": "SR مقصد الزامی است.",
|
||||
"device": "دستگاه",
|
||||
"device-config": "پیکربندی دستگاه",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "laskeva",
|
||||
"description": "Kuvaus",
|
||||
"destination-sr": "Kohde-SR",
|
||||
"destination-sr-mandatory": "Kohde-SR on pakollinen.",
|
||||
"device": "Laite",
|
||||
"device-config": "Laitteen määritykset",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
"action:export-table-to": "Exporter le tableau en {type}",
|
||||
"action:export-vdi": "Exporter un VDI",
|
||||
"action:export-vm": "Exporter la VM | Exporter les VMs",
|
||||
"action:fill-empty-networks": "Remplir les réseaux vides",
|
||||
"action:fill-empty-srs": "Remplir les SRs vides",
|
||||
"action:filter": "Filtrer",
|
||||
"action:force-migrate-on-sr": "Forcer la migration sur le SR",
|
||||
"action:force-reboot": "Forcer le redémarrage",
|
||||
@@ -133,6 +135,8 @@
|
||||
"action:reboot": "Redémarrer",
|
||||
"action:reboot-host": "@:action:reboot l'hôte",
|
||||
"action:reformat": "Reformater",
|
||||
"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",
|
||||
"action:restart-toolstack": "Redémarrer le toolstack",
|
||||
"action:resume": "Reprendre",
|
||||
@@ -201,6 +205,8 @@
|
||||
"all-done!": "Tout est terminé !",
|
||||
"all-good!": "Tout va bien !",
|
||||
"all-quiet-launchpad": "Tout est calme sur la rampe de lancement",
|
||||
"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é",
|
||||
"allow": "Autoriser",
|
||||
"allow-self-signed-ssl": "Vous devrez peut-être autoriser les certificats SSL auto-signés depuis votre navigateur",
|
||||
@@ -376,8 +382,11 @@
|
||||
"deploy-xoa-status:waiting": "En attente de réponse de la XOA…",
|
||||
"descending": "descendant",
|
||||
"description": "Description",
|
||||
"destination-host": "Hôte de destination",
|
||||
"destination-network": "Réseau de destination",
|
||||
"destination-network-required": "Le réseau de destination est requis.",
|
||||
"destination-sr": "SR de destination",
|
||||
"destination-sr-mandatory": "Le SR de destination est obligatoire.",
|
||||
"destination-sr-required": "Le SR de destination est requis.",
|
||||
"details": "Détails",
|
||||
"device": "Appareil",
|
||||
"device-config": "Configuration de l'appareil",
|
||||
@@ -461,6 +470,10 @@
|
||||
"format": "Format",
|
||||
"free-space": "Espace libre",
|
||||
"free-space-on-sr": "Espace libre sur le SR",
|
||||
"from-device": "Interface source",
|
||||
"from-network": "Réseau source",
|
||||
"from-sr": "SR source",
|
||||
"from-vlan": "VLAN source",
|
||||
"from-vm": "Depuis la VM",
|
||||
"full": "Complet",
|
||||
"full-copy": "Copie complète",
|
||||
@@ -680,7 +693,7 @@
|
||||
"job:vdi-export:format-required": "Le format d'export d'un VDI est requis",
|
||||
"job:vdi-export:missing-vdi": "Aucun VDI à exporter",
|
||||
"job:vdi-migrate:in-progress": "Migration en cours…",
|
||||
"job:vdi-migrate:missing-sr": "Le SR de destination est requis",
|
||||
"job:vdi-migrate:missing-sr": "@:destination-sr-required",
|
||||
"job:vdi-migrate:missing-vdi": "Aucun VDI sélectionné",
|
||||
"job:vif-connect:missing-vif": "Aucun VIF à connecter",
|
||||
"job:vif-connect:vif-already-connected": "Le VIF est déjà connecté",
|
||||
@@ -708,9 +721,11 @@
|
||||
"job:vm-force-shutdown:in-progress": "Arrêt forcé en cours…",
|
||||
"job:vm-force-shutdown:missing-vm": "Aucune VM à arrêter de force",
|
||||
"job:vm-migrate:bad-power-state": "La VM doit être allumée",
|
||||
"job:vm-migrate:blocked-operation": "La migration de la VM est bloquée",
|
||||
"job:vm-migrate:in-progress": "Migration en cours…",
|
||||
"job:vm-migrate:missing-host": "L'hôte de destination est requis",
|
||||
"job:vm-migrate:missing-vm": "Aucune VM à migrer",
|
||||
"job:vm-migrate:paused": "La VM ne doit pas être en pause",
|
||||
"job:vm-not-running": "La VM doit être en cours d'exécution",
|
||||
"job:vm-pause:bad-power-state": "La VM doit être allumée",
|
||||
"job:vm-pause:blocked-operation": "La mise en pause de la VM est bloquée",
|
||||
@@ -801,12 +816,15 @@
|
||||
"memory-usage": "Utilisation de la mémoire",
|
||||
"merge-backups-synchronously": "Fusionner les sauvegardes de manière synchrone",
|
||||
"message": "Message",
|
||||
"migration-blocked": "Migration bloquée",
|
||||
"migration-compression": "Compression des migrations",
|
||||
"migration-network": "Réseau de migration",
|
||||
"minimum-dynamic-memory": "Mémoire dynamique minimale",
|
||||
"minimum-static-memory": "Mémoire statique minimale",
|
||||
"missing-patches": "Patches manquants",
|
||||
"missing-translations?": "Traductions manquantes ou incorrectes ?",
|
||||
"mixed-value": "[Mixte]",
|
||||
"modal:action:vm-force-migrate": "@:action:force-migrate de la VM",
|
||||
"modal:action:vm-force-reboot": "@:action:force-reboot la VM",
|
||||
"modal:action:vm-force-shutdown": "@:action:force-shutdown la VM",
|
||||
"modal:action:vm-reboot": "@:action:reboot la VM",
|
||||
@@ -954,6 +972,9 @@
|
||||
"no-custom-field-detected": "Aucun champ personnalisé détecté",
|
||||
"no-data": "Aucune donnée",
|
||||
"no-data-to-calculate": "Aucune donnée à calculer",
|
||||
"no-default-network-configured": "Aucun réseau par défaut n'est configuré.",
|
||||
"no-empty-network-to-fill": "Aucun réseau vide à remplir",
|
||||
"no-empty-sr-to-fill": "Aucun SR vide à remplir",
|
||||
"no-group-attached": "Pas de groupe attaché",
|
||||
"no-host-attached": "Aucun hôte attaché",
|
||||
"no-host-detected": "Aucun hôte détecté",
|
||||
@@ -1007,6 +1028,7 @@
|
||||
"operation:force-reboot": "Redémarrage forcé",
|
||||
"operation:force-shutdown": "Arrêt forcé",
|
||||
"operation:import": "Importation",
|
||||
"operation:migrate": "Migration en cours",
|
||||
"operation:pause": "Mise en pause",
|
||||
"operation:resume": "Reprise",
|
||||
"operation:shutdown": "Arrêt",
|
||||
@@ -1198,6 +1220,7 @@
|
||||
"select-destination-host": "Sélectionnez un hôte de destination",
|
||||
"select-format": "Sélectionner le format",
|
||||
"select-host": "Sélectionnez un hôte",
|
||||
"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-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
|
||||
@@ -1337,6 +1360,9 @@
|
||||
"this-vm-cant-be-migrated": "Cette VM ne peut pas être migrée",
|
||||
"timeout": "Délai d'attente",
|
||||
"timezone": "Fuseau horaire",
|
||||
"to-network": "Réseau cible",
|
||||
"to-sr": "SR cible",
|
||||
"to-vlan": "VLAN cible",
|
||||
"toolstack-uptime": "Temps de disponibilité de Toolstack",
|
||||
"top-#": "Top {n}",
|
||||
"topology": "Topologie",
|
||||
@@ -1414,6 +1440,7 @@
|
||||
"vdi-connect-title": "Vous êtes sur le point de connecter 1 VDI | Vous êtes sur le point de connecter {n} VDIs",
|
||||
"vdi-delete-warning": "Cela supprimera définitivement le VDI sélectionné ainsi que toutes les données qu'il contient. Cette action est irréversible.",
|
||||
"vdi-description": "Description du VDI",
|
||||
"vdi-destination-sr-required": "{name} : le SR de destination est requis.",
|
||||
"vdi-detach-info": "Cela détachera le VDI sélectionné de la VM. Le disque ne sera pas supprimé et pourra être rattaché ultérieurement.",
|
||||
"vdi-detach-title": "Vous êtes sur le point de détacher 1 VDI | Vous êtes sur le point de détacher {n} VDIs",
|
||||
"vdi-disconnect-info": "La VM n'aura plus accès au VDI. Le disque reste attaché et vous pourrez le reconnecter ultérieurement.",
|
||||
@@ -1427,6 +1454,7 @@
|
||||
"vdi-throughput": "Débit du VDI",
|
||||
"vdis": "VDIs",
|
||||
"vdis-allocated-space": "Espace alloué aux VDIs",
|
||||
"vdis-must-be-migrated": "Les VDIs doivent être migrés.",
|
||||
"version": "Version",
|
||||
"version-number": "Version {version}",
|
||||
"vga": "VGA",
|
||||
@@ -1436,6 +1464,7 @@
|
||||
"vif": "VIF",
|
||||
"vif-connect-title": "Vous êtes sur le point de connecter 1 VIF | Vous êtes sur le point de connecter {n} VIFs",
|
||||
"vif-connected": "Le VIF est connecté",
|
||||
"vif-destination-network-required": "{name} : le réseau de destination est requis.",
|
||||
"vif-device": "VIF #{device}",
|
||||
"vif-disconnect-info": "La VM n'aura plus accès au réseau. Le VIF reste attaché et vous pourrez le reconnecter ultérieurement.",
|
||||
"vif-disconnect-title": "Vous êtes sur le point de déconnecter 1 VIF | Vous êtes sur le point de déconnecter {n} VIFs",
|
||||
@@ -1443,6 +1472,7 @@
|
||||
"vif-required": "Le VIF est requis.",
|
||||
"vif-status": "Statut du VIF",
|
||||
"vifs": "VIFs",
|
||||
"vifs-must-be-reassigned": "Les VIFs doivent être réassignés.",
|
||||
"viridian": "Viridian",
|
||||
"virtual-tpm": "TPM virtuel (VTPM)",
|
||||
"virtualization-boot-settings": "Virtualisation & paramètres de démarrage",
|
||||
@@ -1455,6 +1485,7 @@
|
||||
"vm-delete-warning": "Cela supprimera définitivement la VM, ses snapshots et ses disques. Les disques attachés à une autre VM sont conservés. Cette action est irréversible.",
|
||||
"vm-disable-blocked-protection": "Pour continuer, vous pouvez désactiver la protection dans la configuration de la VM.",
|
||||
"vm-disable-delete-protection": "Pour la supprimer, veuillez modifier sa configuration et désactiver la protection contre la suppression.",
|
||||
"vm-disable-migration-protection": "Pour continuer, veuillez modifier sa configuration et désactiver la protection contre la migration.",
|
||||
"vm-is-running": "La VM est en cours d'exécution",
|
||||
"vm-limit-topology": "Limite de topologie physique des VMs",
|
||||
"vm-management": "Gestion de la VM",
|
||||
@@ -1462,10 +1493,13 @@
|
||||
"vm-name-required": "Le nom de la VM est requis.",
|
||||
"vm-not-found": "@:vm introuvable",
|
||||
"vm-not-running": "VM non démarrée",
|
||||
"vm-not-running-intra-pool-nothing-to-migrate": "La VM n'est pas démarrée : dans son pool, il n'y a pas de RAM active à migrer. Tant qu'aucun disque ne change de SR, il n'y a rien à migrer.",
|
||||
"vm-not-running-no-disk-nothing-to-migrate": "Rien à migrer : la VM n'est pas démarrée et n'a aucun disque.",
|
||||
"vm-off": "Cette machine virtuelle est éteinte.",
|
||||
"vm-protected-deletion": "Cette VM est actuellement protégée contre la suppression.",
|
||||
"vm-protected-force-reboot": "Cette VM est protégée contre le redémarrage forcé.",
|
||||
"vm-protected-force-shutdown": "Cette VM est protégée contre l'arrêt forcé.",
|
||||
"vm-protected-migration": "Cette VM est protégée contre la migration.",
|
||||
"vm-protected-pause": "Cette VM est protégée contre la mise en pause.",
|
||||
"vm-protected-reboot": "Cette VM est protégée contre le redémarrage.",
|
||||
"vm-protected-shutdown": "Cette VM est protégée contre l'arrêt.",
|
||||
@@ -1526,5 +1560,6 @@
|
||||
"xoa-ssh-password-confirm-different": "La confirmation du mot de passe SSH est différente",
|
||||
"xva": "XVA",
|
||||
"you-are-currently-on": "Vous êtes actuellement sur : {0}",
|
||||
"your-default-network-is": "Votre réseau par défaut est {name}",
|
||||
"zstd": "ZSTD"
|
||||
}
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "decrescente",
|
||||
"description": "Descrizione",
|
||||
"destination-sr": "SR di destinazione",
|
||||
"destination-sr-mandatory": "L'SR di destinazione è obbligatorio.",
|
||||
"device": "Dispositivo",
|
||||
"device-config": "Configurazione del dispositivo",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "降順",
|
||||
"description": "説明",
|
||||
"destination-sr": "移行先 SR",
|
||||
"destination-sr-mandatory": "移行先 SR は必須です。",
|
||||
"device": "リモートデバイス",
|
||||
"device-config": "デバイス設定",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "내림차순",
|
||||
"description": "설명",
|
||||
"destination-sr": "대상 SR",
|
||||
"destination-sr-mandatory": "대상 SR은 필수입니다.",
|
||||
"device": "장치",
|
||||
"device-config": "장치 설정",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "Synkende",
|
||||
"description": "Beskrivelse",
|
||||
"destination-sr": "Mål-SR",
|
||||
"destination-sr-mandatory": "Mål-SR er påkrevd.",
|
||||
"device": "Enhet",
|
||||
"device-config": "Enhetskonfigurasjon",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"descending": "aflopend",
|
||||
"description": "Omschrijving",
|
||||
"destination-sr": "Bestemming SR",
|
||||
"destination-sr-mandatory": "Bestemming SR is verplicht.",
|
||||
"device": "Apparaat",
|
||||
"device-config": "Apparaatconfiguratie",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "malejąco",
|
||||
"description": "Opis",
|
||||
"destination-sr": "Docelowe SR",
|
||||
"destination-sr-mandatory": "Docelowe SR jest obowiązkowe.",
|
||||
"device": "Urządzenie",
|
||||
"device-config": "Konfiguracja urządzenia",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "descendente",
|
||||
"description": "Descrição",
|
||||
"destination-sr": "SR de destino",
|
||||
"destination-sr-mandatory": "O SR de destino é obrigatório.",
|
||||
"device": "Dispositivo",
|
||||
"device-config": "Configuração do dispositivo",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "по убыванию",
|
||||
"description": "Описание",
|
||||
"destination-sr": "Целевой SR",
|
||||
"destination-sr-mandatory": "Целевой SR обязателен.",
|
||||
"device": "Устройство",
|
||||
"device-config": "Конфигурация устройства",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"descending": "zostupne",
|
||||
"description": "Popis",
|
||||
"destination-sr": "Cieľový repozitár úložiska",
|
||||
"destination-sr-mandatory": "Je potrebné určiť cieľový repozitár úložiska.",
|
||||
"device": "Zariadenie",
|
||||
"device-config": "Konfigurácia zariadenia",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -325,7 +325,6 @@
|
||||
"descending": "fallande",
|
||||
"description": "Beskrivning",
|
||||
"destination-sr": "Destinations-SR",
|
||||
"destination-sr-mandatory": "Destinations-SR krävs.",
|
||||
"device": "Enhet",
|
||||
"device-config": "Enhetskonfiguration",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -330,7 +330,6 @@
|
||||
"descending": "azalan",
|
||||
"description": "Açıklama",
|
||||
"destination-sr": "Hedef SR",
|
||||
"destination-sr-mandatory": "Hedef SR zorunludur.",
|
||||
"device": "Aygıt",
|
||||
"device-config": "Aygıt yapılandırması",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"descending": "за спаданням",
|
||||
"description": "Опис",
|
||||
"destination-sr": "SR призначення",
|
||||
"destination-sr-mandatory": "SR призначення обов'язковий.",
|
||||
"device": "Пристрій",
|
||||
"device-config": "Конфігурація пристрою",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -330,9 +330,8 @@
|
||||
"deploy-xoa-status:starting": "正在启动XOA…",
|
||||
"deploy-xoa-status:waiting": "等待XOA响应…",
|
||||
"descending": "降序",
|
||||
"description": "描述",
|
||||
"destination-sr": "目标存储库",
|
||||
"destination-sr-mandatory": "目标存储库为必填项。",
|
||||
"description": "备注说明",
|
||||
"destination-sr": "目标SR",
|
||||
"device": "设备",
|
||||
"device-config": "设备配置",
|
||||
"dhcp": "DHCP",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import VtsFlagCell from '@core/components/table/cells/VtsFlagCell.vue'
|
||||
import { defineColumn } from '@core/packages/table/define-column.ts'
|
||||
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
|
||||
import type { HeaderConfig } from '@core/tables/types.ts'
|
||||
import { h } from 'vue'
|
||||
|
||||
export const useFlagColumn = defineColumn((config?: HeaderConfig) => ({
|
||||
renderHead: () => renderHeadCell(config?.headerLabel),
|
||||
renderBody: (flagged: boolean) => h(VtsFlagCell, { flagged }),
|
||||
}))
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineColumns } from '@core/packages/table/define-columns.ts'
|
||||
import { useFlagColumn } from '@core/tables/column-definitions/flag-column.ts'
|
||||
import { useNumberColumn } from '@core/tables/column-definitions/number-column.ts'
|
||||
import { useSelectColumn } from '@core/tables/column-definitions/select-column.ts'
|
||||
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export const useVmMigrateVdiColumns = defineColumns(() => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return {
|
||||
vdi: useTextColumn({ headerLabel: () => t('vdi') }),
|
||||
usedSpace: useNumberColumn({ headerLabel: () => t('used-space') }),
|
||||
fromSr: useTextColumn({ headerLabel: () => t('from-sr') }),
|
||||
toSr: useSelectColumn({ headerLabel: () => t('to-sr') }),
|
||||
flag: useFlagColumn(),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineColumns } from '@core/packages/table/define-columns.ts'
|
||||
import { useFlagColumn } from '@core/tables/column-definitions/flag-column.ts'
|
||||
import { useNumberColumn } from '@core/tables/column-definitions/number-column.ts'
|
||||
import { useSelectColumn } from '@core/tables/column-definitions/select-column.ts'
|
||||
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export const useVmMigrateVifColumns = defineColumns(() => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return {
|
||||
fromNetwork: useTextColumn({ headerLabel: () => t('from-network') }),
|
||||
fromDevice: useTextColumn({ headerLabel: () => t('from-device') }),
|
||||
fromVlan: useNumberColumn({ headerLabel: () => t('from-vlan') }),
|
||||
toNetwork: useSelectColumn({ headerLabel: () => t('to-network') }),
|
||||
toVlan: useNumberColumn({ headerLabel: () => t('to-vlan') }),
|
||||
flag: useFlagColumn(),
|
||||
}
|
||||
})
|
||||
@@ -23,6 +23,7 @@ export type VmBlockedOperations =
|
||||
| 'hard_reboot'
|
||||
| 'suspend'
|
||||
| 'destroy'
|
||||
| 'migrate_send'
|
||||
|
||||
export type ActionsByObject = {
|
||||
vm: VmActions
|
||||
|
||||
@@ -11,6 +11,10 @@ Domain objects are created through factories in `src/test/`, one per object type
|
||||
| `createSr` | `FrontXoSr` |
|
||||
| `createVbd` | `FrontXoVbd` |
|
||||
| `createVdi` | `FrontXoVdi` |
|
||||
| `createNetwork` | `FrontXoNetwork` |
|
||||
| `createPif` | `FrontXoPif` |
|
||||
| `createVif` | `FrontXoVif` |
|
||||
| `createPbd` | `FrontXoPbd` |
|
||||
| `createVmSnapshot` | `FrontXoVmSnapshot` |
|
||||
| `createPlugin` | `FrontXoPlugin` |
|
||||
| `createHostStats` | `XapiHostStats` |
|
||||
@@ -33,7 +37,7 @@ const poolStats = createPoolStats({
|
||||
const dashboard = createPoolDashboardTopFiveUsage({ cpu: [createHostCpuUsage()] }, { cpu: [createVmCpuUsage()] })
|
||||
```
|
||||
|
||||
Shared _helper_ factories live there too — `mount-composable.ts`, `mount-chart-card.ts`, `create-enhanced-data-helpers.ts`, `global-test-config.ts`, `create-test-router.ts`, `create-job-result.ts` (what a mocked `defineJob` composable returns), `create-action-item.ts` (a menu / button `LeafActionItem`), `create-plugin-actions.ts` (what a mocked `useXoPluginActions` returns), `find-rendered-values.ts`, `find-titles.ts` (the card and section titles), `find-tooltip-content.ts` (the tooltip an element shows on hover), `get-button.ts` (a button by its label), `get-checkbox.ts` (the input of a `UiCheckbox` by its label), `drawer.ts` (submit or cancel a drawer, read its buttons), `select.ts` (open a `VtsSelect`, read or pick its options), `mount-json-schema-form.ts` (a JSON schema form, or a composable run in one of its fields), `json-schema-item-label.ts` (the labels of the items of a JSON schema list), `i18n.ts` and `linear-chart-stub.ts`.
|
||||
Shared _helper_ factories live there too — `mount-composable.ts`, `mount-chart-card.ts`, `create-enhanced-data-helpers.ts`, `global-test-config.ts`, `create-test-router.ts`, `create-job-result.ts` (what a mocked `defineJob` composable returns), `create-action-item.ts` (a menu / button `LeafActionItem`), `create-plugin-actions.ts` (what a mocked `useXoPluginActions` returns), `find-rendered-values.ts`, `find-tags.ts` (the labels of the rendered tags), `find-titles.ts` (the card and section titles), `find-tooltip-content.ts` (the tooltip an element shows on hover), `get-button.ts` (a button by its label), `get-checkbox.ts` (the input of a `UiCheckbox` by its label), `drawer.ts` (submit or cancel a drawer, read its buttons), `select.ts` (open a `VtsSelect`, read or pick its options), `mount-json-schema-form.ts` (a JSON schema form, or a composable run in one of its fields), `json-schema-item-label.ts` (the labels of the items of a JSON schema list), `group-by.ts`, `i18n.ts` and `linear-chart-stub.ts`.
|
||||
|
||||
Each factory returns a **fully-populated** object of the real front-end type and accepts a `Partial<T>` of overrides, spread last, so a test only states the fields relevant to its case:
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useXoPbdUtils } from '@/modules/pbd/composables/xo-pbd-utils.composable
|
||||
import { useXoPbdCollection, type FrontXoPbd } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
|
||||
import { getPbdsConnectionStatus } from '@/modules/pbd/utils/xo-pbd.util.ts'
|
||||
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import { ONE_GB } from '@/shared/constants.ts'
|
||||
import { type IconName, objectIcon } from '@core/icons'
|
||||
import { SR_SCOPE_TYPE, type SrScope } from '@core/types/storage-repository.type.ts'
|
||||
import { toComputed } from '@core/utils/to-computed.util.ts'
|
||||
@@ -112,6 +113,12 @@ export function useXoSrUtils(
|
||||
return hostName ?? t('unknown')
|
||||
}
|
||||
|
||||
function getSrSelectLabel(sr: FrontXoSr): string {
|
||||
const gbLeft = Math.floor(Math.max(sr.size - sr.physical_usage, 0) / ONE_GB)
|
||||
|
||||
return `${sr.name_label} (${getSrLocation(sr)}) - ${t('n-gb-left', { n: gbLeft })}`
|
||||
}
|
||||
|
||||
function getSrAccessModeLabel(sr: FrontXoSr): string {
|
||||
return sr.shared ? t('shared') : t('local')
|
||||
}
|
||||
@@ -127,6 +134,7 @@ export function useXoSrUtils(
|
||||
srStatusIcon,
|
||||
getSrStatusIcon,
|
||||
getSrLocation,
|
||||
getSrSelectLabel,
|
||||
getSrAccessModeLabel,
|
||||
getSrProvisioningLabel,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import { isSrWritable } from '@/modules/storage-repository/utils/xo-sr.util.ts'
|
||||
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import { ONE_GB } from '@/shared/constants.ts'
|
||||
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import { objectIcon } from '@core/icons'
|
||||
import { required } from '@core/packages/form-validation'
|
||||
@@ -43,7 +42,7 @@ export function useVdiMigrateForm(rawVdi: MaybeRefOrGetter<FrontXoVdi>) {
|
||||
})
|
||||
|
||||
const { srs, useGetSrById } = useXoSrCollection()
|
||||
const { getSrLocation } = useXoSrUtils()
|
||||
const { getSrSelectLabel } = useXoSrUtils()
|
||||
const { pbdsBySr } = useXoPbdCollection()
|
||||
|
||||
const availableSrs = computed(() =>
|
||||
@@ -69,10 +68,7 @@ export function useVdiMigrateForm(rawVdi: MaybeRefOrGetter<FrontXoVdi>) {
|
||||
required: true,
|
||||
placeholder: () => t('action:select-storage'),
|
||||
option: {
|
||||
label: sr => {
|
||||
const gbLeft = Math.floor(Math.max(sr.size - sr.physical_usage, 0) / ONE_GB)
|
||||
return `${sr.name_label} (${getSrLocation(sr)}) - ${t('n-gb-left', { n: gbLeft })}`
|
||||
},
|
||||
label: sr => getSrSelectLabel(sr),
|
||||
value: 'id',
|
||||
properties: sr => ({ icon: objectIcon('sr', getPbdsConnectionStatus(pbdsBySr.value.get(sr.id) ?? [])) }),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-co
|
||||
import { type FrontXoVdi, useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import { useXoVmVbdsUtils } from '@/modules/vm/composables/xo-vm-vbd-utils.composable.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { ONE_GB } from '@/shared/constants.ts'
|
||||
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import { objectIcon } from '@core/icons'
|
||||
import { type FormValidationConfig, mergeValidationConfigs, required } from '@core/packages/form-validation'
|
||||
@@ -49,7 +48,7 @@ export function useVdiFormBase<T extends BaseVdiFormData & Record<string, unknow
|
||||
const { useGetVdisByIds } = useXoVdiCollection()
|
||||
const { useGetVbdsByIds } = useXoVbdCollection()
|
||||
|
||||
const { getSrLocation } = useXoSrUtils()
|
||||
const { getSrSelectLabel } = useXoSrUtils()
|
||||
|
||||
const { notCdDriveVbds } = useXoVmVbdsUtils(vm)
|
||||
|
||||
@@ -105,10 +104,7 @@ export function useVdiFormBase<T extends BaseVdiFormData & Record<string, unknow
|
||||
searchable: true,
|
||||
required: true,
|
||||
option: {
|
||||
label: sr => {
|
||||
const gbLeft = Math.floor((sr.size - sr.physical_usage) / ONE_GB)
|
||||
return `${sr.name_label} (${getSrLocation(sr)}) - ${t('n-gb-left', { n: gbLeft })}`
|
||||
},
|
||||
label: sr => getSrSelectLabel(sr),
|
||||
value: 'id',
|
||||
properties: sr => ({ icon: objectIcon('sr', getPbdsConnectionStatus(pbdsBySr.value.get(sr.id) ?? [])) }),
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<VmPowerStateActions :vm />
|
||||
</template>
|
||||
</MenuItem>
|
||||
<VmMigrateButton :vm />
|
||||
<VmSnapshotButton :vm />
|
||||
<VmDuplicateButton :vm />
|
||||
<VmExportButton :vm />
|
||||
@@ -16,6 +17,7 @@
|
||||
import VmDeleteButton from '@/modules/vm/components/actions/delete/VmDeleteButton.vue'
|
||||
import VmDuplicateButton from '@/modules/vm/components/actions/duplicate/VmDuplicateButton.vue'
|
||||
import VmExportButton from '@/modules/vm/components/actions/export/VmExportButton.vue'
|
||||
import VmMigrateButton from '@/modules/vm/components/actions/migrate/VmMigrateButton.vue'
|
||||
import VmSnapshotButton from '@/modules/vm/components/actions/snapshot/VmSnapshotButton.vue'
|
||||
import VmPowerStateActions from '@/modules/vm/components/actions/VmPowerStateActions.vue'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import VmMigrateButton from '@/modules/vm/components/actions/migrate/VmMigrateButton.vue'
|
||||
import type { useVmMigrate } from '@/modules/vm/composables/use-vm-migrate.composable.ts'
|
||||
import { createVm } from '@/test/create-vm.ts'
|
||||
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
|
||||
import { t } from '@/test/i18n.ts'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { migrateVm } = vi.hoisted(() => ({
|
||||
migrateVm: vi.fn(),
|
||||
}))
|
||||
|
||||
const canMigrateVm = ref(true)
|
||||
const isMigratingVm = ref(false)
|
||||
const isMigrationBlocked = ref(false)
|
||||
const migrateVmErrorMessage = ref<string | undefined>(undefined)
|
||||
|
||||
vi.mock(import('@/modules/vm/composables/use-vm-migrate.composable.ts'), () => ({
|
||||
useVmMigrate: (() => ({
|
||||
migrateVm,
|
||||
canMigrateVm,
|
||||
isMigratingVm,
|
||||
isMigrationBlocked,
|
||||
migrateVmErrorMessage,
|
||||
})) as unknown as typeof useVmMigrate,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
migrateVm.mockReset()
|
||||
|
||||
canMigrateVm.value = true
|
||||
isMigratingVm.value = false
|
||||
isMigrationBlocked.value = false
|
||||
migrateVmErrorMessage.value = undefined
|
||||
})
|
||||
|
||||
function mountButton() {
|
||||
return mount(VmMigrateButton, { props: { vm: createVm() }, global: createGlobalTestConfig() })
|
||||
}
|
||||
|
||||
function findTrigger(wrapper: ReturnType<typeof mountButton>) {
|
||||
return wrapper.get('.menu-trigger')
|
||||
}
|
||||
|
||||
it('opens the migration when the VM can be migrated', async () => {
|
||||
const wrapper = mountButton()
|
||||
|
||||
await findTrigger(wrapper).trigger('click')
|
||||
|
||||
expect(findTrigger(wrapper).classes('disabled')).toBe(false)
|
||||
expect(wrapper.find('.em-dash-prefix').exists()).toBe(false)
|
||||
expect(migrateVm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('is disabled with the reason when the VM cannot be migrated', async () => {
|
||||
canMigrateVm.value = false
|
||||
migrateVmErrorMessage.value = t('job:vm-migrate:paused')
|
||||
|
||||
const wrapper = mountButton()
|
||||
|
||||
await findTrigger(wrapper).trigger('click')
|
||||
|
||||
expect(findTrigger(wrapper).classes('disabled')).toBe(true)
|
||||
expect(wrapper.get('.em-dash-prefix').text()).toBe(t('job:vm-migrate:paused'))
|
||||
expect(migrateVm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays enabled when the migration is blocked, so the blocked modal can open', async () => {
|
||||
canMigrateVm.value = false
|
||||
isMigrationBlocked.value = true
|
||||
migrateVmErrorMessage.value = t('job:vm-migrate:blocked-operation')
|
||||
|
||||
const wrapper = mountButton()
|
||||
|
||||
await findTrigger(wrapper).trigger('click')
|
||||
|
||||
expect(findTrigger(wrapper).classes('disabled')).toBe(false)
|
||||
expect(wrapper.find('.em-dash-prefix').exists()).toBe(false)
|
||||
expect(migrateVm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('is busy, not disabled, while the VM is being migrated', async () => {
|
||||
canMigrateVm.value = false
|
||||
isMigratingVm.value = true
|
||||
migrateVmErrorMessage.value = t('job:vm-migrate:in-progress')
|
||||
|
||||
const wrapper = mountButton()
|
||||
|
||||
await findTrigger(wrapper).trigger('click')
|
||||
|
||||
expect(findTrigger(wrapper).classes('disabled')).toBe(false)
|
||||
expect(findTrigger(wrapper).classes('active')).toBe(true)
|
||||
expect(wrapper.find('.em-dash-prefix').exists()).toBe(false)
|
||||
expect(migrateVm).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<MenuItem accent="neutral" icon="action:migrate" :disabled="isDisabled" :busy="isMigratingVm" @click="migrateVm()">
|
||||
{{ t('action:migrate') }}
|
||||
<i v-if="isDisabled" class="em-dash-prefix">{{ migrateVmErrorMessage }}</i>
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useVmMigrate } from '@/modules/vm/composables/use-vm-migrate.composable.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import MenuItem from '@core/components/menu/MenuItem.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { vm } = defineProps<{
|
||||
vm: FrontXoVm
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { migrateVm, canMigrateVm, isMigratingVm, isMigrationBlocked, migrateVmErrorMessage } = useVmMigrate(() => vm)
|
||||
|
||||
const isDisabled = computed(() => !canMigrateVm.value && !isMigratingVm.value && !isMigrationBlocked.value)
|
||||
</script>
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import type {
|
||||
FrontXoNetwork,
|
||||
useXoNetworkCollection,
|
||||
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
import type { FrontXoPbd, useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
|
||||
import type { FrontXoPif, useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
import type { FrontXoPool, useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
|
||||
import type {
|
||||
FrontXoSr,
|
||||
useXoSrCollection,
|
||||
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import type { FrontXoVbd, useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
|
||||
import type { FrontXoVdi, useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import type { FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
|
||||
import VmMigrateDrawer from '@/modules/vm/components/drawer/VmMigrateDrawer.vue'
|
||||
import VmMigrateVdisTable from '@/modules/vm/components/form/migrate/VmMigrateVdisTable.vue'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { createHost } from '@/test/create-host.ts'
|
||||
import { createNetwork } from '@/test/create-network.ts'
|
||||
import { createPbd } from '@/test/create-pbd.ts'
|
||||
import { createPif } from '@/test/create-pif.ts'
|
||||
import { createPool } from '@/test/create-pool.ts'
|
||||
import { createSr } from '@/test/create-sr.ts'
|
||||
import { createVbd } from '@/test/create-vbd.ts'
|
||||
import { createVdi } from '@/test/create-vdi.ts'
|
||||
import { createVm } from '@/test/create-vm.ts'
|
||||
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
|
||||
import { groupBy } from '@/test/group-by.ts'
|
||||
import { t } from '@/test/i18n.ts'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { computed, type MaybeRefOrGetter, ref, toValue } from 'vue'
|
||||
|
||||
const {
|
||||
useXoPoolCollectionMock,
|
||||
useXoHostCollectionMock,
|
||||
useXoNetworkCollectionMock,
|
||||
useXoPifCollectionMock,
|
||||
useXoSrCollectionMock,
|
||||
useXoPbdCollectionMock,
|
||||
useXoVbdCollectionMock,
|
||||
useXoVdiCollectionMock,
|
||||
useXoVifCollectionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useXoPoolCollectionMock: vi.fn(),
|
||||
useXoHostCollectionMock: vi.fn(),
|
||||
useXoNetworkCollectionMock: vi.fn(),
|
||||
useXoPifCollectionMock: vi.fn(),
|
||||
useXoSrCollectionMock: vi.fn(),
|
||||
useXoPbdCollectionMock: vi.fn(),
|
||||
useXoVbdCollectionMock: vi.fn(),
|
||||
useXoVdiCollectionMock: vi.fn(),
|
||||
useXoVifCollectionMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'), () => ({
|
||||
useXoPoolCollection: useXoPoolCollectionMock as unknown as typeof useXoPoolCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
|
||||
useXoHostCollection: useXoHostCollectionMock as unknown as typeof useXoHostCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/network/remote-resources/use-xo-network-collection.ts'), () => ({
|
||||
useXoNetworkCollection: useXoNetworkCollectionMock as unknown as typeof useXoNetworkCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pif/remote-resources/use-xo-pif-collection.ts'), () => ({
|
||||
useXoPifCollection: useXoPifCollectionMock as unknown as typeof useXoPifCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
|
||||
useXoSrCollection: useXoSrCollectionMock as unknown as typeof useXoSrCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'), () => ({
|
||||
useXoPbdCollection: useXoPbdCollectionMock as unknown as typeof useXoPbdCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'), () => ({
|
||||
useXoVbdCollection: useXoVbdCollectionMock as unknown as typeof useXoVbdCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'), () => ({
|
||||
useXoVdiCollection: useXoVdiCollectionMock as unknown as typeof useXoVdiCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vif/remote-resources/use-xo-vif-collection.ts'), () => ({
|
||||
useXoVifCollection: useXoVifCollectionMock as unknown as typeof useXoVifCollection,
|
||||
}))
|
||||
|
||||
const POOL_ID = 'pool-1' as FrontXoPool['id']
|
||||
const SOURCE_HOST_ID = 'host-src' as FrontXoHost['id']
|
||||
const TARGET_HOST_ID = 'host-dst' as FrontXoHost['id']
|
||||
const NETWORK_ID = 'net-mgmt' as FrontXoNetwork['id']
|
||||
const PIF_ID = 'pif-1' as FrontXoPif['id']
|
||||
const SR_ID = 'sr-1' as FrontXoSr['id']
|
||||
const VDI_ID = 'vdi-1' as FrontXoVdi['id']
|
||||
const VBD_ID = 'vbd-1' as FrontXoVbd['id']
|
||||
|
||||
const pools = [createPool({ id: POOL_ID, name_label: 'Pool', otherConfig: {} })]
|
||||
|
||||
const hosts = [
|
||||
createHost({ id: SOURCE_HOST_ID, name_label: 'Source host', $pool: POOL_ID }),
|
||||
createHost({ id: TARGET_HOST_ID, name_label: 'Target host', $pool: POOL_ID }),
|
||||
]
|
||||
|
||||
const networks = [createNetwork({ id: NETWORK_ID, name_label: 'Management', $pool: POOL_ID, PIFs: [PIF_ID] })]
|
||||
|
||||
const pifs = [
|
||||
createPif({
|
||||
id: PIF_ID,
|
||||
$host: TARGET_HOST_ID,
|
||||
$pool: POOL_ID,
|
||||
$network: NETWORK_ID,
|
||||
ip: '10.0.0.2',
|
||||
management: true,
|
||||
}),
|
||||
]
|
||||
|
||||
const srs = [createSr({ id: SR_ID, name_label: 'Shared SR', $pool: POOL_ID, shared: true })]
|
||||
|
||||
const sourceHostPbd = createPbd({ id: 'pbd-src' as FrontXoPbd['id'], SR: SR_ID, host: SOURCE_HOST_ID, $pool: POOL_ID })
|
||||
const targetHostPbd = createPbd({ id: 'pbd-dst' as FrontXoPbd['id'], SR: SR_ID, host: TARGET_HOST_ID, $pool: POOL_ID })
|
||||
|
||||
const vdis = [createVdi({ id: VDI_ID, name_label: 'Disk', $SR: SR_ID, $pool: POOL_ID })]
|
||||
|
||||
const vbds = [createVbd({ id: VBD_ID, VDI: VDI_ID })]
|
||||
|
||||
// The SR is reachable from the target host unless a test disconnects it
|
||||
const loadedPbds = ref<FrontXoPbd[]>([])
|
||||
|
||||
beforeEach(() => {
|
||||
useXoPoolCollectionMock.mockReset()
|
||||
useXoHostCollectionMock.mockReset()
|
||||
useXoNetworkCollectionMock.mockReset()
|
||||
useXoPifCollectionMock.mockReset()
|
||||
useXoSrCollectionMock.mockReset()
|
||||
useXoPbdCollectionMock.mockReset()
|
||||
useXoVbdCollectionMock.mockReset()
|
||||
useXoVdiCollectionMock.mockReset()
|
||||
useXoVifCollectionMock.mockReset()
|
||||
|
||||
loadedPbds.value = [sourceHostPbd, targetHostPbd]
|
||||
|
||||
useXoPoolCollectionMock.mockReturnValue({
|
||||
pools: computed(() => pools),
|
||||
useGetPoolById: (id: MaybeRefOrGetter<FrontXoPool['id'] | undefined>) =>
|
||||
computed(() => pools.find(pool => pool.id === toValue(id))),
|
||||
})
|
||||
|
||||
useXoHostCollectionMock.mockReturnValue({
|
||||
hostsByPool: computed(() => groupBy(hosts, host => host.$pool)),
|
||||
getHostById: (id: FrontXoHost['id'] | undefined) => hosts.find(host => host.id === id),
|
||||
})
|
||||
|
||||
useXoNetworkCollectionMock.mockReturnValue({
|
||||
networks: computed(() => networks),
|
||||
getNetworkById: (id: FrontXoNetwork['id'] | undefined) => networks.find(network => network.id === id),
|
||||
})
|
||||
|
||||
useXoPifCollectionMock.mockReturnValue({
|
||||
pifsByHost: computed(() => groupBy(pifs, pif => pif.$host)),
|
||||
getPifsByIds: (ids: FrontXoPif['id'][]) => pifs.filter(pif => ids.includes(pif.id)),
|
||||
})
|
||||
|
||||
useXoSrCollectionMock.mockReturnValue({
|
||||
srs: computed(() => srs),
|
||||
getSrById: (id: FrontXoSr['id'] | undefined) => srs.find(sr => sr.id === id),
|
||||
})
|
||||
|
||||
useXoPbdCollectionMock.mockReturnValue({
|
||||
pbdsBySr: computed(() => groupBy(loadedPbds.value, pbd => pbd.SR)),
|
||||
getPbdsByIds: (ids: FrontXoPbd['id'][]) => loadedPbds.value.filter(pbd => ids.includes(pbd.id)),
|
||||
})
|
||||
|
||||
useXoVbdCollectionMock.mockReturnValue({
|
||||
getVbdsByIds: (ids: FrontXoVbd['id'][]) => vbds.filter(vbd => ids.includes(vbd.id)),
|
||||
})
|
||||
|
||||
useXoVdiCollectionMock.mockReturnValue({
|
||||
useGetVdisByIds: (ids: MaybeRefOrGetter<FrontXoVdi['id'][]>) =>
|
||||
computed(() => vdis.filter(vdi => toValue(ids).includes(vdi.id))),
|
||||
})
|
||||
|
||||
useXoVifCollectionMock.mockReturnValue({
|
||||
useGetVifsByIds: (ids: MaybeRefOrGetter<FrontXoVif['id'][]>) => computed(() => toValue(ids).map(() => undefined)),
|
||||
})
|
||||
})
|
||||
|
||||
async function mountDrawer(vm: FrontXoVm = createVm({ $pool: POOL_ID, $container: SOURCE_HOST_ID, $VBDs: [VBD_ID] })) {
|
||||
const wrapper = mount(VmMigrateDrawer, { props: { vm }, global: createGlobalTestConfig(), attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function findButtonLabels(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('.buttons button').map(button => button.text())
|
||||
}
|
||||
|
||||
function isOnMappingStep(wrapper: VueWrapper) {
|
||||
return wrapper.findComponent(VmMigrateVdisTable).exists()
|
||||
}
|
||||
|
||||
function getField(wrapper: VueWrapper, label: string) {
|
||||
const field = wrapper.findAll('.vts-input-wrapper').find(field => field.get('.ui-label').text().includes(label))
|
||||
|
||||
if (field === undefined) {
|
||||
throw new Error(`No field labelled "${label}"`)
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
async function select(wrapper: VueWrapper, label: string, optionLabel: string) {
|
||||
await getField(wrapper, label).get('.vts-select .ui-input').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const option = wrapper.findAll('.ui-dropdown').find(option => option.text().startsWith(optionLabel))
|
||||
|
||||
if (option === undefined) {
|
||||
throw new Error(`No option "${optionLabel}" in the select labelled "${label}"`)
|
||||
}
|
||||
|
||||
await option.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function submit(wrapper: VueWrapper) {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function clickButton(wrapper: VueWrapper, label: string) {
|
||||
const button = wrapper.findAll('.buttons button').find(button => button.text() === label)
|
||||
|
||||
if (button === undefined) {
|
||||
throw new Error(`No button "${label}"`)
|
||||
}
|
||||
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function mountDrawerAtMappingStep(vm?: FrontXoVm) {
|
||||
const wrapper = await mountDrawer(vm)
|
||||
await select(wrapper, t('destination-host'), 'Target host')
|
||||
await submit(wrapper)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('general step', () => {
|
||||
it('is the first step, offering to cancel or to continue', async () => {
|
||||
const wrapper = await mountDrawer()
|
||||
|
||||
expect(isOnMappingStep(wrapper)).toBe(false)
|
||||
expect(findButtonLabels(wrapper)).toEqual([t('cancel'), t('action:continue')])
|
||||
})
|
||||
|
||||
it('cancels from the cancel button', async () => {
|
||||
const wrapper = await mountDrawer()
|
||||
|
||||
await clickButton(wrapper, t('cancel'))
|
||||
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stays on the general step when it is submitted without a destination host', async () => {
|
||||
const wrapper = await mountDrawer()
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(isOnMappingStep(wrapper)).toBe(false)
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapping step', () => {
|
||||
it('follows a valid general step, offering to go back or to migrate', async () => {
|
||||
const wrapper = await mountDrawerAtMappingStep()
|
||||
|
||||
expect(isOnMappingStep(wrapper)).toBe(true)
|
||||
expect(findButtonLabels(wrapper)).toEqual([t('action:back'), t('action:migrate')])
|
||||
})
|
||||
|
||||
it('goes back to the general step, without cancelling', async () => {
|
||||
const wrapper = await mountDrawerAtMappingStep()
|
||||
|
||||
await clickButton(wrapper, t('action:back'))
|
||||
|
||||
expect(isOnMappingStep(wrapper)).toBe(false)
|
||||
expect(wrapper.emitted('cancel')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not confirm before it is submitted', async () => {
|
||||
const wrapper = await mountDrawerAtMappingStep()
|
||||
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('confirms with the general and mapping payloads merged when it is submitted', async () => {
|
||||
const wrapper = await mountDrawerAtMappingStep()
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.emitted('confirm')).toEqual([[{ hostId: TARGET_HOST_ID }]])
|
||||
})
|
||||
|
||||
it('stays on the mapping step and lists the errors when a disk has no destination SR', async () => {
|
||||
loadedPbds.value = [sourceHostPbd]
|
||||
|
||||
const wrapper = await mountDrawerAtMappingStep()
|
||||
|
||||
expect(wrapper.find('.vts-error-list').exists()).toBe(false)
|
||||
|
||||
await submit(wrapper)
|
||||
|
||||
expect(isOnMappingStep(wrapper)).toBe(true)
|
||||
expect(wrapper.find('.vts-error-list').exists()).toBe(true)
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the errors again when going back to the general step', async () => {
|
||||
loadedPbds.value = [sourceHostPbd]
|
||||
|
||||
const wrapper = await mountDrawerAtMappingStep()
|
||||
await submit(wrapper)
|
||||
await clickButton(wrapper, t('action:back'))
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.find('.vts-error-list').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<UiDrawer class="vm-migrate-drawer" @confirm="handleConfirm()" @dismiss="emit('cancel')">
|
||||
<template #title>
|
||||
{{ t('action:migrate-n-vms', 1) }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="currentStep === 'general'" class="form">
|
||||
<UiTitle>{{ t('general-information') }}</UiTitle>
|
||||
<div class="fields">
|
||||
<VmMigrateFormSelect v-bind="poolSelectBindings" />
|
||||
<VmMigrateFormSelect v-bind="hostSelectBindings" />
|
||||
<VmMigrateFormSelect v-bind="migrationNetworkSelectBindings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="form">
|
||||
<div class="section">
|
||||
<UiTitle>{{ t('storage-repositories') }}</UiTitle>
|
||||
<UiAlert v-if="hasNoDiskToMigrate" accent="warning">
|
||||
{{ t('vm-not-running-no-disk-nothing-to-migrate') }}
|
||||
</UiAlert>
|
||||
<UiAlert v-else-if="isNothingToMigrate" accent="warning">
|
||||
{{ t('vm-not-running-intra-pool-nothing-to-migrate') }}
|
||||
</UiAlert>
|
||||
<UiAlert v-else-if="areAllSrsShared && !isOnlyStorageMigratable" accent="info">
|
||||
{{ t('all-srs-shared-storage-migration-optional') }}
|
||||
</UiAlert>
|
||||
<VmMigrateMappingToolbar
|
||||
:id="destinationSrSelectId"
|
||||
:fill-disabled="missingSrRows.length === 0"
|
||||
:fill-disabled-tooltip="t('no-empty-sr-to-fill')"
|
||||
:fill-label="t('action:fill-empty-srs')"
|
||||
:label="t('destination-sr')"
|
||||
:reset-label="t('action:reset-all-srs')"
|
||||
@fill="fillEmptySrs()"
|
||||
@reset="resetSrs()"
|
||||
/>
|
||||
<VmMigrateVdisTable
|
||||
:flagged-rows="missingSrRows"
|
||||
:rows="vdiRows"
|
||||
:show-errors="showMappingErrors"
|
||||
:srs="availableSrs"
|
||||
@select-sr="setTargetSrId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<UiTitle>{{ t('vifs') }}</UiTitle>
|
||||
<UiAlert v-if="areAllVifsFollowing" accent="info">
|
||||
{{ t('all-vifs-follow-vm') }}
|
||||
</UiAlert>
|
||||
<template v-else>
|
||||
<VmMigrateMappingToolbar
|
||||
:id="destinationNetworkSelectId"
|
||||
:fill-disabled="missingNetworkRows.length === 0"
|
||||
:fill-disabled-tooltip="t('no-empty-network-to-fill')"
|
||||
:fill-label="t('action:fill-empty-networks')"
|
||||
:label="t('destination-network')"
|
||||
:reset-label="t('action:reset-all-networks')"
|
||||
@fill="fillEmptyNetworks()"
|
||||
@reset="resetNetworks()"
|
||||
/>
|
||||
<VmMigrateVifsTable
|
||||
:flagged-rows="missingNetworkRows"
|
||||
:networks="availableNetworks"
|
||||
:rows="vifRows"
|
||||
:show-errors="showMappingErrors"
|
||||
@select-network="setTargetNetworkId"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<template v-if="currentStep === 'general'">
|
||||
<VtsOverlayCancelButton @click="emit('cancel')" />
|
||||
<VtsOverlayConfirmButton>{{ t('action:continue') }}</VtsOverlayConfirmButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VtsOverlayButton type="button" variant="secondary" @click="goBack()">{{ t('action:back') }}</VtsOverlayButton>
|
||||
<VtsOverlayConfirmButton>{{ t('action:migrate') }}</VtsOverlayConfirmButton>
|
||||
</template>
|
||||
</template>
|
||||
</UiDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import VmMigrateFormSelect from '@/modules/vm/components/form/migrate/VmMigrateFormSelect.vue'
|
||||
import VmMigrateMappingToolbar from '@/modules/vm/components/form/migrate/VmMigrateMappingToolbar.vue'
|
||||
import VmMigrateVdisTable from '@/modules/vm/components/form/migrate/VmMigrateVdisTable.vue'
|
||||
import VmMigrateVifsTable from '@/modules/vm/components/form/migrate/VmMigrateVifsTable.vue'
|
||||
import { useVmMigrateForm, type VmMigratePayload } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import { useVmMigrateMappingForm } from '@/modules/vm/form/migrate/use-vm-migrate-mapping-form.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import VtsOverlayButton from '@core/components/overlay/VtsOverlayButton.vue'
|
||||
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
|
||||
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
|
||||
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
|
||||
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { useFormSelect } from '@core/packages/form-select'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { vm } = defineProps<{
|
||||
vm: FrontXoVm
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [payload: VmMigratePayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentStep = ref<'general' | 'mapping'>('general')
|
||||
|
||||
const generalPayload = ref<VmMigratePayload | undefined>(undefined)
|
||||
|
||||
const showMappingErrors = ref(false)
|
||||
|
||||
const { formData, poolSelectBindings, hostSelectBindings, migrationNetworkSelectBindings, validateAndBuildPayload } =
|
||||
useVmMigrateForm(() => vm)
|
||||
|
||||
const {
|
||||
availableSrs,
|
||||
availableNetworks,
|
||||
vdiRows,
|
||||
vifRows,
|
||||
destinationSrId,
|
||||
destinationNetworkId,
|
||||
areAllSrsShared,
|
||||
areAllVifsFollowing,
|
||||
missingSrRows,
|
||||
missingNetworkRows,
|
||||
isOnlyStorageMigratable,
|
||||
isNothingToMigrate,
|
||||
hasNoDiskToMigrate,
|
||||
setTargetSrId,
|
||||
setTargetNetworkId,
|
||||
fillEmptySrs,
|
||||
resetSrs,
|
||||
fillEmptyNetworks,
|
||||
resetNetworks,
|
||||
buildPayload: buildMappingPayload,
|
||||
} = useVmMigrateMappingForm(
|
||||
() => vm,
|
||||
() => formData.pool,
|
||||
() => formData.host
|
||||
)
|
||||
|
||||
const { id: destinationSrSelectId } = useFormSelect(availableSrs, {
|
||||
model: destinationSrId,
|
||||
searchable: true,
|
||||
placeholder: () => t('action:select-storage'),
|
||||
option: {
|
||||
label: 'name_label',
|
||||
value: 'id',
|
||||
},
|
||||
})
|
||||
|
||||
const { id: destinationNetworkSelectId } = useFormSelect(availableNetworks, {
|
||||
model: destinationNetworkId,
|
||||
searchable: true,
|
||||
placeholder: () => t('action:select-network'),
|
||||
option: {
|
||||
label: 'name_label',
|
||||
value: 'id',
|
||||
},
|
||||
})
|
||||
|
||||
async function handleConfirm() {
|
||||
if (currentStep.value === 'general') {
|
||||
const payload = await validateAndBuildPayload()
|
||||
|
||||
if (payload === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
generalPayload.value = payload
|
||||
currentStep.value = 'mapping'
|
||||
return
|
||||
}
|
||||
|
||||
const mappingPayload = buildMappingPayload()
|
||||
|
||||
if (mappingPayload === undefined || generalPayload.value === undefined) {
|
||||
showMappingErrors.value = true
|
||||
return
|
||||
}
|
||||
|
||||
emit('confirm', { ...generalPayload.value, ...mappingPayload })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
showMappingErrors.value = false
|
||||
currentStep.value = 'general'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-migrate-drawer {
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.4rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.6rem 2.4rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<VtsInputWrapper :label :message="messages" wrap-message>
|
||||
<VtsSelect :id accent="brand">
|
||||
<template #default="{ option }">
|
||||
<VtsOption :option>
|
||||
<span class="select-option">
|
||||
<VtsIcon v-if="option.properties.icon" :name="option.properties.icon" size="medium" />
|
||||
{{ option.properties.label }}
|
||||
</span>
|
||||
</VtsOption>
|
||||
</template>
|
||||
</VtsSelect>
|
||||
</VtsInputWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import type { DropdownAccent } from '@core/components/ui/dropdown/UiDropdown.vue'
|
||||
import type { IconName } from '@core/icons'
|
||||
import type { FormSelectId } from '@core/packages/form-select'
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import VtsOption from '@core/components/select/VtsOption.vue'
|
||||
import VtsSelect from '@core/components/select/VtsSelect.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { error, info } = defineProps<{
|
||||
id: FormSelectId<{ icon?: IconName; accent?: DropdownAccent }>
|
||||
label: string
|
||||
error?: InputWrapperMessage
|
||||
info?: InputWrapperMessage
|
||||
}>()
|
||||
|
||||
const messages = computed(() => [error, info].filter(message => message !== undefined).flat())
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.select-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="vm-migrate-mapping-toolbar">
|
||||
<VmMigrateFormSelect :id :label class="select" />
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
v-tooltip="fillTooltip"
|
||||
accent="brand"
|
||||
size="medium"
|
||||
variant="primary"
|
||||
:disabled="fillDisabled"
|
||||
@click="emit('fill')"
|
||||
>
|
||||
{{ fillLabel }}
|
||||
</UiButton>
|
||||
<VtsDivider type="stretch" />
|
||||
<UiButton accent="brand" size="medium" variant="secondary" @click="emit('reset')">
|
||||
{{ resetLabel }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import VmMigrateFormSelect from '@/modules/vm/components/form/migrate/VmMigrateFormSelect.vue'
|
||||
import type { FormSelectId } from '@core/packages/form-select'
|
||||
import VtsDivider from '@core/components/divider/VtsDivider.vue'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive.ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { fillDisabled, fillDisabledTooltip } = defineProps<{
|
||||
id: FormSelectId
|
||||
label: string
|
||||
fillLabel: string
|
||||
resetLabel: string
|
||||
fillDisabled?: boolean
|
||||
fillDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
fill: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const fillTooltip = computed(() => {
|
||||
if (fillDisabled && fillDisabledTooltip !== undefined) {
|
||||
return fillDisabledTooltip
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-migrate-mapping-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1.6rem;
|
||||
|
||||
.select {
|
||||
flex: 0 1 44.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="vm-migrate-vdis-table">
|
||||
<VtsTable :state :pagination-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
<HeadCells />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VtsRow v-for="row in paginatedRows" :key="row.vdi.id" :error="showErrors && flaggedVdiIds.has(row.vdi.id)">
|
||||
<BodyCells :item="row" />
|
||||
</VtsRow>
|
||||
</tbody>
|
||||
</VtsTable>
|
||||
<VtsFlagLegend v-if="flaggedRows.length > 0">{{ t('vdis-must-be-migrated') }}</VtsFlagLegend>
|
||||
<VtsErrorList v-if="showErrors && flaggedRows.length > 0">
|
||||
<li v-for="row in flaggedRows" :key="row.vdi.id">
|
||||
{{ t('vdi-destination-sr-required', { name: row.vdi.name_label }) }}
|
||||
</li>
|
||||
</VtsErrorList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
|
||||
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
|
||||
import { getVdiIcon } from '@/modules/vdi/utils/xo-vdi.util.ts'
|
||||
import type { VmMigrateVdiRow } from '@/modules/vm/form/migrate/use-vm-migrate-mapping-form.ts'
|
||||
import VtsErrorList from '@core/components/error-list/VtsErrorList.vue'
|
||||
import VtsFlagLegend from '@core/components/table/VtsFlagLegend.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable.ts'
|
||||
import { useTableState } from '@core/composables/table-state.composable.ts'
|
||||
import { useFormSelect } from '@core/packages/form-select'
|
||||
import { useVmMigrateVdiColumns } from '@core/tables/column-sets/vm-migrate-vdi-columns.ts'
|
||||
import { formatSizeRaw } from '@core/utils/size.util.ts'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { rows, srs, flaggedRows } = defineProps<{
|
||||
rows: VmMigrateVdiRow[]
|
||||
srs: FrontXoSr[]
|
||||
flaggedRows: VmMigrateVdiRow[]
|
||||
showErrors: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSr: [vdiId: VmMigrateVdiRow['vdi']['id'], srId: FrontXoSr['id'] | undefined]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getVbdsByIds } = useXoVbdCollection()
|
||||
const { getSrSelectLabel } = useXoSrUtils()
|
||||
|
||||
const state = useTableState({
|
||||
empty: () => (rows.length === 0 ? t('no-vdi-detected') : false),
|
||||
})
|
||||
|
||||
const { pageRecords: paginatedRows, paginationBindings } = usePagination('vm-migrate-vdis', () => rows, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const flaggedVdiIds = computed(() => new Set(flaggedRows.map(row => row.vdi.id)))
|
||||
|
||||
const { HeadCells, BodyCells } = useVmMigrateVdiColumns({
|
||||
body: (row: VmMigrateVdiRow) => {
|
||||
const targetSrId = computed({
|
||||
get: () => row.targetSrId,
|
||||
set: srId => emit('selectSr', row.vdi.id, srId),
|
||||
})
|
||||
|
||||
const { id: srSelectId } = useFormSelect(() => srs, {
|
||||
model: targetSrId,
|
||||
searchable: true,
|
||||
placeholder: () => t('action:select-storage'),
|
||||
option: {
|
||||
label: sr => getSrSelectLabel(sr),
|
||||
value: 'id',
|
||||
},
|
||||
})
|
||||
|
||||
const usedSpace = formatSizeRaw(row.vdi.usage, 2)
|
||||
const sourceSrName = row.sourceSr?.name_label ?? ''
|
||||
|
||||
return {
|
||||
vdi: r => r(row.vdi.name_label, { leftIcon: { icon: getVdiIcon(getVbdsByIds(row.vdi.$VBDs)) } }),
|
||||
usedSpace: r => r(usedSpace.value, usedSpace.prefix),
|
||||
fromSr: r => r(sourceSrName),
|
||||
toSr: r => r(srSelectId),
|
||||
flag: r => r(flaggedVdiIds.value.has(row.vdi.id)),
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-migrate-vdis-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="vm-migrate-vifs-table">
|
||||
<VtsTable :state :pagination-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
<HeadCells />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VtsRow v-for="row in paginatedRows" :key="row.vif.id" :error="showErrors && flaggedVifIds.has(row.vif.id)">
|
||||
<BodyCells :item="row" />
|
||||
</VtsRow>
|
||||
</tbody>
|
||||
</VtsTable>
|
||||
<VtsFlagLegend v-if="flaggedRows.length > 0">{{ t('vifs-must-be-reassigned') }}</VtsFlagLegend>
|
||||
<VtsErrorList v-if="showErrors && flaggedRows.length > 0">
|
||||
<li v-for="row in flaggedRows" :key="row.vif.id">
|
||||
{{ t('vif-destination-network-required', { name: row.sourceNetwork?.name_label ?? row.vif.MAC }) }}
|
||||
</li>
|
||||
</VtsErrorList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
import type { VmMigrateVifRow } from '@/modules/vm/form/migrate/use-vm-migrate-mapping-form.ts'
|
||||
import VtsErrorList from '@core/components/error-list/VtsErrorList.vue'
|
||||
import VtsFlagLegend from '@core/components/table/VtsFlagLegend.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable.ts'
|
||||
import { useTableState } from '@core/composables/table-state.composable.ts'
|
||||
import { useFormSelect } from '@core/packages/form-select'
|
||||
import { useVmMigrateVifColumns } from '@core/tables/column-sets/vm-migrate-vif-columns.ts'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { rows, networks, flaggedRows } = defineProps<{
|
||||
rows: VmMigrateVifRow[]
|
||||
networks: FrontXoNetwork[]
|
||||
flaggedRows: VmMigrateVifRow[]
|
||||
showErrors: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNetwork: [vifId: VmMigrateVifRow['vif']['id'], networkId: FrontXoNetwork['id'] | undefined]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getPifsByIds } = useXoPifCollection()
|
||||
|
||||
const state = useTableState({
|
||||
empty: () => (rows.length === 0 ? t('no-vif-detected') : false),
|
||||
})
|
||||
|
||||
const { pageRecords: paginatedRows, paginationBindings } = usePagination('vm-migrate-vifs', () => rows, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const flaggedVifIds = computed(() => new Set(flaggedRows.map(row => row.vif.id)))
|
||||
|
||||
function getNetworkVlan(network: FrontXoNetwork | undefined) {
|
||||
if (network === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [firstPif] = getPifsByIds(network.PIFs)
|
||||
|
||||
if (firstPif === undefined || firstPif.vlan === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return firstPif.vlan
|
||||
}
|
||||
|
||||
const { HeadCells, BodyCells } = useVmMigrateVifColumns({
|
||||
body: (row: VmMigrateVifRow) => {
|
||||
const targetNetworkId = computed({
|
||||
get: () => row.targetNetworkId,
|
||||
set: networkId => emit('selectNetwork', row.vif.id, networkId),
|
||||
})
|
||||
|
||||
const { id: networkSelectId } = useFormSelect(() => networks, {
|
||||
model: targetNetworkId,
|
||||
searchable: true,
|
||||
placeholder: () => t('action:select-network'),
|
||||
option: {
|
||||
label: 'name_label',
|
||||
value: 'id',
|
||||
},
|
||||
})
|
||||
|
||||
const sourceVlan = computed(() => getNetworkVlan(row.sourceNetwork))
|
||||
|
||||
const targetVlan = computed(() => getNetworkVlan(networks.find(network => network.id === row.targetNetworkId)))
|
||||
|
||||
return {
|
||||
fromNetwork: r => r(row.sourceNetwork?.name_label ?? ''),
|
||||
fromDevice: r => r(t('vif-device', { device: row.vif.device })),
|
||||
fromVlan: r => r(sourceVlan.value),
|
||||
toNetwork: r => r(networkSelectId),
|
||||
toVlan: r => r(targetVlan.value),
|
||||
flag: r => r(flaggedVifIds.value.has(row.vif.id)),
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-migrate-vifs-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
|
||||
import type { VmMigratePayload } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import { useXoVmMigrateJob } from '@/modules/vm/jobs/xo-vm-migrate.job.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { isVmMigrationBlocked } from '@/modules/vm/utils/xo-vm.util.ts'
|
||||
import { useBlockedModal } from '@core/composables/modals/use-blocked-modal.ts'
|
||||
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
|
||||
import { toComputed } from '@core/utils/to-computed.util.ts'
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter, ref } from 'vue'
|
||||
|
||||
export function useVmMigrate(rawVm: MaybeRefOrGetter<FrontXoVm>) {
|
||||
const vm = toComputed(rawVm)
|
||||
|
||||
const payload = ref<VmMigratePayload | undefined>(undefined)
|
||||
|
||||
const {
|
||||
run,
|
||||
canRun: canMigrateVm,
|
||||
isRunning: isMigratingVm,
|
||||
errorMessage: migrateVmErrorMessage,
|
||||
} = useXoVmMigrateJob(() => vm.value, payload)
|
||||
|
||||
const { xo5VmAdvancedHref } = useXoVmUtils(vm)
|
||||
|
||||
const isMigrationBlocked = computed(() => isVmMigrationBlocked(vm.value))
|
||||
|
||||
const { open: openBlockedModal } = useBlockedModal()
|
||||
|
||||
const { open: openMigrateDrawer } = useOverlay({
|
||||
component: () => import('@/modules/vm/components/drawer/VmMigrateDrawer.vue'),
|
||||
events: {
|
||||
onConfirm: (migratePayload: VmMigratePayload) => {
|
||||
payload.value = migratePayload
|
||||
|
||||
run({ detached: true })
|
||||
},
|
||||
onCancel: true,
|
||||
},
|
||||
})
|
||||
|
||||
const props = reactiveComputed(() => ({
|
||||
vm: vm.value,
|
||||
}))
|
||||
|
||||
function migrateVm() {
|
||||
if (isMigrationBlocked.value) {
|
||||
return openBlockedModal({ props: { blockedOperation: 'migrate_send', href: xo5VmAdvancedHref.value } })
|
||||
}
|
||||
|
||||
return openMigrateDrawer({ props })
|
||||
}
|
||||
|
||||
return {
|
||||
migrateVm,
|
||||
canMigrateVm,
|
||||
isMigratingVm,
|
||||
isMigrationBlocked,
|
||||
migrateVmErrorMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useVmMigrate } from '@/modules/vm/composables/use-vm-migrate.composable.ts'
|
||||
import type { VmMigratePayload } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import type { useXoVmMigrateJob } from '@/modules/vm/jobs/xo-vm-migrate.job.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
|
||||
import { createVm } from '@/test/create-vm.ts'
|
||||
import { mountComposable } from '@/test/mount-composable.ts'
|
||||
import type { useBlockedModal } from '@core/composables/modals/use-blocked-modal.ts'
|
||||
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
type OverlayEvents = Record<string, ((...args: unknown[]) => unknown) | true>
|
||||
|
||||
type OverlayStub = { events: OverlayEvents; open: ReturnType<typeof vi.fn> }
|
||||
|
||||
const { useXoVmMigrateJobMock, runMock, useOverlayMock, openBlockedModalMock, buildXo5Route } = vi.hoisted(() => ({
|
||||
useXoVmMigrateJobMock: vi.fn(),
|
||||
runMock: vi.fn(),
|
||||
useOverlayMock: vi.fn(),
|
||||
openBlockedModalMock: vi.fn(),
|
||||
buildXo5Route: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vm/jobs/xo-vm-migrate.job.ts'), () => ({
|
||||
useXoVmMigrateJob: useXoVmMigrateJobMock as unknown as typeof useXoVmMigrateJob,
|
||||
}))
|
||||
|
||||
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
|
||||
useOverlay: useOverlayMock as unknown as typeof useOverlay,
|
||||
}))
|
||||
|
||||
vi.mock(import('@core/composables/modals/use-blocked-modal.ts'), () => ({
|
||||
useBlockedModal: (() => ({ open: openBlockedModalMock })) as unknown as typeof useBlockedModal,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
|
||||
useXoRoutes: (() => ({ buildXo5Route })) as unknown as typeof useXoRoutes,
|
||||
}))
|
||||
|
||||
const payload: VmMigratePayload = { hostId: 'host-target' as VmMigratePayload['hostId'] }
|
||||
|
||||
// Each `run()` records the payload the job was given at that moment
|
||||
let runPayloads: (VmMigratePayload | undefined)[]
|
||||
|
||||
// The overlay the composable defines itself: the migrate drawer (`onConfirm`)
|
||||
let overlays: OverlayStub[]
|
||||
|
||||
function getOverlay(eventName: string): OverlayStub {
|
||||
const overlay = overlays.find(overlay => eventName in overlay.events)
|
||||
|
||||
if (overlay === undefined) {
|
||||
throw new Error(`No overlay handles "${eventName}"`)
|
||||
}
|
||||
|
||||
return overlay
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useXoVmMigrateJobMock.mockReset()
|
||||
runMock.mockReset()
|
||||
useOverlayMock.mockReset()
|
||||
openBlockedModalMock.mockReset()
|
||||
buildXo5Route.mockReset()
|
||||
|
||||
runPayloads = []
|
||||
overlays = []
|
||||
|
||||
useXoVmMigrateJobMock.mockImplementation((_vm: unknown, payloadRef: Ref<VmMigratePayload | undefined>) => {
|
||||
runMock.mockImplementation(() => {
|
||||
runPayloads.push(payloadRef.value)
|
||||
})
|
||||
|
||||
return {
|
||||
run: runMock,
|
||||
canRun: computed(() => true),
|
||||
isRunning: computed(() => false),
|
||||
errorMessage: computed(() => undefined),
|
||||
}
|
||||
})
|
||||
|
||||
useOverlayMock.mockImplementation(({ events }: { events: OverlayEvents }) => {
|
||||
const overlay: OverlayStub = { events, open: vi.fn() }
|
||||
|
||||
overlays.push(overlay)
|
||||
|
||||
return { open: overlay.open, abort: vi.fn() }
|
||||
})
|
||||
|
||||
buildXo5Route.mockImplementation((path: string) => `xo5:${path}`)
|
||||
})
|
||||
|
||||
function mountVmMigrate(vm: FrontXoVm = createVm({ name_label: 'web-server' })) {
|
||||
const { wrapper } = mountComposable(() => useVmMigrate(vm))
|
||||
|
||||
return { vm, result: wrapper.vm }
|
||||
}
|
||||
|
||||
async function confirmDrawer(migratePayload: VmMigratePayload = payload) {
|
||||
const { events } = getOverlay('onConfirm')
|
||||
|
||||
if (events.onConfirm === true) {
|
||||
throw new Error('The drawer confirmation is not handled')
|
||||
}
|
||||
|
||||
events.onConfirm(migratePayload)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('migrateVm', () => {
|
||||
it('opens the migrate drawer with the VM', () => {
|
||||
const { vm, result } = mountVmMigrate()
|
||||
|
||||
result.migrateVm()
|
||||
|
||||
expect(getOverlay('onConfirm').open).toHaveBeenCalledWith({ props: { vm } })
|
||||
expect(openBlockedModalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the blocked modal instead of the drawer when the VM is protected against migration', () => {
|
||||
const { vm, result } = mountVmMigrate(
|
||||
createVm({ blockedOperations: { migrate_send: 'true' } as FrontXoVm['blockedOperations'] })
|
||||
)
|
||||
|
||||
result.migrateVm()
|
||||
|
||||
expect(result.isMigrationBlocked).toBe(true)
|
||||
expect(openBlockedModalMock).toHaveBeenCalledWith({
|
||||
props: { blockedOperation: 'migrate_send', href: `xo5:/vms/${vm.id}/advanced` },
|
||||
})
|
||||
expect(getOverlay('onConfirm').open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the migration detached with the payload confirmed in the drawer', async () => {
|
||||
const { result } = mountVmMigrate()
|
||||
|
||||
result.migrateVm()
|
||||
await confirmDrawer()
|
||||
|
||||
expect(runPayloads).toEqual([payload])
|
||||
expect(runMock).toHaveBeenCalledWith({ detached: true })
|
||||
})
|
||||
})
|
||||
@@ -121,6 +121,8 @@ export function useXoVmUtils(rawVm: MaybeRefOrGetter<FrontXoVm>) {
|
||||
copy: t('operation:duplicate'),
|
||||
export: t('operation:export'),
|
||||
import: t('operation:import'),
|
||||
migrate_send: t('operation:migrate'),
|
||||
pool_migrate: t('operation:migrate'),
|
||||
unknown: '',
|
||||
},
|
||||
'unknown'
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
|
||||
import {
|
||||
type FrontXoNetwork,
|
||||
useXoNetworkCollection,
|
||||
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
import { getNetworkStatus } from '@/modules/network/utils/xo-network.util.ts'
|
||||
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
import { type FrontXoPool, useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
|
||||
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import { objectIcon } from '@core/icons'
|
||||
import { required, requiredIf } from '@core/packages/form-validation'
|
||||
import { useValidatedForm } from '@core/packages/validated-form'
|
||||
import { toComputed } from '@core/utils/to-computed.util.ts'
|
||||
import { watchImmediate } from '@vueuse/shared'
|
||||
import { computed, type MaybeRefOrGetter, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export type VmMigrateFormData = {
|
||||
pool: FrontXoPool['id'] | undefined
|
||||
host: FrontXoHost['id'] | undefined
|
||||
migrationNetwork: FrontXoNetwork['id'] | undefined
|
||||
}
|
||||
|
||||
export type VmMigratePayload = {
|
||||
hostId: FrontXoHost['id']
|
||||
migrationNetworkId?: FrontXoNetwork['id']
|
||||
srIdByVdiId?: Record<FrontXoVdi['id'], FrontXoSr['id']>
|
||||
networkIdByVifId?: Record<FrontXoVif['id'], FrontXoNetwork['id']>
|
||||
}
|
||||
|
||||
export function useVmMigrateForm(rawVm: MaybeRefOrGetter<FrontXoVm>) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const vm = toComputed(rawVm)
|
||||
|
||||
const { pools, useGetPoolById } = useXoPoolCollection()
|
||||
const { hostsByPool } = useXoHostCollection()
|
||||
const { networks, getNetworkById } = useXoNetworkCollection()
|
||||
const { pifsByHost, getPifsByIds } = useXoPifCollection()
|
||||
|
||||
const formData = reactive<VmMigrateFormData>({
|
||||
pool: vm.value.$pool,
|
||||
host: undefined,
|
||||
migrationNetwork: undefined,
|
||||
})
|
||||
|
||||
const isIntraPool = computed(() => formData.pool === vm.value.$pool)
|
||||
|
||||
const isMigrationNetworkRequired = computed(() => !isIntraPool.value)
|
||||
|
||||
const { useFormSelect, useSelect, validate } = useValidatedForm(formData, {
|
||||
errors: {
|
||||
onSubmit: () => ({
|
||||
pool: { required },
|
||||
host: { required },
|
||||
migrationNetwork: { requiredIf: requiredIf(isMigrationNetworkRequired) },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const selectedPool = useGetPoolById(() => formData.pool)
|
||||
|
||||
const availableHosts = computed(() => {
|
||||
if (formData.pool === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
const poolHosts = hostsByPool.value.get(formData.pool) ?? []
|
||||
|
||||
return poolHosts.filter(host => host.id !== vm.value.$container)
|
||||
})
|
||||
|
||||
const selectedHostPifs = computed(() => {
|
||||
if (formData.host === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pifsByHost.value.get(formData.host) ?? []
|
||||
})
|
||||
|
||||
const selectedHostPifsWithIp = computed(() => selectedHostPifs.value.filter(pif => pif.ip !== ''))
|
||||
|
||||
const availableMigrationNetworks = computed(() => {
|
||||
if (formData.host === undefined) {
|
||||
return networks.value.filter(network => network.$pool === formData.pool && network.PIFs.length > 0)
|
||||
}
|
||||
|
||||
const reachableNetworkIds = new Set(selectedHostPifsWithIp.value.map(pif => pif.$network))
|
||||
|
||||
return networks.value.filter(network => reachableNetworkIds.has(network.id))
|
||||
})
|
||||
|
||||
const poolMigrationNetwork = computed(() => {
|
||||
const poolMigrationNetworkId = selectedPool.value?.otherConfig['xo:migrationNetwork']
|
||||
|
||||
if (poolMigrationNetworkId === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return getNetworkById(poolMigrationNetworkId as FrontXoNetwork['id'])
|
||||
})
|
||||
|
||||
const defaultMigrationNetwork = computed(() => {
|
||||
if (formData.host === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const migrationPif = selectedHostPifsWithIp.value.find(pif => pif.$network === poolMigrationNetwork.value?.id)
|
||||
|
||||
if (migrationPif !== undefined) {
|
||||
return getNetworkById(migrationPif.$network)
|
||||
}
|
||||
|
||||
if (isIntraPool.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const managementPif = selectedHostPifs.value.find(pif => pif.management)
|
||||
|
||||
if (managementPif === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return getNetworkById(managementPif.$network)
|
||||
})
|
||||
|
||||
const suggestedMigrationNetwork = computed(() =>
|
||||
formData.host === undefined ? poolMigrationNetwork.value : defaultMigrationNetwork.value
|
||||
)
|
||||
|
||||
watch(
|
||||
() => formData.pool,
|
||||
() => {
|
||||
formData.host = undefined
|
||||
}
|
||||
)
|
||||
|
||||
watchImmediate([() => formData.host, () => suggestedMigrationNetwork.value?.id], () => {
|
||||
formData.migrationNetwork = suggestedMigrationNetwork.value?.id
|
||||
})
|
||||
|
||||
const migrationNetworkInfo = computed<InputWrapperMessage>(() => {
|
||||
if (poolMigrationNetwork.value === undefined) {
|
||||
return {
|
||||
content: t('no-default-network-configured'),
|
||||
accent: 'info',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: t('your-default-network-is', { name: poolMigrationNetwork.value.name_label }),
|
||||
accent: 'info',
|
||||
}
|
||||
})
|
||||
|
||||
const { id: poolSelectId } = useFormSelect('pool', pools, {
|
||||
searchable: true,
|
||||
required: true,
|
||||
placeholder: () => t('select-pool'),
|
||||
option: {
|
||||
label: 'name_label',
|
||||
value: 'id',
|
||||
},
|
||||
})
|
||||
|
||||
const { id: hostSelectId } = useFormSelect('host', availableHosts, {
|
||||
searchable: true,
|
||||
required: true,
|
||||
placeholder: () => t('select-destination-host'),
|
||||
option: {
|
||||
label: 'name_label',
|
||||
value: 'id',
|
||||
properties: host => ({ icon: getHostIcon(host) }),
|
||||
},
|
||||
})
|
||||
|
||||
const { id: migrationNetworkSelectId } = useFormSelect('migrationNetwork', availableMigrationNetworks, {
|
||||
searchable: true,
|
||||
required: isMigrationNetworkRequired,
|
||||
placeholder: () => t('action:select-network'),
|
||||
option: {
|
||||
label: 'name_label',
|
||||
value: 'id',
|
||||
properties: network => ({ icon: objectIcon('network', getNetworkStatus(getPifsByIds(network.PIFs))) }),
|
||||
},
|
||||
})
|
||||
|
||||
async function validateAndBuildPayload(): Promise<VmMigratePayload | undefined> {
|
||||
const isValid = await validate()
|
||||
|
||||
if (!isValid || formData.host === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
hostId: formData.host,
|
||||
...(formData.migrationNetwork !== undefined && { migrationNetworkId: formData.migrationNetwork }),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formData,
|
||||
poolSelectBindings: useSelect(poolSelectId, () => ({ label: t('pool') })),
|
||||
hostSelectBindings: useSelect(hostSelectId, () => ({ label: t('destination-host') })),
|
||||
migrationNetworkSelectBindings: useSelect(migrationNetworkSelectId, () => ({
|
||||
label: t('migration-network'),
|
||||
info: migrationNetworkInfo.value,
|
||||
})),
|
||||
validateAndBuildPayload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import type {
|
||||
FrontXoNetwork,
|
||||
useXoNetworkCollection,
|
||||
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
import type { FrontXoPif, useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
import type { FrontXoPool, useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
|
||||
import { useVmMigrateForm } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { createHost } from '@/test/create-host.ts'
|
||||
import { createNetwork } from '@/test/create-network.ts'
|
||||
import { createPif } from '@/test/create-pif.ts'
|
||||
import { createPool } from '@/test/create-pool.ts'
|
||||
import { createVm } from '@/test/create-vm.ts'
|
||||
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
|
||||
import { groupBy } from '@/test/group-by.ts'
|
||||
import type { FormSelect, FormSelectId } from '@core/packages/form-select'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { computed, defineComponent, h, inject, nextTick, ref, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { useXoPoolCollectionMock, useXoHostCollectionMock, useXoNetworkCollectionMock, useXoPifCollectionMock } =
|
||||
vi.hoisted(() => ({
|
||||
useXoPoolCollectionMock: vi.fn(),
|
||||
useXoHostCollectionMock: vi.fn(),
|
||||
useXoNetworkCollectionMock: vi.fn(),
|
||||
useXoPifCollectionMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'), () => ({
|
||||
useXoPoolCollection: useXoPoolCollectionMock as unknown as typeof useXoPoolCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
|
||||
useXoHostCollection: useXoHostCollectionMock as unknown as typeof useXoHostCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/network/remote-resources/use-xo-network-collection.ts'), () => ({
|
||||
useXoNetworkCollection: useXoNetworkCollectionMock as unknown as typeof useXoNetworkCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pif/remote-resources/use-xo-pif-collection.ts'), () => ({
|
||||
useXoPifCollection: useXoPifCollectionMock as unknown as typeof useXoPifCollection,
|
||||
}))
|
||||
|
||||
const POOL_ID = 'pool-1' as FrontXoPool['id']
|
||||
const OTHER_POOL_ID = 'pool-2' as FrontXoPool['id']
|
||||
const SOURCE_HOST_ID = 'host-src' as FrontXoHost['id']
|
||||
const TARGET_HOST_ID = 'host-dst' as FrontXoHost['id']
|
||||
const SECOND_TARGET_HOST_ID = 'host-dst-2' as FrontXoHost['id']
|
||||
const OTHER_POOL_HOST_ID = 'host-other' as FrontXoHost['id']
|
||||
const MANAGEMENT_NETWORK_ID = 'net-mgmt' as FrontXoNetwork['id']
|
||||
const MIGRATION_NETWORK_ID = 'net-migr' as FrontXoNetwork['id']
|
||||
const NO_IP_NETWORK_ID = 'net-noip' as FrontXoNetwork['id']
|
||||
const OTHER_POOL_NETWORK_ID = 'net-other' as FrontXoNetwork['id']
|
||||
|
||||
const pools = [
|
||||
createPool({ id: POOL_ID, name_label: 'nova', otherConfig: { 'xo:migrationNetwork': MIGRATION_NETWORK_ID } }),
|
||||
createPool({ id: OTHER_POOL_ID, name_label: 'other', otherConfig: {} }),
|
||||
]
|
||||
|
||||
const hosts = [
|
||||
createHost({ id: SOURCE_HOST_ID, name_label: 'source', $pool: POOL_ID }),
|
||||
createHost({ id: TARGET_HOST_ID, name_label: 'target', $pool: POOL_ID }),
|
||||
createHost({ id: SECOND_TARGET_HOST_ID, name_label: 'second target', $pool: POOL_ID }),
|
||||
createHost({ id: OTHER_POOL_HOST_ID, name_label: 'other', $pool: OTHER_POOL_ID }),
|
||||
]
|
||||
|
||||
const networks = [
|
||||
createNetwork({
|
||||
id: MANAGEMENT_NETWORK_ID,
|
||||
name_label: 'Management',
|
||||
$pool: POOL_ID,
|
||||
PIFs: ['pif-1' as FrontXoPif['id']],
|
||||
}),
|
||||
createNetwork({
|
||||
id: MIGRATION_NETWORK_ID,
|
||||
name_label: 'BackupNet',
|
||||
$pool: POOL_ID,
|
||||
PIFs: ['pif-2' as FrontXoPif['id'], 'pif-5' as FrontXoPif['id']],
|
||||
}),
|
||||
createNetwork({ id: NO_IP_NETWORK_ID, name_label: 'No IP', $pool: POOL_ID, PIFs: ['pif-3' as FrontXoPif['id']] }),
|
||||
createNetwork({ id: 'net-nopif' as FrontXoNetwork['id'], name_label: 'No PIF', $pool: POOL_ID }),
|
||||
createNetwork({
|
||||
id: OTHER_POOL_NETWORK_ID,
|
||||
name_label: 'Other management',
|
||||
$pool: OTHER_POOL_ID,
|
||||
PIFs: ['pif-4' as FrontXoPif['id']],
|
||||
}),
|
||||
]
|
||||
|
||||
const pifs = [
|
||||
createPif({
|
||||
id: 'pif-1' as FrontXoPif['id'],
|
||||
$host: TARGET_HOST_ID,
|
||||
$pool: POOL_ID,
|
||||
$network: MANAGEMENT_NETWORK_ID,
|
||||
ip: '10.1.0.14',
|
||||
management: true,
|
||||
}),
|
||||
createPif({
|
||||
id: 'pif-2' as FrontXoPif['id'],
|
||||
$host: TARGET_HOST_ID,
|
||||
$pool: POOL_ID,
|
||||
$network: MIGRATION_NETWORK_ID,
|
||||
ip: '172.16.210.14',
|
||||
}),
|
||||
createPif({ id: 'pif-3' as FrontXoPif['id'], $host: TARGET_HOST_ID, $pool: POOL_ID, $network: NO_IP_NETWORK_ID }),
|
||||
createPif({
|
||||
id: 'pif-4' as FrontXoPif['id'],
|
||||
$host: OTHER_POOL_HOST_ID,
|
||||
$pool: OTHER_POOL_ID,
|
||||
$network: OTHER_POOL_NETWORK_ID,
|
||||
ip: '10.1.9.150',
|
||||
management: true,
|
||||
}),
|
||||
createPif({
|
||||
id: 'pif-5' as FrontXoPif['id'],
|
||||
$host: SECOND_TARGET_HOST_ID,
|
||||
$pool: POOL_ID,
|
||||
$network: MIGRATION_NETWORK_ID,
|
||||
ip: '172.16.210.15',
|
||||
}),
|
||||
]
|
||||
|
||||
const loadedNetworks = ref<FrontXoNetwork[]>([])
|
||||
|
||||
beforeEach(() => {
|
||||
useXoPoolCollectionMock.mockReset()
|
||||
useXoHostCollectionMock.mockReset()
|
||||
useXoNetworkCollectionMock.mockReset()
|
||||
useXoPifCollectionMock.mockReset()
|
||||
|
||||
useXoPoolCollectionMock.mockReturnValue({
|
||||
pools: computed(() => pools),
|
||||
useGetPoolById: (id: MaybeRefOrGetter<FrontXoPool['id'] | undefined>) =>
|
||||
computed(() => pools.find(pool => pool.id === toValue(id))),
|
||||
})
|
||||
|
||||
useXoHostCollectionMock.mockReturnValue({
|
||||
hostsByPool: computed(() => groupBy(hosts, host => host.$pool)),
|
||||
})
|
||||
|
||||
loadedNetworks.value = networks
|
||||
|
||||
useXoNetworkCollectionMock.mockReturnValue({
|
||||
networks: computed(() => loadedNetworks.value),
|
||||
getNetworkById: (id: FrontXoNetwork['id'] | undefined) => loadedNetworks.value.find(network => network.id === id),
|
||||
})
|
||||
|
||||
useXoPifCollectionMock.mockReturnValue({
|
||||
pifsByHost: computed(() => groupBy(pifs, pif => pif.$host)),
|
||||
getPifsByIds: (ids: FrontXoPif['id'][]) => pifs.filter(pif => ids.includes(pif.id)),
|
||||
})
|
||||
})
|
||||
|
||||
type SelectProbe = { select: FormSelect }
|
||||
|
||||
const SelectProbe = defineComponent({
|
||||
props: { selectId: { type: Symbol, required: true } },
|
||||
setup(props) {
|
||||
return { select: inject(props.selectId as FormSelectId) }
|
||||
},
|
||||
render: () => null,
|
||||
})
|
||||
|
||||
function mountVmMigrateForm(vm: FrontXoVm = createVm({ $pool: POOL_ID, $container: SOURCE_HOST_ID })) {
|
||||
let form: ReturnType<typeof useVmMigrateForm> | undefined
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
form = useVmMigrateForm(vm)
|
||||
|
||||
const { hostSelectBindings, migrationNetworkSelectBindings } = form
|
||||
|
||||
return () => [
|
||||
h(SelectProbe, { selectId: hostSelectBindings.value.id }),
|
||||
h(SelectProbe, { selectId: migrationNetworkSelectBindings.value.id }),
|
||||
]
|
||||
},
|
||||
}),
|
||||
{ global: createGlobalTestConfig() }
|
||||
)
|
||||
|
||||
const [hostProbe, networkProbe] = wrapper.findAllComponents(SelectProbe)
|
||||
|
||||
if (form === undefined || hostProbe === undefined || networkProbe === undefined) {
|
||||
throw new Error('VM migrate form did not mount')
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
hostSelect: (hostProbe.vm as unknown as SelectProbe).select,
|
||||
networkSelect: (networkProbe.vm as unknown as SelectProbe).select,
|
||||
}
|
||||
}
|
||||
|
||||
describe('host select', () => {
|
||||
it('lists the hosts of the selected pool, current host excluded', () => {
|
||||
const { hostSelect } = mountVmMigrateForm()
|
||||
|
||||
expect(hostSelect.allOptions.value.map(option => option.id)).toEqual([TARGET_HOST_ID, SECOND_TARGET_HOST_ID])
|
||||
})
|
||||
|
||||
it('resets the host when the pool changes', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
form.formData.pool = OTHER_POOL_ID
|
||||
await nextTick()
|
||||
|
||||
expect(form.formData.host).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('migration network select', () => {
|
||||
it('lists the pool networks with PIFs and selects the pool migration network until a host is selected', () => {
|
||||
const { form, networkSelect } = mountVmMigrateForm()
|
||||
|
||||
expect(networkSelect.allOptions.value.map(option => option.id)).toEqual([
|
||||
MANAGEMENT_NETWORK_ID,
|
||||
MIGRATION_NETWORK_ID,
|
||||
NO_IP_NETWORK_ID,
|
||||
])
|
||||
expect(form.formData.migrationNetwork).toBe(MIGRATION_NETWORK_ID)
|
||||
expect(networkSelect.selectedLabel.value).toBe('BackupNet')
|
||||
})
|
||||
|
||||
it('clears the selection when switching to a pool without migration network', async () => {
|
||||
const { form, networkSelect } = mountVmMigrateForm()
|
||||
|
||||
form.formData.pool = OTHER_POOL_ID
|
||||
await nextTick()
|
||||
|
||||
expect(networkSelect.allOptions.value.map(option => option.id)).toEqual([OTHER_POOL_NETWORK_ID])
|
||||
expect(form.formData.migrationNetwork).toBeUndefined()
|
||||
expect(networkSelect.selectedLabel.value).toBe('')
|
||||
})
|
||||
|
||||
it('lists the networks on which the selected host has a PIF with an IP', async () => {
|
||||
const { form, networkSelect } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
|
||||
expect(networkSelect.allOptions.value.map(option => option.id)).toEqual([
|
||||
MANAGEMENT_NETWORK_ID,
|
||||
MIGRATION_NETWORK_ID,
|
||||
])
|
||||
})
|
||||
|
||||
it('selects the pool migration network when the host is selected', async () => {
|
||||
const { form, networkSelect } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
|
||||
expect(form.formData.migrationNetwork).toBe(MIGRATION_NETWORK_ID)
|
||||
expect(networkSelect.selectedLabel.value).toBe('BackupNet')
|
||||
})
|
||||
|
||||
it('drops a manual choice when the host changes, even if the suggested network is the same', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
form.formData.migrationNetwork = MANAGEMENT_NETWORK_ID
|
||||
form.formData.host = SECOND_TARGET_HOST_ID
|
||||
await nextTick()
|
||||
|
||||
expect(form.formData.migrationNetwork).toBe(MIGRATION_NETWORK_ID)
|
||||
})
|
||||
|
||||
it('keeps a manual choice when the suggested network object is replaced by a collection update', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
form.formData.migrationNetwork = MANAGEMENT_NETWORK_ID
|
||||
|
||||
loadedNetworks.value = networks.map(network =>
|
||||
network.id === MIGRATION_NETWORK_ID ? { ...network, name_label: 'BackupNet renamed' } : network
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(form.formData.migrationNetwork).toBe(MANAGEMENT_NETWORK_ID)
|
||||
})
|
||||
|
||||
it('falls back to the management network for an inter-pool migration', async () => {
|
||||
const { form, networkSelect } = mountVmMigrateForm()
|
||||
|
||||
form.formData.pool = OTHER_POOL_ID
|
||||
await nextTick()
|
||||
form.formData.host = OTHER_POOL_HOST_ID
|
||||
await nextTick()
|
||||
|
||||
expect(form.formData.migrationNetwork).toBe(OTHER_POOL_NETWORK_ID)
|
||||
expect(networkSelect.selectedLabel.value).toBe('Other management')
|
||||
})
|
||||
|
||||
it('shows the pool migration network in the info message as soon as the drawer opens', () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
expect(form.migrationNetworkSelectBindings.value.info).toEqual({
|
||||
content: 'Your default network is BackupNet',
|
||||
accent: 'info',
|
||||
})
|
||||
})
|
||||
|
||||
it('tells that no default network is configured when the selected pool has no migration network', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.pool = OTHER_POOL_ID
|
||||
await nextTick()
|
||||
form.formData.host = OTHER_POOL_HOST_ID
|
||||
await nextTick()
|
||||
|
||||
expect(form.migrationNetworkSelectBindings.value.info).toEqual({
|
||||
content: 'No default network is configured.',
|
||||
accent: 'info',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateAndBuildPayload', () => {
|
||||
it('builds a payload with the host and the migration network', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
|
||||
await expect(form.validateAndBuildPayload()).resolves.toEqual({
|
||||
hostId: TARGET_HOST_ID,
|
||||
migrationNetworkId: MIGRATION_NETWORK_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts no migration network for an intra-pool migration', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.host = TARGET_HOST_ID
|
||||
await nextTick()
|
||||
form.formData.migrationNetwork = undefined
|
||||
|
||||
await expect(form.validateAndBuildPayload()).resolves.toEqual({ hostId: TARGET_HOST_ID })
|
||||
})
|
||||
|
||||
it('requires a migration network for an inter-pool migration', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
form.formData.pool = OTHER_POOL_ID
|
||||
await nextTick()
|
||||
form.formData.host = OTHER_POOL_HOST_ID
|
||||
await nextTick()
|
||||
form.formData.migrationNetwork = undefined
|
||||
|
||||
await expect(form.validateAndBuildPayload()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds no payload without a host', async () => {
|
||||
const { form } = mountVmMigrateForm()
|
||||
|
||||
await expect(form.validateAndBuildPayload()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import {
|
||||
type FrontXoNetwork,
|
||||
useXoNetworkCollection,
|
||||
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
import { useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
|
||||
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
|
||||
import {
|
||||
type FrontXoSr,
|
||||
useXoSrCollection,
|
||||
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import { isSrWritable } from '@/modules/storage-repository/utils/xo-sr.util.ts'
|
||||
import { type FrontXoVdi, useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
|
||||
import { useXoVmVbdsUtils } from '@/modules/vm/composables/xo-vm-vbd-utils.composable.ts'
|
||||
import type { VmMigratePayload } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { toComputed } from '@core/utils/to-computed.util.ts'
|
||||
import { VM_POWER_STATE } from '@vates/types'
|
||||
import { computed, type MaybeRefOrGetter, ref, watch } from 'vue'
|
||||
|
||||
export type VmMigrateVdiRow = {
|
||||
vdi: FrontXoVdi
|
||||
sourceSr: FrontXoSr | undefined
|
||||
requiresSr: boolean
|
||||
targetSrId: FrontXoSr['id'] | undefined
|
||||
}
|
||||
|
||||
export type VmMigrateVifRow = {
|
||||
vif: FrontXoVif
|
||||
sourceNetwork: FrontXoNetwork | undefined
|
||||
targetNetworkId: FrontXoNetwork['id'] | undefined
|
||||
}
|
||||
|
||||
export type VmMigrateMappingPayload = Pick<VmMigratePayload, 'srIdByVdiId' | 'networkIdByVifId'>
|
||||
|
||||
export function useVmMigrateMappingForm(
|
||||
rawVm: MaybeRefOrGetter<FrontXoVm>,
|
||||
rawTargetPoolId: MaybeRefOrGetter<FrontXoPool['id'] | undefined>,
|
||||
rawTargetHostId: MaybeRefOrGetter<FrontXoHost['id'] | undefined>
|
||||
) {
|
||||
const vm = toComputed(rawVm)
|
||||
const targetPoolId = toComputed(rawTargetPoolId)
|
||||
const targetHostId = toComputed(rawTargetHostId)
|
||||
|
||||
const { notCdDriveVbds } = useXoVmVbdsUtils(vm)
|
||||
const { useGetVdisByIds } = useXoVdiCollection()
|
||||
const { srs, getSrById } = useXoSrCollection()
|
||||
const { pbdsBySr } = useXoPbdCollection()
|
||||
const { useGetVifsByIds } = useXoVifCollection()
|
||||
const { networks, getNetworkById } = useXoNetworkCollection()
|
||||
const { pifsByHost, getPifsByIds } = useXoPifCollection()
|
||||
|
||||
const isIntraPool = computed(() => targetPoolId.value === vm.value.$pool)
|
||||
|
||||
function isSrConnectedToTargetHost(srId: FrontXoSr['id']) {
|
||||
const srPbds = pbdsBySr.value.get(srId) ?? []
|
||||
|
||||
return srPbds.some(pbd => pbd.host === targetHostId.value && pbd.attached)
|
||||
}
|
||||
|
||||
const availableSrs = computed(() =>
|
||||
srs.value.filter(sr => sr.$pool === targetPoolId.value && isSrWritable(sr) && isSrConnectedToTargetHost(sr.id))
|
||||
)
|
||||
|
||||
const targetHostNetworkIds = computed(() => {
|
||||
if (targetHostId.value === undefined) {
|
||||
return new Set<FrontXoNetwork['id']>()
|
||||
}
|
||||
|
||||
const targetHostPifs = pifsByHost.value.get(targetHostId.value) ?? []
|
||||
|
||||
return new Set(targetHostPifs.map(pif => pif.$network))
|
||||
})
|
||||
|
||||
const availableNetworks = computed(() =>
|
||||
networks.value.filter(network => network.$pool === targetPoolId.value && targetHostNetworkIds.value.has(network.id))
|
||||
)
|
||||
|
||||
const vmVdiIds = computed(() => notCdDriveVbds.value.flatMap(vbd => (vbd.VDI ? [vbd.VDI as FrontXoVdi['id']] : [])))
|
||||
|
||||
const vmVdis = useGetVdisByIds(vmVdiIds)
|
||||
|
||||
const vmVifs = useGetVifsByIds(() => vm.value.VIFs)
|
||||
|
||||
const targetSrIdByVdiId = ref(new Map<FrontXoVdi['id'], FrontXoSr['id'] | undefined>())
|
||||
const targetNetworkIdByVifId = ref(new Map<FrontXoVif['id'], FrontXoNetwork['id'] | undefined>())
|
||||
|
||||
const destinationSrId = ref<FrontXoSr['id'] | undefined>(undefined)
|
||||
const destinationNetworkId = ref<FrontXoNetwork['id'] | undefined>(undefined)
|
||||
|
||||
function getDefaultTargetSrId(vdi: FrontXoVdi) {
|
||||
if (isIntraPool.value && isSrConnectedToTargetHost(vdi.$SR)) {
|
||||
return vdi.$SR
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const vdiRows = computed<VmMigrateVdiRow[]>(() => {
|
||||
if (targetHostId.value === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return vmVdis.value.map(vdi => {
|
||||
const defaultTargetSrId = getDefaultTargetSrId(vdi)
|
||||
|
||||
const targetSrId = targetSrIdByVdiId.value.has(vdi.id) ? targetSrIdByVdiId.value.get(vdi.id) : defaultTargetSrId
|
||||
|
||||
return {
|
||||
vdi,
|
||||
sourceSr: getSrById(vdi.$SR),
|
||||
requiresSr: defaultTargetSrId === undefined,
|
||||
targetSrId,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function getNetworkVlans(network: FrontXoNetwork) {
|
||||
return getPifsByIds(network.PIFs)
|
||||
.map(pif => pif.vlan)
|
||||
.filter(vlan => vlan !== -1)
|
||||
}
|
||||
|
||||
function findMatchingNetwork(sourceNetwork: FrontXoNetwork | undefined) {
|
||||
if (sourceNetwork === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const networkWithSameName = availableNetworks.value.find(network => network.name_label === sourceNetwork.name_label)
|
||||
|
||||
if (networkWithSameName !== undefined) {
|
||||
return networkWithSameName
|
||||
}
|
||||
|
||||
const sourceVlans = getNetworkVlans(sourceNetwork)
|
||||
|
||||
if (sourceVlans.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return availableNetworks.value.find(network => getNetworkVlans(network).some(vlan => sourceVlans.includes(vlan)))
|
||||
}
|
||||
|
||||
const vifRows = computed<VmMigrateVifRow[]>(() => {
|
||||
if (targetHostId.value === undefined || isIntraPool.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return vmVifs.value.map(vif => {
|
||||
const sourceNetwork = getNetworkById(vif.$network)
|
||||
|
||||
const targetNetworkId = targetNetworkIdByVifId.value.has(vif.id)
|
||||
? targetNetworkIdByVifId.value.get(vif.id)
|
||||
: findMatchingNetwork(sourceNetwork)?.id
|
||||
|
||||
return {
|
||||
vif,
|
||||
sourceNetwork,
|
||||
targetNetworkId,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const flaggedVdiRows = computed(() => vdiRows.value.filter(row => row.requiresSr))
|
||||
|
||||
function setTargetSrId(vdiId: FrontXoVdi['id'], srId: FrontXoSr['id'] | undefined) {
|
||||
targetSrIdByVdiId.value.set(vdiId, srId)
|
||||
}
|
||||
|
||||
function setTargetNetworkId(vifId: FrontXoVif['id'], networkId: FrontXoNetwork['id'] | undefined) {
|
||||
targetNetworkIdByVifId.value.set(vifId, networkId)
|
||||
}
|
||||
|
||||
function resetSrs() {
|
||||
targetSrIdByVdiId.value.clear()
|
||||
}
|
||||
|
||||
function resetNetworks() {
|
||||
targetNetworkIdByVifId.value.clear()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
destinationSrId.value = undefined
|
||||
destinationNetworkId.value = undefined
|
||||
resetSrs()
|
||||
resetNetworks()
|
||||
}
|
||||
|
||||
watch([targetPoolId, targetHostId], reset)
|
||||
|
||||
const movedVdiRows = computed(() =>
|
||||
vdiRows.value.filter(row => row.targetSrId !== undefined && row.targetSrId !== row.vdi.$SR)
|
||||
)
|
||||
|
||||
const isOnlyStorageMigratable = computed(() => vm.value.power_state !== VM_POWER_STATE.RUNNING && isIntraPool.value)
|
||||
|
||||
const isNothingToMigrate = computed(() => {
|
||||
if (!isOnlyStorageMigratable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return movedVdiRows.value.length === 0
|
||||
})
|
||||
|
||||
const hasNoDiskToMigrate = computed(() => isNothingToMigrate.value && vdiRows.value.length === 0)
|
||||
|
||||
const areAllSrsShared = computed(() => vdiRows.value.length > 0 && flaggedVdiRows.value.length === 0)
|
||||
|
||||
const areAllVifsFollowing = computed(() => isIntraPool.value)
|
||||
|
||||
const missingSrRows = computed(() => flaggedVdiRows.value.filter(row => row.targetSrId === undefined))
|
||||
|
||||
const missingNetworkRows = computed(() => vifRows.value.filter(row => row.targetNetworkId === undefined))
|
||||
|
||||
function fillEmptySrs() {
|
||||
if (destinationSrId.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const row of missingSrRows.value) {
|
||||
setTargetSrId(row.vdi.id, destinationSrId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fillEmptyNetworks() {
|
||||
if (destinationNetworkId.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const row of missingNetworkRows.value) {
|
||||
setTargetNetworkId(row.vif.id, destinationNetworkId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const hasMissingSr = computed(() => missingSrRows.value.length > 0)
|
||||
|
||||
const hasMissingNetwork = computed(() => missingNetworkRows.value.length > 0)
|
||||
|
||||
const isValid = computed(() => !hasMissingSr.value && !hasMissingNetwork.value)
|
||||
|
||||
function buildPayload(): VmMigrateMappingPayload | undefined {
|
||||
if (!isValid.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload: VmMigrateMappingPayload = {}
|
||||
|
||||
if (movedVdiRows.value.length > 0) {
|
||||
const srIdByVdiId: Record<FrontXoVdi['id'], FrontXoSr['id']> = {}
|
||||
|
||||
for (const row of movedVdiRows.value) {
|
||||
if (row.targetSrId !== undefined) {
|
||||
srIdByVdiId[row.vdi.id] = row.targetSrId
|
||||
}
|
||||
}
|
||||
|
||||
payload.srIdByVdiId = srIdByVdiId
|
||||
}
|
||||
|
||||
if (vifRows.value.length > 0) {
|
||||
const networkIdByVifId: Record<FrontXoVif['id'], FrontXoNetwork['id']> = {}
|
||||
|
||||
for (const row of vifRows.value) {
|
||||
if (row.targetNetworkId !== undefined) {
|
||||
networkIdByVifId[row.vif.id] = row.targetNetworkId
|
||||
}
|
||||
}
|
||||
|
||||
payload.networkIdByVifId = networkIdByVifId
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
isIntraPool,
|
||||
availableSrs,
|
||||
availableNetworks,
|
||||
vdiRows,
|
||||
vifRows,
|
||||
destinationSrId,
|
||||
destinationNetworkId,
|
||||
areAllSrsShared,
|
||||
areAllVifsFollowing,
|
||||
missingSrRows,
|
||||
missingNetworkRows,
|
||||
hasMissingSr,
|
||||
hasMissingNetwork,
|
||||
isOnlyStorageMigratable,
|
||||
isNothingToMigrate,
|
||||
hasNoDiskToMigrate,
|
||||
isValid,
|
||||
setTargetSrId,
|
||||
setTargetNetworkId,
|
||||
fillEmptySrs,
|
||||
resetSrs,
|
||||
fillEmptyNetworks,
|
||||
resetNetworks,
|
||||
buildPayload,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import type {
|
||||
FrontXoNetwork,
|
||||
useXoNetworkCollection,
|
||||
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
import type { FrontXoPbd, useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
|
||||
import type { FrontXoPif, useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
|
||||
import type {
|
||||
FrontXoSr,
|
||||
useXoSrCollection,
|
||||
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import type { FrontXoVbd } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
|
||||
import type { FrontXoVdi, useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import type { FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
|
||||
import type { useXoVmVbdsUtils } from '@/modules/vm/composables/xo-vm-vbd-utils.composable.ts'
|
||||
import { useVmMigrateMappingForm } from '@/modules/vm/form/migrate/use-vm-migrate-mapping-form.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { createNetwork } from '@/test/create-network.ts'
|
||||
import { createPbd } from '@/test/create-pbd.ts'
|
||||
import { createPif } from '@/test/create-pif.ts'
|
||||
import { createSr } from '@/test/create-sr.ts'
|
||||
import { createVbd } from '@/test/create-vbd.ts'
|
||||
import { createVdi } from '@/test/create-vdi.ts'
|
||||
import { createVif } from '@/test/create-vif.ts'
|
||||
import { createVm } from '@/test/create-vm.ts'
|
||||
import { groupBy } from '@/test/group-by.ts'
|
||||
import { mountComposable } from '@/test/mount-composable.ts'
|
||||
import { VM_POWER_STATE } from '@vates/types'
|
||||
import { computed, type MaybeRefOrGetter, nextTick, ref, toValue } from 'vue'
|
||||
|
||||
const {
|
||||
useXoVmVbdsUtilsMock,
|
||||
useXoVdiCollectionMock,
|
||||
useXoSrCollectionMock,
|
||||
useXoPbdCollectionMock,
|
||||
useXoVifCollectionMock,
|
||||
useXoNetworkCollectionMock,
|
||||
useXoPifCollectionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useXoVmVbdsUtilsMock: vi.fn(),
|
||||
useXoVdiCollectionMock: vi.fn(),
|
||||
useXoSrCollectionMock: vi.fn(),
|
||||
useXoPbdCollectionMock: vi.fn(),
|
||||
useXoVifCollectionMock: vi.fn(),
|
||||
useXoNetworkCollectionMock: vi.fn(),
|
||||
useXoPifCollectionMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vm/composables/xo-vm-vbd-utils.composable.ts'), () => ({
|
||||
useXoVmVbdsUtils: useXoVmVbdsUtilsMock as unknown as typeof useXoVmVbdsUtils,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'), () => ({
|
||||
useXoVdiCollection: useXoVdiCollectionMock as unknown as typeof useXoVdiCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
|
||||
useXoSrCollection: useXoSrCollectionMock as unknown as typeof useXoSrCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'), () => ({
|
||||
useXoPbdCollection: useXoPbdCollectionMock as unknown as typeof useXoPbdCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/vif/remote-resources/use-xo-vif-collection.ts'), () => ({
|
||||
useXoVifCollection: useXoVifCollectionMock as unknown as typeof useXoVifCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/network/remote-resources/use-xo-network-collection.ts'), () => ({
|
||||
useXoNetworkCollection: useXoNetworkCollectionMock as unknown as typeof useXoNetworkCollection,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/pif/remote-resources/use-xo-pif-collection.ts'), () => ({
|
||||
useXoPifCollection: useXoPifCollectionMock as unknown as typeof useXoPifCollection,
|
||||
}))
|
||||
|
||||
const POOL_A = 'pool-a' as FrontXoPool['id']
|
||||
const POOL_B = 'pool-b' as FrontXoPool['id']
|
||||
const HOST_A1 = 'host-a1' as FrontXoHost['id']
|
||||
const HOST_A2 = 'host-a2' as FrontXoHost['id']
|
||||
const HOST_B1 = 'host-b1' as FrontXoHost['id']
|
||||
const HOST_B2 = 'host-b2' as FrontXoHost['id']
|
||||
const SR_SHARED = 'sr-shared' as FrontXoSr['id']
|
||||
const SR_LOCAL_A1 = 'sr-local-a1' as FrontXoSr['id']
|
||||
const SR_B = 'sr-b' as FrontXoSr['id']
|
||||
const SR_ISO_B = 'sr-iso-b' as FrontXoSr['id']
|
||||
const VDI_SHARED = 'vdi-shared' as FrontXoVdi['id']
|
||||
const VDI_LOCAL = 'vdi-local' as FrontXoVdi['id']
|
||||
const VIF_PROD = 'vif-prod' as FrontXoVif['id']
|
||||
const VIF_GUEST = 'vif-guest' as FrontXoVif['id']
|
||||
const NET_A_PROD = 'net-a-prod' as FrontXoNetwork['id']
|
||||
const NET_A_GUEST = 'net-a-guest' as FrontXoNetwork['id']
|
||||
const NET_B_PROD = 'net-b-prod' as FrontXoNetwork['id']
|
||||
const NET_B_MGMT = 'net-b-mgmt' as FrontXoNetwork['id']
|
||||
const NET_B_NO_PIF = 'net-b-no-pif' as FrontXoNetwork['id']
|
||||
const NET_B_OTHER_HOST = 'net-b-other-host' as FrontXoNetwork['id']
|
||||
const NET_B_GUEST_VLAN = 'net-b-guest-vlan' as FrontXoNetwork['id']
|
||||
const PIF_A_GUEST = 'pif-a-guest' as FrontXoPif['id']
|
||||
const PIF_A2_GUEST = 'pif-a2-guest' as FrontXoPif['id']
|
||||
const PIF_B_PROD = 'pif-b-prod' as FrontXoPif['id']
|
||||
const PIF_B_MGMT = 'pif-b-mgmt' as FrontXoPif['id']
|
||||
const PIF_B_OTHER_HOST = 'pif-b-other-host' as FrontXoPif['id']
|
||||
const PIF_B_GUEST_VLAN = 'pif-b-guest-vlan' as FrontXoPif['id']
|
||||
|
||||
const srs = [
|
||||
createSr({ id: SR_SHARED, name_label: 'Shared', $pool: POOL_A, shared: true }),
|
||||
createSr({ id: SR_LOCAL_A1, name_label: 'Local A1', $pool: POOL_A, shared: false }),
|
||||
createSr({ id: SR_B, name_label: 'SR B', $pool: POOL_B, shared: true }),
|
||||
createSr({ id: SR_ISO_B, name_label: 'ISO B', $pool: POOL_B, shared: true, content_type: 'iso' }),
|
||||
]
|
||||
|
||||
const pbds = [
|
||||
createPbd({ id: 'pbd-1' as FrontXoPbd['id'], SR: SR_SHARED, host: HOST_A1, $pool: POOL_A }),
|
||||
createPbd({ id: 'pbd-2' as FrontXoPbd['id'], SR: SR_SHARED, host: HOST_A2, $pool: POOL_A }),
|
||||
createPbd({ id: 'pbd-3' as FrontXoPbd['id'], SR: SR_LOCAL_A1, host: HOST_A1, $pool: POOL_A }),
|
||||
createPbd({ id: 'pbd-4' as FrontXoPbd['id'], SR: SR_B, host: HOST_B1, $pool: POOL_B }),
|
||||
createPbd({ id: 'pbd-5' as FrontXoPbd['id'], SR: SR_ISO_B, host: HOST_B1, $pool: POOL_B }),
|
||||
]
|
||||
|
||||
const vdis = [
|
||||
createVdi({ id: VDI_SHARED, name_label: 'Shared disk', $SR: SR_SHARED, $pool: POOL_A }),
|
||||
createVdi({ id: VDI_LOCAL, name_label: 'Local disk', $SR: SR_LOCAL_A1, $pool: POOL_A }),
|
||||
]
|
||||
|
||||
const sharedVbd = createVbd({ id: 'vbd-1' as FrontXoVbd['id'], VDI: VDI_SHARED })
|
||||
const localVbd = createVbd({ id: 'vbd-2' as FrontXoVbd['id'], VDI: VDI_LOCAL })
|
||||
|
||||
const vbds = [sharedVbd, localVbd]
|
||||
|
||||
const networks = [
|
||||
createNetwork({ id: NET_A_PROD, name_label: 'Prod', $pool: POOL_A }),
|
||||
createNetwork({ id: NET_A_GUEST, name_label: 'Guest', $pool: POOL_A, PIFs: [PIF_A2_GUEST, PIF_A_GUEST] }),
|
||||
createNetwork({ id: NET_B_PROD, name_label: 'Prod', $pool: POOL_B, PIFs: [PIF_B_PROD] }),
|
||||
createNetwork({ id: NET_B_MGMT, name_label: 'Management', $pool: POOL_B, PIFs: [PIF_B_MGMT] }),
|
||||
createNetwork({ id: NET_B_NO_PIF, name_label: 'No PIF', $pool: POOL_B, PIFs: [] }),
|
||||
createNetwork({ id: NET_B_OTHER_HOST, name_label: 'Other host', $pool: POOL_B, PIFs: [PIF_B_OTHER_HOST] }),
|
||||
createNetwork({ id: NET_B_GUEST_VLAN, name_label: 'Guest VLAN', $pool: POOL_B, PIFs: [PIF_B_GUEST_VLAN] }),
|
||||
]
|
||||
|
||||
const vifs = [
|
||||
createVif({ id: VIF_PROD, $network: NET_A_PROD, $pool: POOL_A }),
|
||||
createVif({ id: VIF_GUEST, $network: NET_A_GUEST, $pool: POOL_A }),
|
||||
]
|
||||
|
||||
const pifs = [
|
||||
createPif({ id: PIF_A2_GUEST, $host: HOST_A2, $pool: POOL_A, $network: NET_A_GUEST, device: 'eth5' }),
|
||||
createPif({ id: PIF_A_GUEST, $host: HOST_A1, $pool: POOL_A, $network: NET_A_GUEST, device: 'eth1' }),
|
||||
createPif({ id: PIF_B_PROD, $host: HOST_B1, $pool: POOL_B, $network: NET_B_PROD }),
|
||||
createPif({ id: PIF_B_MGMT, $host: HOST_B1, $pool: POOL_B, $network: NET_B_MGMT }),
|
||||
createPif({ id: PIF_B_GUEST_VLAN, $host: HOST_B1, $pool: POOL_B, $network: NET_B_GUEST_VLAN, vlan: 20 }),
|
||||
createPif({ id: PIF_B_OTHER_HOST, $host: HOST_B2, $pool: POOL_B, $network: NET_B_OTHER_HOST }),
|
||||
]
|
||||
|
||||
// The collections load lazily, so each source is a ref a test can fill after the form is mounted
|
||||
const loadedVbds = ref<FrontXoVbd[]>([])
|
||||
const loadedVdis = ref<FrontXoVdi[]>([])
|
||||
const loadedPbds = ref<FrontXoPbd[]>([])
|
||||
const loadedVifs = ref<FrontXoVif[]>([])
|
||||
const loadedPifs = ref<FrontXoPif[]>([])
|
||||
|
||||
beforeEach(() => {
|
||||
useXoVmVbdsUtilsMock.mockReset()
|
||||
useXoVdiCollectionMock.mockReset()
|
||||
useXoSrCollectionMock.mockReset()
|
||||
useXoPbdCollectionMock.mockReset()
|
||||
useXoVifCollectionMock.mockReset()
|
||||
useXoNetworkCollectionMock.mockReset()
|
||||
useXoPifCollectionMock.mockReset()
|
||||
|
||||
loadedVbds.value = vbds
|
||||
loadedVdis.value = vdis
|
||||
loadedPbds.value = pbds
|
||||
loadedVifs.value = vifs
|
||||
loadedPifs.value = pifs
|
||||
|
||||
useXoVmVbdsUtilsMock.mockReturnValue({ notCdDriveVbds: computed(() => loadedVbds.value) })
|
||||
|
||||
useXoVdiCollectionMock.mockReturnValue({
|
||||
useGetVdisByIds: (ids: MaybeRefOrGetter<FrontXoVdi['id'][]>) =>
|
||||
computed(() => loadedVdis.value.filter(vdi => toValue(ids).includes(vdi.id))),
|
||||
})
|
||||
|
||||
useXoSrCollectionMock.mockReturnValue({
|
||||
srs: computed(() => srs),
|
||||
getSrById: (id: FrontXoSr['id'] | undefined) => srs.find(sr => sr.id === id),
|
||||
})
|
||||
|
||||
useXoPbdCollectionMock.mockReturnValue({
|
||||
pbdsBySr: computed(() => groupBy(loadedPbds.value, pbd => pbd.SR)),
|
||||
})
|
||||
|
||||
useXoVifCollectionMock.mockReturnValue({
|
||||
useGetVifsByIds: (ids: MaybeRefOrGetter<FrontXoVif['id'][]>) =>
|
||||
computed(() => loadedVifs.value.filter(vif => toValue(ids).includes(vif.id))),
|
||||
})
|
||||
|
||||
useXoNetworkCollectionMock.mockReturnValue({
|
||||
networks: computed(() => networks),
|
||||
getNetworkById: (id: FrontXoNetwork['id'] | undefined) => networks.find(network => network.id === id),
|
||||
})
|
||||
|
||||
useXoPifCollectionMock.mockReturnValue({
|
||||
pifsByHost: computed(() => groupBy(loadedPifs.value, pif => pif.$host)),
|
||||
getPifsByIds: (ids: FrontXoPif['id'][]) => loadedPifs.value.filter(pif => ids.includes(pif.id)),
|
||||
})
|
||||
})
|
||||
|
||||
function mountMappingForm(
|
||||
targetPoolId: FrontXoPool['id'] | undefined,
|
||||
targetHostId: FrontXoHost['id'] | undefined,
|
||||
vm: FrontXoVm = createVm({ $pool: POOL_A, $container: HOST_A1, VIFs: [VIF_PROD, VIF_GUEST] })
|
||||
) {
|
||||
const poolId = ref(targetPoolId)
|
||||
const hostId = ref(targetHostId)
|
||||
|
||||
const { wrapper } = mountComposable(() => useVmMigrateMappingForm(vm, poolId, hostId))
|
||||
|
||||
return { form: wrapper.vm, poolId, hostId }
|
||||
}
|
||||
|
||||
describe('without a target host', () => {
|
||||
it('has no rows', () => {
|
||||
const { form } = mountMappingForm(POOL_A, undefined)
|
||||
|
||||
expect(form.vdiRows).toEqual([])
|
||||
expect(form.vifRows).toEqual([])
|
||||
})
|
||||
|
||||
it('does not claim that all SRs are shared', () => {
|
||||
const { form } = mountMappingForm(POOL_A, undefined)
|
||||
|
||||
expect(form.areAllSrsShared).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('intra-pool migration', () => {
|
||||
it('flags only the disks whose SR is not connected to the target host', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vdiRows.map(row => [row.vdi.id, row.requiresSr])).toEqual([
|
||||
[VDI_SHARED, false],
|
||||
[VDI_LOCAL, true],
|
||||
])
|
||||
expect(form.areAllSrsShared).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the source SR on each row', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vdiRows.map(row => row.sourceSr?.id)).toEqual([SR_SHARED, SR_LOCAL_A1])
|
||||
})
|
||||
|
||||
it('does not map VIFs', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vifRows).toEqual([])
|
||||
expect(form.areAllVifsFollowing).toBe(true)
|
||||
expect(form.hasMissingNetwork).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the writable SRs connected to the target host', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.availableSrs.map(sr => sr.id)).toEqual([SR_SHARED])
|
||||
})
|
||||
|
||||
it('treats an SR whose PBD on the target host is detached as not connected', () => {
|
||||
loadedPbds.value = pbds.map(pbd => (pbd.host === HOST_A2 ? { ...pbd, attached: false } : pbd))
|
||||
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vdiRows.map(row => [row.vdi.id, row.requiresSr])).toEqual([
|
||||
[VDI_SHARED, true],
|
||||
[VDI_LOCAL, true],
|
||||
])
|
||||
expect(form.availableSrs).toEqual([])
|
||||
})
|
||||
|
||||
it('is invalid while a flagged disk has no target SR', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.missingSrRows.map(row => row.vdi.id)).toEqual([VDI_LOCAL])
|
||||
expect(form.hasMissingSr).toBe(true)
|
||||
expect(form.isValid).toBe(false)
|
||||
expect(form.buildPayload()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pre-selects no destination SR', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.destinationSrId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pre-fills the disks whose SR is connected to the target host with their source SR', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vdiRows.map(row => row.targetSrId)).toEqual([SR_SHARED, undefined])
|
||||
})
|
||||
|
||||
it('fills only the empty flagged rows with the destination SR', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
form.destinationSrId = SR_SHARED
|
||||
form.fillEmptySrs()
|
||||
|
||||
expect(form.vdiRows.map(row => row.targetSrId)).toEqual([SR_SHARED, SR_SHARED])
|
||||
expect(form.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('resets every target SR to its pre-filled value', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
form.destinationSrId = SR_SHARED
|
||||
form.fillEmptySrs()
|
||||
form.resetSrs()
|
||||
|
||||
expect(form.vdiRows.map(row => row.targetSrId)).toEqual([SR_SHARED, undefined])
|
||||
})
|
||||
|
||||
it('builds a payload with only the disks that change SR', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
form.destinationSrId = SR_SHARED
|
||||
form.fillEmptySrs()
|
||||
|
||||
expect(form.buildPayload()).toEqual({ srIdByVdiId: { [VDI_LOCAL]: SR_SHARED } })
|
||||
})
|
||||
|
||||
it('builds an empty payload when every SR is shared', () => {
|
||||
const vm = createVm({ $pool: POOL_A, $container: HOST_A1, VIFs: [] })
|
||||
loadedVbds.value = [sharedVbd]
|
||||
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2, vm)
|
||||
|
||||
expect(form.areAllSrsShared).toBe(true)
|
||||
expect(form.isValid).toBe(true)
|
||||
expect(form.buildPayload()).toEqual({})
|
||||
})
|
||||
|
||||
it('builds an empty payload when every disk stays on its SR', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1)
|
||||
|
||||
expect(form.vdiRows.map(row => row.targetSrId)).toEqual([SR_SHARED, SR_LOCAL_A1])
|
||||
expect(form.buildPayload()).toEqual({})
|
||||
})
|
||||
|
||||
it('includes a pre-filled disk once its target SR is changed', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1)
|
||||
|
||||
form.setTargetSrId(VDI_SHARED, SR_LOCAL_A1)
|
||||
|
||||
expect(form.buildPayload()).toEqual({ srIdByVdiId: { [VDI_SHARED]: SR_LOCAL_A1 } })
|
||||
})
|
||||
|
||||
it('lets a pre-filled disk be cleared', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1)
|
||||
|
||||
form.setTargetSrId(VDI_SHARED, undefined)
|
||||
|
||||
expect(form.vdiRows.map(row => row.targetSrId)).toEqual([undefined, SR_LOCAL_A1])
|
||||
})
|
||||
|
||||
it('can migrate more than the storage of a running VM', () => {
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1)
|
||||
|
||||
expect(form.isOnlyStorageMigratable).toBe(false)
|
||||
expect(form.isNothingToMigrate).toBe(false)
|
||||
expect(form.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('stays valid for a halted VM while no disk changes SR', () => {
|
||||
const vm = createVm({ $pool: POOL_A, $container: POOL_A, power_state: VM_POWER_STATE.HALTED })
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1, vm)
|
||||
|
||||
expect(form.isNothingToMigrate).toBe(true)
|
||||
expect(form.hasNoDiskToMigrate).toBe(false)
|
||||
expect(form.isValid).toBe(true)
|
||||
expect(form.buildPayload()).toEqual({})
|
||||
})
|
||||
|
||||
it('has nothing to migrate for a halted VM without disk', () => {
|
||||
const vm = createVm({ $pool: POOL_A, $container: POOL_A, power_state: VM_POWER_STATE.HALTED })
|
||||
loadedVbds.value = []
|
||||
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1, vm)
|
||||
|
||||
expect(form.hasNoDiskToMigrate).toBe(true)
|
||||
expect(form.isValid).toBe(true)
|
||||
expect(form.buildPayload()).toEqual({})
|
||||
})
|
||||
|
||||
it('stays valid for a suspended VM while no disk changes SR', () => {
|
||||
const vm = createVm({ $pool: POOL_A, $container: HOST_A1, power_state: VM_POWER_STATE.SUSPENDED })
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1, vm)
|
||||
|
||||
expect(form.isNothingToMigrate).toBe(true)
|
||||
expect(form.isValid).toBe(true)
|
||||
expect(form.buildPayload()).toEqual({})
|
||||
})
|
||||
|
||||
it('is valid for a halted VM once a disk changes SR', () => {
|
||||
const vm = createVm({ $pool: POOL_A, $container: POOL_A, power_state: VM_POWER_STATE.HALTED })
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A1, vm)
|
||||
|
||||
form.setTargetSrId(VDI_SHARED, SR_LOCAL_A1)
|
||||
|
||||
expect(form.isOnlyStorageMigratable).toBe(true)
|
||||
expect(form.isNothingToMigrate).toBe(false)
|
||||
expect(form.buildPayload()).toEqual({ srIdByVdiId: { [VDI_SHARED]: SR_LOCAL_A1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('inter-pool migration', () => {
|
||||
it('can migrate more than the storage of a halted VM', () => {
|
||||
const vm = createVm({ $pool: POOL_A, $container: POOL_A, power_state: VM_POWER_STATE.HALTED })
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1, vm)
|
||||
|
||||
expect(form.isOnlyStorageMigratable).toBe(false)
|
||||
expect(form.isNothingToMigrate).toBe(false)
|
||||
})
|
||||
|
||||
it('flags every disk', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.vdiRows.map(row => row.requiresSr)).toEqual([true, true])
|
||||
expect(form.areAllSrsShared).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the writable SRs of the target pool connected to the target host', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.availableSrs.map(sr => sr.id)).toEqual([SR_B])
|
||||
})
|
||||
|
||||
it('maps every VIF and pre-fills networks with the same name', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.vifRows.map(row => [row.vif.id, row.sourceNetwork?.id, row.targetNetworkId])).toEqual([
|
||||
[VIF_PROD, NET_A_PROD, NET_B_PROD],
|
||||
[VIF_GUEST, NET_A_GUEST, undefined],
|
||||
])
|
||||
expect(form.areAllVifsFollowing).toBe(false)
|
||||
expect(form.missingNetworkRows.map(row => row.vif.id)).toEqual([VIF_GUEST])
|
||||
expect(form.hasMissingNetwork).toBe(true)
|
||||
})
|
||||
|
||||
it('offers only the networks of the target pool that have a PIF on the target host', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.availableNetworks.map(network => network.id)).toEqual([NET_B_PROD, NET_B_MGMT, NET_B_GUEST_VLAN])
|
||||
})
|
||||
|
||||
it('fills only the empty rows with the destination network', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
form.destinationNetworkId = NET_B_MGMT
|
||||
form.fillEmptyNetworks()
|
||||
|
||||
expect(form.vifRows.map(row => row.targetNetworkId)).toEqual([NET_B_PROD, NET_B_MGMT])
|
||||
})
|
||||
|
||||
it('pre-fills a network sharing a VLAN with the source network when no name matches', () => {
|
||||
loadedPifs.value = pifs.map(pif => (pif.id === PIF_A_GUEST ? { ...pif, vlan: 20 } : pif))
|
||||
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.vifRows.map(row => row.targetNetworkId)).toEqual([NET_B_PROD, NET_B_GUEST_VLAN])
|
||||
})
|
||||
|
||||
it('resets every target network to its pre-filled value', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
form.destinationNetworkId = NET_B_MGMT
|
||||
form.fillEmptyNetworks()
|
||||
form.setTargetNetworkId(VIF_PROD, NET_B_MGMT)
|
||||
form.resetNetworks()
|
||||
|
||||
expect(form.vifRows.map(row => row.targetNetworkId)).toEqual([NET_B_PROD, undefined])
|
||||
})
|
||||
|
||||
it('builds a payload with every disk and every VIF', () => {
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
form.destinationSrId = SR_B
|
||||
form.fillEmptySrs()
|
||||
form.destinationNetworkId = NET_B_MGMT
|
||||
form.fillEmptyNetworks()
|
||||
|
||||
expect(form.buildPayload()).toEqual({
|
||||
srIdByVdiId: { [VDI_SHARED]: SR_B, [VDI_LOCAL]: SR_B },
|
||||
networkIdByVifId: { [VIF_PROD]: NET_B_PROD, [VIF_GUEST]: NET_B_MGMT },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('target change', () => {
|
||||
it('rebuilds the rows and resets the choices when the target host changes', async () => {
|
||||
const { form, poolId, hostId } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
form.destinationSrId = SR_SHARED
|
||||
form.fillEmptySrs()
|
||||
|
||||
poolId.value = POOL_B
|
||||
hostId.value = HOST_B1
|
||||
await nextTick()
|
||||
|
||||
expect(form.destinationSrId).toBeUndefined()
|
||||
expect(form.vdiRows.map(row => [row.requiresSr, row.targetSrId])).toEqual([
|
||||
[true, undefined],
|
||||
[true, undefined],
|
||||
])
|
||||
expect(form.vifRows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collections loaded after the target host is chosen', () => {
|
||||
it('builds the VDI rows once the VDIs arrive', async () => {
|
||||
loadedVdis.value = []
|
||||
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vdiRows).toEqual([])
|
||||
|
||||
loadedVdis.value = vdis
|
||||
await nextTick()
|
||||
|
||||
expect(form.vdiRows.map(row => [row.vdi.id, row.targetSrId])).toEqual([
|
||||
[VDI_SHARED, SR_SHARED],
|
||||
[VDI_LOCAL, undefined],
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the VIF rows once the VIFs arrive', async () => {
|
||||
loadedVifs.value = []
|
||||
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.vifRows).toEqual([])
|
||||
expect(form.hasMissingNetwork).toBe(false)
|
||||
|
||||
loadedVifs.value = vifs
|
||||
await nextTick()
|
||||
|
||||
expect(form.vifRows.map(row => [row.vif.id, row.targetNetworkId])).toEqual([
|
||||
[VIF_PROD, NET_B_PROD],
|
||||
[VIF_GUEST, undefined],
|
||||
])
|
||||
expect(form.hasMissingNetwork).toBe(true)
|
||||
})
|
||||
|
||||
it('unflags a shared disk once the PBDs arrive', async () => {
|
||||
loadedPbds.value = []
|
||||
|
||||
const { form } = mountMappingForm(POOL_A, HOST_A2)
|
||||
|
||||
expect(form.vdiRows.map(row => [row.requiresSr, row.targetSrId])).toEqual([
|
||||
[true, undefined],
|
||||
[true, undefined],
|
||||
])
|
||||
|
||||
loadedPbds.value = pbds
|
||||
await nextTick()
|
||||
|
||||
expect(form.vdiRows.map(row => [row.requiresSr, row.targetSrId])).toEqual([
|
||||
[false, SR_SHARED],
|
||||
[true, undefined],
|
||||
])
|
||||
})
|
||||
|
||||
it('pre-fills the networks once the PIFs arrive', async () => {
|
||||
loadedPifs.value = []
|
||||
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
expect(form.availableNetworks).toEqual([])
|
||||
expect(form.vifRows.map(row => row.targetNetworkId)).toEqual([undefined, undefined])
|
||||
|
||||
loadedPifs.value = pifs
|
||||
await nextTick()
|
||||
|
||||
expect(form.vifRows.map(row => row.targetNetworkId)).toEqual([NET_B_PROD, undefined])
|
||||
})
|
||||
|
||||
it('keeps the choices already made when the rows are rebuilt', async () => {
|
||||
loadedVdis.value = vdis.filter(vdi => vdi.id === VDI_SHARED)
|
||||
|
||||
const { form } = mountMappingForm(POOL_B, HOST_B1)
|
||||
|
||||
form.setTargetSrId(VDI_SHARED, SR_B)
|
||||
|
||||
loadedVdis.value = vdis
|
||||
await nextTick()
|
||||
|
||||
expect(form.vdiRows.map(row => row.targetSrId)).toEqual([SR_B, undefined])
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,7 @@ export function useDuplicateVmForm(rawVm: MaybeRefOrGetter<FrontXoVm>) {
|
||||
|
||||
const vm = toComputed(rawVm)
|
||||
|
||||
const { getSrLocation } = useXoSrUtils()
|
||||
const { getSrLocation, getSrSelectLabel } = useXoSrUtils()
|
||||
|
||||
const formData = reactive<DuplicateVmFormData>({
|
||||
name: `${vm.value.name_label}_COPY`,
|
||||
@@ -79,10 +79,7 @@ export function useDuplicateVmForm(rawVm: MaybeRefOrGetter<FrontXoVm>) {
|
||||
required: () => isFullCopy.value,
|
||||
searchable: true,
|
||||
option: {
|
||||
label: sr => {
|
||||
const gbLeft = Math.floor((sr.size - sr.physical_usage) / 1024 ** 3)
|
||||
return `${sr.name_label} (${getSrLocation(sr)}) - ${t('n-gb-left', { n: gbLeft })}`
|
||||
},
|
||||
label: sr => getSrSelectLabel(sr),
|
||||
properties: sr => ({ icon: objectIcon('sr', getPbdsConnectionStatus(pbdsBySr.value.get(sr.id) ?? [])) }),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,10 +12,11 @@ import { mountComposable } from '@/test/mount-composable.ts'
|
||||
import { VM_POWER_STATE } from '@vates/types'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const { useXoSrCollectionMock, useXoPbdCollectionMock, getSrLocation } = vi.hoisted(() => ({
|
||||
const { useXoSrCollectionMock, useXoPbdCollectionMock, getSrLocation, getSrSelectLabel } = vi.hoisted(() => ({
|
||||
useXoSrCollectionMock: vi.fn(),
|
||||
useXoPbdCollectionMock: vi.fn(),
|
||||
getSrLocation: vi.fn(),
|
||||
getSrSelectLabel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
|
||||
@@ -27,17 +28,19 @@ vi.mock(import('@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'), () =>
|
||||
}))
|
||||
|
||||
vi.mock(import('@/modules/storage-repository/composables/xo-sr-utils.composable.ts'), () => ({
|
||||
useXoSrUtils: (() => ({ getSrLocation })) as unknown as typeof useXoSrUtils,
|
||||
useXoSrUtils: (() => ({ getSrLocation, getSrSelectLabel })) as unknown as typeof useXoSrUtils,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
useXoSrCollectionMock.mockReset()
|
||||
useXoPbdCollectionMock.mockReset()
|
||||
getSrLocation.mockReset()
|
||||
getSrSelectLabel.mockReset()
|
||||
|
||||
useXoSrCollectionMock.mockReturnValue({ srs: computed(() => []) })
|
||||
useXoPbdCollectionMock.mockReturnValue({ pbdsBySr: computed(() => new Map()) })
|
||||
getSrLocation.mockReturnValue('Local')
|
||||
getSrSelectLabel.mockImplementation((sr: FrontXoSr) => sr.name_label)
|
||||
})
|
||||
|
||||
function mountDuplicateVmForm(vm: FrontXoVm = createVm()) {
|
||||
|
||||
59
@xen-orchestra/web/src/modules/vm/jobs/xo-vm-migrate.job.ts
Normal file
59
@xen-orchestra/web/src/modules/vm/jobs/xo-vm-migrate.job.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import type { VmMigratePayload } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import { xoVmArg } from '@/modules/vm/jobs/xo-vm-args.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import { isVmMigrationBlocked, isVmOperationPending, MIGRATION_OPERATIONS } from '@/modules/vm/utils/xo-vm.util.ts'
|
||||
import { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
|
||||
import { fetchPost } from '@/shared/utils/fetch.util.ts'
|
||||
import { defineJob, defineJobArg, JobError, JobRunningError } from '@core/packages/job'
|
||||
import { VM_POWER_STATE, type XoTask } from '@vates/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const xoVmMigratePayloadArg = defineJobArg<VmMigratePayload | undefined>({
|
||||
identify: false,
|
||||
toArray: false,
|
||||
})
|
||||
|
||||
export const useXoVmMigrateJob = defineJob('vm.migrate', [xoVmArg, xoVmMigratePayloadArg], () => {
|
||||
const { t } = useI18n()
|
||||
const { monitorTask } = useXoTaskUtils()
|
||||
const { getHostById } = useXoHostCollection()
|
||||
|
||||
return {
|
||||
async run(vm: FrontXoVm, payload: VmMigratePayload | undefined) {
|
||||
if (payload === undefined) {
|
||||
throw new JobError(t('job:vm-migrate:missing-host'))
|
||||
}
|
||||
|
||||
const { taskId } = await fetchPost<{ taskId: XoTask['id'] }>(`vms/${vm.id}/actions/migrate`, payload)
|
||||
|
||||
await monitorTask(taskId)
|
||||
},
|
||||
|
||||
validate: (isRunning, vm: FrontXoVm | undefined, payload: VmMigratePayload | undefined) => {
|
||||
if (vm === undefined) {
|
||||
throw new JobError(t('job:vm-migrate:missing-vm'))
|
||||
}
|
||||
|
||||
if (isRunning || isVmOperationPending(vm, MIGRATION_OPERATIONS)) {
|
||||
throw new JobRunningError(t('job:vm-migrate:in-progress'))
|
||||
}
|
||||
|
||||
if (vm.power_state === VM_POWER_STATE.PAUSED) {
|
||||
throw new JobError(t('job:vm-migrate:paused'))
|
||||
}
|
||||
|
||||
if (isVmMigrationBlocked(vm)) {
|
||||
throw new JobError(t('job:vm-migrate:blocked-operation'))
|
||||
}
|
||||
|
||||
if (payload === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (getHostById(payload.hostId) === undefined) {
|
||||
throw new JobError(t('job:vm-migrate:missing-host'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
|
||||
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
|
||||
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
|
||||
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
|
||||
import type { VmMigratePayload } from '@/modules/vm/form/migrate/use-vm-migrate-form.ts'
|
||||
import { useXoVmMigrateJob } from '@/modules/vm/jobs/xo-vm-migrate.job.ts'
|
||||
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
|
||||
import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
|
||||
import type { fetchPost } from '@/shared/utils/fetch.util.ts'
|
||||
import { createHost } from '@/test/create-host.ts'
|
||||
import { createVm } from '@/test/create-vm.ts'
|
||||
import { t } from '@/test/i18n.ts'
|
||||
import { mountComposable } from '@/test/mount-composable.ts'
|
||||
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { fetchPostMock, monitorTaskMock } = vi.hoisted(() => ({
|
||||
fetchPostMock: vi.fn(),
|
||||
monitorTaskMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock(import('@/shared/utils/fetch.util.ts'), () => ({
|
||||
fetchPost: fetchPostMock as unknown as typeof fetchPost,
|
||||
}))
|
||||
|
||||
vi.mock(import('@/shared/composables/xo-task-utils.composable.ts'), () => ({
|
||||
useXoTaskUtils: (() => ({ monitorTask: monitorTaskMock })) as unknown as typeof useXoTaskUtils,
|
||||
}))
|
||||
|
||||
const POOL_ID = 'pool-source' as FrontXoPool['id']
|
||||
const OTHER_POOL_ID = 'pool-other' as FrontXoPool['id']
|
||||
const HOST_ID = 'host-target' as FrontXoHost['id']
|
||||
const OTHER_POOL_HOST_ID = 'host-other-pool' as FrontXoHost['id']
|
||||
const TASK_ID = 'task-1'
|
||||
|
||||
const hosts = [
|
||||
createHost({ id: HOST_ID, $pool: POOL_ID }),
|
||||
createHost({ id: OTHER_POOL_HOST_ID, $pool: OTHER_POOL_ID }),
|
||||
]
|
||||
|
||||
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
|
||||
useXoHostCollection: (() => ({
|
||||
getHostById: (id: FrontXoHost['id']) => hosts.find(host => host.id === id),
|
||||
})) as unknown as typeof useXoHostCollection,
|
||||
}))
|
||||
|
||||
const payload: VmMigratePayload = { hostId: HOST_ID }
|
||||
|
||||
const payloadWithMovedDisk: VmMigratePayload = {
|
||||
hostId: HOST_ID,
|
||||
srIdByVdiId: { ['vdi-1' as FrontXoVdi['id']]: 'sr-1' as FrontXoSr['id'] },
|
||||
}
|
||||
|
||||
const crossPoolPayload: VmMigratePayload = { hostId: OTHER_POOL_HOST_ID }
|
||||
|
||||
const unknownHostPayload: VmMigratePayload = { hostId: 'host-unknown' as FrontXoHost['id'] }
|
||||
|
||||
// An explicit `undefined` must reach the job, so the payload is passed through an object rather than
|
||||
// a parameter with a default value, which `undefined` would trigger
|
||||
function mountMigrateJob(
|
||||
vm: FrontXoVm,
|
||||
{ payload: migratePayload }: { payload: VmMigratePayload | undefined } = { payload }
|
||||
) {
|
||||
const { wrapper } = mountComposable(() => useXoVmMigrateJob(() => vm, ref(migratePayload)))
|
||||
|
||||
return wrapper.vm
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchPostMock.mockReset()
|
||||
monitorTaskMock.mockReset()
|
||||
|
||||
fetchPostMock.mockResolvedValue({ taskId: TASK_ID })
|
||||
monitorTaskMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('validate', () => {
|
||||
it('can run for a running VM', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.RUNNING }))
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a halted VM before the drawer has produced a payload', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.HALTED }), { payload: undefined })
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a halted VM migrated to another pool', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.HALTED, $pool: POOL_ID }), {
|
||||
payload: crossPoolPayload,
|
||||
})
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a halted VM migrated within its pool when a disk changes SR', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.HALTED, $pool: POOL_ID }), {
|
||||
payload: payloadWithMovedDisk,
|
||||
})
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a halted VM migrated within its pool without any disk changing SR', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.HALTED, $pool: POOL_ID }))
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a suspended VM migrated to another pool', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.SUSPENDED, $pool: POOL_ID }), {
|
||||
payload: crossPoolPayload,
|
||||
})
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a suspended VM migrated within its pool when a disk changes SR', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.SUSPENDED, $pool: POOL_ID }), {
|
||||
payload: payloadWithMovedDisk,
|
||||
})
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a suspended VM migrated within its pool without any disk changing SR', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.SUSPENDED, $pool: POOL_ID }))
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can run for a running VM migrated within its pool without any disk changing SR', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.RUNNING, $pool: POOL_ID }))
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
expect(job.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cannot run for a paused VM', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.PAUSED }))
|
||||
|
||||
expect(job.canRun).toBe(false)
|
||||
expect(job.errorMessage).toBe(t('job:vm-migrate:paused'))
|
||||
})
|
||||
|
||||
it('reports a migration in progress while the VM is being migrated', () => {
|
||||
const job = mountMigrateJob(createVm({ current_operations: { 'op-1': VM_OPERATIONS.MIGRATE_SEND } }))
|
||||
|
||||
expect(job.canRun).toBe(false)
|
||||
expect(job.isRunning).toBe(true)
|
||||
expect(job.errorMessage).toBe(t('job:vm-migrate:in-progress'))
|
||||
})
|
||||
|
||||
it('cannot run when migrate_send is blocked on the VM', () => {
|
||||
const job = mountMigrateJob(
|
||||
createVm({ blockedOperations: { migrate_send: 'true' } as FrontXoVm['blockedOperations'] })
|
||||
)
|
||||
|
||||
expect(job.canRun).toBe(false)
|
||||
expect(job.errorMessage).toBe(t('job:vm-migrate:blocked-operation'))
|
||||
})
|
||||
|
||||
it('cannot run when pool_migrate is blocked on the VM', () => {
|
||||
const job = mountMigrateJob(
|
||||
createVm({ blockedOperations: { pool_migrate: 'true' } as FrontXoVm['blockedOperations'] })
|
||||
)
|
||||
|
||||
expect(job.canRun).toBe(false)
|
||||
expect(job.errorMessage).toBe(t('job:vm-migrate:blocked-operation'))
|
||||
})
|
||||
|
||||
it('can run before the drawer has produced a payload', () => {
|
||||
const job = mountMigrateJob(createVm(), { payload: undefined })
|
||||
|
||||
expect(job.canRun).toBe(true)
|
||||
})
|
||||
|
||||
it('cannot run when the destination host is not in the collection', () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.HALTED, $pool: POOL_ID }), {
|
||||
payload: unknownHostPayload,
|
||||
})
|
||||
|
||||
expect(job.canRun).toBe(false)
|
||||
expect(job.errorMessage).toBe(t('job:vm-migrate:missing-host'))
|
||||
})
|
||||
|
||||
// Reopening the menu mounts a new job instance whose payload is still empty: it must see the migration in progress
|
||||
it('reports a migration in progress to another instance without payload', async () => {
|
||||
monitorTaskMock.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const vm = createVm()
|
||||
|
||||
// Both instances are mounted together so they share the same job store
|
||||
const { wrapper } = mountComposable(() => {
|
||||
const { run } = useXoVmMigrateJob(() => vm, ref(payload))
|
||||
const { isRunning, canRun } = useXoVmMigrateJob(() => vm, ref(undefined))
|
||||
|
||||
return { run, isRunning, canRun }
|
||||
})
|
||||
|
||||
void wrapper.vm.run()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.isRunning).toBe(true)
|
||||
expect(wrapper.vm.canRun).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('run', () => {
|
||||
it('posts the payload to the migrate action and waits for the task', async () => {
|
||||
const vm = createVm()
|
||||
const job = mountMigrateJob(vm)
|
||||
|
||||
await job.run()
|
||||
|
||||
expect(fetchPostMock).toHaveBeenCalledWith(`vms/${vm.id}/actions/migrate`, payload)
|
||||
expect(monitorTaskMock).toHaveBeenCalledWith(TASK_ID)
|
||||
})
|
||||
|
||||
it('rejects when the task fails', async () => {
|
||||
const taskError = new Error('VM_INCOMPATIBLE_WITH_THIS_HOST()')
|
||||
monitorTaskMock.mockRejectedValue(taskError)
|
||||
|
||||
const job = mountMigrateJob(createVm())
|
||||
|
||||
await expect(job.run()).rejects.toBe(taskError)
|
||||
})
|
||||
|
||||
it('rejects before posting when there is no payload', async () => {
|
||||
const job = mountMigrateJob(createVm(), { payload: undefined })
|
||||
|
||||
await expect(job.run()).rejects.toThrow(t('job:vm-migrate:missing-host'))
|
||||
expect(fetchPostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects before posting when the destination host is not in the collection', async () => {
|
||||
const job = mountMigrateJob(createVm(), { payload: unknownHostPayload })
|
||||
|
||||
await expect(job.run()).rejects.toThrow(t('job:vm-migrate:missing-host'))
|
||||
expect(fetchPostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects before posting when the VM cannot be migrated', async () => {
|
||||
const job = mountMigrateJob(createVm({ power_state: VM_POWER_STATE.PAUSED }))
|
||||
|
||||
await expect(job.run()).rejects.toThrow(t('job:vm-migrate:paused'))
|
||||
expect(fetchPostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,16 @@ export const CHANGING_STATE_OPERATIONS: Partial<VM_OPERATIONS>[] = [
|
||||
VM_OPERATIONS.COPY,
|
||||
VM_OPERATIONS.EXPORT,
|
||||
VM_OPERATIONS.IMPORT,
|
||||
VM_OPERATIONS.MIGRATE_SEND,
|
||||
VM_OPERATIONS.POOL_MIGRATE,
|
||||
]
|
||||
|
||||
export const MIGRATION_OPERATIONS: VM_OPERATIONS[] = [VM_OPERATIONS.MIGRATE_SEND, VM_OPERATIONS.POOL_MIGRATE]
|
||||
|
||||
export function isVmMigrationBlocked(vm: FrontXoVm) {
|
||||
return MIGRATION_OPERATIONS.some(operation => vm.blockedOperations[operation] !== undefined)
|
||||
}
|
||||
|
||||
export function isVmOperationPending(vm: FrontXoVm, operations: VM_OPERATIONS[] | VM_OPERATIONS) {
|
||||
const currentOperations = Object.values(vm.current_operations)
|
||||
|
||||
|
||||
23
@xen-orchestra/web/src/test/create-network.ts
Normal file
23
@xen-orchestra/web/src/test/create-network.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
|
||||
|
||||
/**
|
||||
* Builds a fully-populated `FrontXoNetwork` for use in tests. Pass `overrides` to
|
||||
* tweak only the fields relevant to the case under test.
|
||||
*/
|
||||
export function createNetwork(overrides: Partial<FrontXoNetwork> = {}): FrontXoNetwork {
|
||||
return {
|
||||
id: 'network-123' as FrontXoNetwork['id'],
|
||||
name_label: 'Test network',
|
||||
name_description: 'A test network',
|
||||
defaultIsLocked: false,
|
||||
nbd: false,
|
||||
tags: [],
|
||||
$pool: 'pool-789' as FrontXoNetwork['$pool'],
|
||||
MTU: 1500,
|
||||
PIFs: [],
|
||||
other_config: {},
|
||||
type: 'network',
|
||||
isBonded: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
19
@xen-orchestra/web/src/test/create-pbd.ts
Normal file
19
@xen-orchestra/web/src/test/create-pbd.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { FrontXoPbd } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
|
||||
|
||||
/**
|
||||
* Builds a fully-populated `FrontXoPbd` for use in tests. Pass `overrides` to
|
||||
* tweak only the fields relevant to the case under test.
|
||||
*/
|
||||
export function createPbd(overrides: Partial<FrontXoPbd> = {}): FrontXoPbd {
|
||||
return {
|
||||
id: 'pbd-123' as FrontXoPbd['id'],
|
||||
type: 'PBD',
|
||||
attached: true,
|
||||
host: 'host-456' as FrontXoPbd['host'],
|
||||
SR: 'sr-123' as FrontXoPbd['SR'],
|
||||
device_config: {},
|
||||
otherConfig: {},
|
||||
$pool: 'pool-789' as FrontXoPbd['$pool'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
36
@xen-orchestra/web/src/test/create-pif.ts
Normal file
36
@xen-orchestra/web/src/test/create-pif.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FrontXoPif } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
|
||||
|
||||
/**
|
||||
* Builds a fully-populated `FrontXoPif` for use in tests. Pass `overrides` to
|
||||
* tweak only the fields relevant to the case under test.
|
||||
*/
|
||||
export function createPif(overrides: Partial<FrontXoPif> = {}): FrontXoPif {
|
||||
return {
|
||||
id: 'pif-123' as FrontXoPif['id'],
|
||||
$host: 'host-456' as FrontXoPif['$host'],
|
||||
$network: 'network-123' as FrontXoPif['$network'],
|
||||
$pool: 'pool-789' as FrontXoPif['$pool'],
|
||||
attached: true,
|
||||
carrier: true,
|
||||
device: 'eth0',
|
||||
dns: '',
|
||||
gateway: '',
|
||||
ip: '',
|
||||
ipv6: [],
|
||||
mac: '00:00:00:00:00:00',
|
||||
management: false,
|
||||
mode: 'Static',
|
||||
mtu: 1500,
|
||||
netmask: '',
|
||||
speed: 1000,
|
||||
vlan: -1,
|
||||
isBondMaster: false,
|
||||
isBondSlave: false,
|
||||
bondSlaves: undefined,
|
||||
physical: true,
|
||||
type: 'PIF',
|
||||
ipv6Mode: 'None',
|
||||
primaryAddressType: 'IPv4',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
24
@xen-orchestra/web/src/test/create-vif.ts
Normal file
24
@xen-orchestra/web/src/test/create-vif.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
|
||||
|
||||
/**
|
||||
* Builds a fully-populated `FrontXoVif` for use in tests. Pass `overrides` to
|
||||
* tweak only the fields relevant to the case under test.
|
||||
*/
|
||||
export function createVif(overrides: Partial<FrontXoVif> = {}): FrontXoVif {
|
||||
return {
|
||||
id: 'vif-123' as FrontXoVif['id'],
|
||||
$VM: 'vm-123' as FrontXoVif['$VM'],
|
||||
$network: 'network-123' as FrontXoVif['$network'],
|
||||
$pool: 'pool-789' as FrontXoVif['$pool'],
|
||||
attached: true,
|
||||
device: '0',
|
||||
txChecksumming: true,
|
||||
lockingMode: 'network_default',
|
||||
MAC: '00:00:00:00:00:00',
|
||||
MTU: 1500,
|
||||
rateLimit: undefined,
|
||||
type: 'VIF',
|
||||
other_config: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
19
@xen-orchestra/web/src/test/group-by.ts
Normal file
19
@xen-orchestra/web/src/test/group-by.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Groups `items` by the key `getKey` derives from each of them, preserving their order
|
||||
* inside each group. Meant to build the `xsByY` maps the remote-resource collections
|
||||
* expose (`hostsByPool`, `pifsByHost`, `pbdsBySr`…) from a flat fixture array, so a test
|
||||
* keeps a single source of truth for its objects.
|
||||
*/
|
||||
export function groupBy<TItem, TKey>(items: TItem[], getKey: (item: TItem) => TKey): Map<TKey, TItem[]> {
|
||||
const groups = new Map<TKey, TItem[]>()
|
||||
|
||||
for (const item of items) {
|
||||
const key = getKey(item)
|
||||
const group = groups.get(key) ?? []
|
||||
|
||||
group.push(item)
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
- [i18n] Update Arabic, Danish, Finnish, Italian, Japanese, Korean, Norwegian, Persian, Polish, Portuguese (Brasil), Russian and Ukrainian translations (PR [#10641](https://github.com/vatesfr/xen-orchestra/pull/10641))
|
||||
- [CLI] Add new command to `xo-server-db` cli: `disable-remote <id>`, disables a remote while xo-server is stopped (PR [#10587](https://github.com/vatesfr/xen-orchestra/pull/10587/))
|
||||
- [REST API/SDN Controller] With the XAPI plugin, traffic rules take an optional OpenFlow `priority`, unique on their network, the highest applying first, in `add_traffic_rule`, `update_traffic_rule` and the JSON-RPC methods `sdnController.addRule` and `sdnController.addNetworkRule`; a rule that has one is deleted or updated only when it is given too, in `delete_traffic_rule`, the `oldRule` of `update_traffic_rule` and the JSON-RPC methods `sdnController.deleteRule` and `sdnController.deleteNetworkRule` (PR [#10525](https://github.com/vatesfr/xen-orchestra/pull/10525))
|
||||
- [VM] Add migrate action on VMs (PR [#10615](https://github.com/vatesfr/xen-orchestra/pull/10615))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user