|
| 1 | +<script> |
| 2 | +import { mapGetters } from 'vuex'; |
| 3 | +
|
| 4 | +import { PVC } from '@shell/config/types'; |
| 5 | +import { exceptionToErrorsArray } from '@shell/utils/error'; |
| 6 | +import { sortBy } from '@shell/utils/sort'; |
| 7 | +import { HCI } from '../types'; |
| 8 | +import { parseVolumeClaimTemplates } from '@pkg/harvester/utils/vm'; |
| 9 | +
|
| 10 | +import { Card } from '@components/Card'; |
| 11 | +import { Banner } from '@components/Banner'; |
| 12 | +import AsyncButton from '@shell/components/AsyncButton'; |
| 13 | +import LabeledSelect from '@shell/components/form/LabeledSelect'; |
| 14 | +
|
| 15 | +export default { |
| 16 | + name: 'HarvesterStorageMigrationDialog', |
| 17 | +
|
| 18 | + emits: ['close'], |
| 19 | +
|
| 20 | + components: { |
| 21 | + AsyncButton, Banner, Card, LabeledSelect |
| 22 | + }, |
| 23 | +
|
| 24 | + props: { |
| 25 | + resources: { |
| 26 | + type: Array, |
| 27 | + required: true |
| 28 | + } |
| 29 | + }, |
| 30 | +
|
| 31 | + async fetch() { |
| 32 | + this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC }); |
| 33 | + }, |
| 34 | +
|
| 35 | + data() { |
| 36 | + return { |
| 37 | + sourceVolume: '', |
| 38 | + targetVolume: '', |
| 39 | + errors: [], |
| 40 | + allPVCs: [], |
| 41 | + }; |
| 42 | + }, |
| 43 | +
|
| 44 | + computed: { |
| 45 | + ...mapGetters({ t: 'i18n/t' }), |
| 46 | +
|
| 47 | + actionResource() { |
| 48 | + return this.resources[0]; |
| 49 | + }, |
| 50 | +
|
| 51 | + sourceVolumeOptions() { |
| 52 | + const volumes = this.actionResource.spec?.template?.spec?.volumes || []; |
| 53 | +
|
| 54 | + return sortBy( |
| 55 | + volumes |
| 56 | + .map((v) => v.persistentVolumeClaim?.claimName) |
| 57 | + .filter((name) => !!name) |
| 58 | + .map((name) => ({ |
| 59 | + label: name, |
| 60 | + value: name |
| 61 | + })), |
| 62 | + 'label' |
| 63 | + ); |
| 64 | + }, |
| 65 | +
|
| 66 | + namespacePVCs() { |
| 67 | + return this.allPVCs.filter((pvc) => pvc.metadata.namespace === this.actionResource.metadata.namespace); |
| 68 | + }, |
| 69 | +
|
| 70 | + vmUsedVolumeNames() { |
| 71 | + const allVMs = this.$store.getters['harvester/all'](HCI.VM) || []; |
| 72 | + const names = new Set(); |
| 73 | +
|
| 74 | + allVMs.forEach((vm) => { |
| 75 | + // Collect volume names from spec.template.spec.volumes (both PVC and DataVolume references) |
| 76 | + const volumes = vm.spec?.template?.spec?.volumes || []; |
| 77 | +
|
| 78 | + volumes.forEach((v) => { |
| 79 | + const name = v.persistentVolumeClaim?.claimName || v.dataVolume?.name; |
| 80 | +
|
| 81 | + if (name) { |
| 82 | + names.add(`${ vm.metadata.namespace }/${ name }`); |
| 83 | + } |
| 84 | + }); |
| 85 | +
|
| 86 | + // Collect volume names from volumeClaimTemplates annotation |
| 87 | + const templates = parseVolumeClaimTemplates(vm); |
| 88 | +
|
| 89 | + templates.forEach((t) => { |
| 90 | + if (t.metadata?.name) { |
| 91 | + names.add(`${ vm.metadata.namespace }/${ t.metadata.name }`); |
| 92 | + } |
| 93 | + }); |
| 94 | + }); |
| 95 | +
|
| 96 | + return names; |
| 97 | + }, |
| 98 | +
|
| 99 | + targetVolumeOptions() { |
| 100 | + return sortBy( |
| 101 | + this.namespacePVCs |
| 102 | + .filter((pvc) => { |
| 103 | + // Exclude volumes used by any VM (via spec.volumes or volumeClaimTemplates) |
| 104 | + if (this.vmUsedVolumeNames.has(`${ pvc.metadata.namespace }/${ pvc.metadata.name }`)) { |
| 105 | + return false; |
| 106 | + } |
| 107 | +
|
| 108 | + return true; |
| 109 | + }) |
| 110 | + .map((pvc) => ({ |
| 111 | + label: pvc.metadata.name, |
| 112 | + value: pvc.metadata.name |
| 113 | + })), |
| 114 | + 'label' |
| 115 | + ); |
| 116 | + }, |
| 117 | +
|
| 118 | + disableSave() { |
| 119 | + return !this.sourceVolume || !this.targetVolume; |
| 120 | + }, |
| 121 | + }, |
| 122 | +
|
| 123 | + methods: { |
| 124 | + close() { |
| 125 | + this.sourceVolume = ''; |
| 126 | + this.targetVolume = ''; |
| 127 | + this.errors = []; |
| 128 | + this.$emit('close'); |
| 129 | + }, |
| 130 | +
|
| 131 | + async apply(buttonDone) { |
| 132 | + if (!this.actionResource) { |
| 133 | + buttonDone(false); |
| 134 | +
|
| 135 | + return; |
| 136 | + } |
| 137 | +
|
| 138 | + if (!this.sourceVolume) { |
| 139 | + const name = this.t('harvester.modal.storageMigration.fields.sourceVolume.label'); |
| 140 | +
|
| 141 | + this['errors'] = [this.t('validation.required', { key: name })]; |
| 142 | + buttonDone(false); |
| 143 | +
|
| 144 | + return; |
| 145 | + } |
| 146 | +
|
| 147 | + if (!this.targetVolume) { |
| 148 | + const name = this.t('harvester.modal.storageMigration.fields.targetVolume.label'); |
| 149 | +
|
| 150 | + this['errors'] = [this.t('validation.required', { key: name })]; |
| 151 | + buttonDone(false); |
| 152 | +
|
| 153 | + return; |
| 154 | + } |
| 155 | +
|
| 156 | + try { |
| 157 | + await this.actionResource.doAction('storageMigration', { |
| 158 | + sourceVolume: this.sourceVolume, |
| 159 | + targetVolume: this.targetVolume |
| 160 | + }, {}, false); |
| 161 | +
|
| 162 | + buttonDone(true); |
| 163 | + this.close(); |
| 164 | + } catch (err) { |
| 165 | + const error = err?.data || err; |
| 166 | +
|
| 167 | + this['errors'] = exceptionToErrorsArray(error); |
| 168 | + buttonDone(false); |
| 169 | + } |
| 170 | + }, |
| 171 | + } |
| 172 | +}; |
| 173 | +</script> |
| 174 | +
|
| 175 | +<template> |
| 176 | + <Card :show-highlight-border="false"> |
| 177 | + <template #title> |
| 178 | + {{ t('harvester.modal.storageMigration.title') }} |
| 179 | + </template> |
| 180 | +
|
| 181 | + <template #body> |
| 182 | + <LabeledSelect |
| 183 | + v-model:value="sourceVolume" |
| 184 | + :label="t('harvester.modal.storageMigration.fields.sourceVolume.label')" |
| 185 | + :placeholder="t('harvester.modal.storageMigration.fields.sourceVolume.placeholder')" |
| 186 | + :options="sourceVolumeOptions" |
| 187 | + class="mb-20" |
| 188 | + required |
| 189 | + /> |
| 190 | +
|
| 191 | + <LabeledSelect |
| 192 | + v-model:value="targetVolume" |
| 193 | + :label="t('harvester.modal.storageMigration.fields.targetVolume.label')" |
| 194 | + :placeholder="t('harvester.modal.storageMigration.fields.targetVolume.placeholder')" |
| 195 | + :options="targetVolumeOptions" |
| 196 | + required |
| 197 | + /> |
| 198 | +
|
| 199 | + <Banner |
| 200 | + v-for="(err, i) in errors" |
| 201 | + :key="i" |
| 202 | + color="error" |
| 203 | + :label="err" |
| 204 | + /> |
| 205 | + </template> |
| 206 | +
|
| 207 | + <template |
| 208 | + #actions |
| 209 | + class="actions" |
| 210 | + > |
| 211 | + <div class="buttons"> |
| 212 | + <button |
| 213 | + class="btn role-secondary mr-10" |
| 214 | + @click="close" |
| 215 | + > |
| 216 | + {{ t('generic.cancel') }} |
| 217 | + </button> |
| 218 | +
|
| 219 | + <AsyncButton |
| 220 | + mode="apply" |
| 221 | + :disabled="disableSave" |
| 222 | + @click="apply" |
| 223 | + /> |
| 224 | + </div> |
| 225 | + </template> |
| 226 | + </Card> |
| 227 | +</template> |
| 228 | +
|
| 229 | +<style lang="scss" scoped> |
| 230 | +.actions { |
| 231 | + width: 100%; |
| 232 | +} |
| 233 | +
|
| 234 | +.buttons { |
| 235 | + display: flex; |
| 236 | + justify-content: flex-end; |
| 237 | + width: 100%; |
| 238 | +} |
| 239 | +</style> |
0 commit comments