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:
Elise-F
2026-10-28 10:59:25 +01:00
committed by GitHub
parent 092133f811
commit 5385f9f229
61 changed files with 3396 additions and 46 deletions

View File

@@ -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>

View File

@@ -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')

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -355,7 +355,6 @@
"descending": "تنازلي",
"description": "الوصف",
"destination-sr": "SR الوجهة",
"destination-sr-mandatory": "SR الوجهة إلزامي.",
"device": "الجهاز",
"device-config": "تكوين الجهاز",
"dhcp": "DHCP",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -355,7 +355,6 @@
"descending": "نزولی",
"description": "توصیف",
"destination-sr": "SR مقصد",
"destination-sr-mandatory": "SR مقصد الزامی است.",
"device": "دستگاه",
"device-config": "پیکربندی دستگاه",
"dhcp": "DHCP",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -355,7 +355,6 @@
"descending": "降順",
"description": "説明",
"destination-sr": "移行先 SR",
"destination-sr-mandatory": "移行先 SR は必須です。",
"device": "リモートデバイス",
"device-config": "デバイス設定",
"dhcp": "DHCP",

View File

@@ -355,7 +355,6 @@
"descending": "내림차순",
"description": "설명",
"destination-sr": "대상 SR",
"destination-sr-mandatory": "대상 SR은 필수입니다.",
"device": "장치",
"device-config": "장치 설정",
"dhcp": "DHCP",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -355,7 +355,6 @@
"descending": "по убыванию",
"description": "Описание",
"destination-sr": "Целевой SR",
"destination-sr-mandatory": "Целевой SR обязателен.",
"device": "Устройство",
"device-config": "Конфигурация устройства",
"dhcp": "DHCP",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -355,7 +355,6 @@
"descending": "за спаданням",
"description": "Опис",
"destination-sr": "SR призначення",
"destination-sr-mandatory": "SR призначення обов'язковий.",
"device": "Пристрій",
"device-config": "Конфігурація пристрою",
"dhcp": "DHCP",

View File

@@ -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",

View File

@@ -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 }),
}))

View File

@@ -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(),
}
})

View File

@@ -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(),
}
})

View File

@@ -23,6 +23,7 @@ export type VmBlockedOperations =
| 'hard_reboot'
| 'suspend'
| 'destroy'
| 'migrate_send'
export type ActionsByObject = {
vm: VmActions

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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) ?? [])) }),
},

View File

@@ -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) ?? [])) }),
},

View File

@@ -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'

View File

@@ -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()
})

View File

@@ -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>

View File

@@ -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)
})
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}
}

View File

@@ -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 })
})
})

View File

@@ -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'

View File

@@ -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,
}
}

View File

@@ -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()
})
})

View File

@@ -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,
}
}

View File

@@ -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])
})
})

View File

@@ -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) ?? [])) }),
},
})

View File

@@ -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()) {

View 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'))
}
},
}
})

View File

@@ -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()
})
})

View File

@@ -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)

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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
}

View File

@@ -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