feat: add cdrom hotplug volume (#703)

Signed-off-by: Tim Liou <tim.liou@suse.com>
This commit is contained in:
Tim Liou
2026-02-13 16:33:50 +08:00
committed by GitHub
parent c8a613874a
commit 3fdc9f03a3
10 changed files with 429 additions and 35 deletions
@@ -13,11 +13,12 @@ import { ucFirst, randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array';
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { SOURCE_TYPE } from '../../../config/harvester-map';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm';
export default {
emits: ['update:value'],
@@ -117,6 +118,10 @@ export default {
return this.mode === _CREATE;
},
isHotplugCdRomFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('hotplugCdRom');
},
defaultStorageClass() {
const defaultStorage = this.$store.getters['harvester/all'](STORAGE_CLASS).find((sc) => sc.isDefault);
@@ -146,7 +151,7 @@ export default {
value: {
handler(neu) {
const rows = clone(neu).map((V) => {
if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId) {
if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId && V.image !== EMPTY_IMAGE) {
V.to = {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`,
params: {
@@ -217,8 +222,48 @@ export default {
}
},
unplugVolume(volume) {
this.vm.unplugVolume(volume.name);
canDoVolumeHotplugAction(volume) {
if (!this.isHotplugCdRomFeatureEnabled && volume.type === 'cd-rom') {
return false;
}
if (volume.hotpluggable) {
return true;
}
return volume.type === 'cd-rom' && volume.bus === 'sata' && volume.image === EMPTY_IMAGE;
},
getVolumeHotplugAction(volume) {
if (volume.type === 'cd-rom' && volume.bus === 'sata') {
if (volume.image === EMPTY_IMAGE) {
return VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE;
}
return VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE;
}
return VOLUME_HOTPLUG_ACTION.DETACH_DISK;
},
getVolumeHotplugActionLabel(volume) {
const labels = {
[VOLUME_HOTPLUG_ACTION.DETACH_DISK]: 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel',
[VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE]: 'harvester.modal.insertCdRomVolume.actionLabel',
[VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.actionLabel',
};
return labels[this.getVolumeHotplugAction(volume)];
},
hotplugVolume(volume) {
const calls = {
[VOLUME_HOTPLUG_ACTION.DETACH_DISK]: () => this.vm.unplugVolume(volume.name),
[VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE]: () => this.vm.insertCdRomVolume(volume.name),
[VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE]: () => this.vm.ejectCdRomVolume(volume.name),
};
return calls[this.getVolumeHotplugAction(volume)]();
},
componentFor(type) {
@@ -346,12 +391,12 @@ export default {
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
v-if="canDoVolumeHotplugAction(volume) && isView"
type="button"
class="role-link btn btn-sm remove"
@click="unplugVolume(volume)"
@click="hotplugVolume(volume)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachVolume.actionLabel') }}
{{ t(getVolumeHotplugActionLabel(volume)) }}
</button>
</div>
<div>
@@ -14,6 +14,7 @@ import { _VIEW } from '@shell/config/query-params';
import LabelValue from '@shell/components/LabelValue';
import { ucFirst } from '@shell/utils/string';
import { GIBIBYTE } from '../../../../utils/unit';
import { EMPTY_IMAGE } from '../../../../utils/vm';
export default {
name: 'HarvesterEditVMImage',
@@ -96,8 +97,20 @@ export default {
return this.mode === _VIEW;
},
isExistingCdrom() {
return this.value.type === 'cd-rom' && !this.value.newCreateId;
},
isEmptyImage() {
return this.value.image === EMPTY_IMAGE;
},
isHotplugCdRomFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('hotplugCdRom');
},
imagesOption() {
return this.images
const images = this.images
.filter((image) => {
if (!image.isReady) return false;
@@ -114,6 +127,19 @@ export default {
value: image.id,
disabled: image.isImportedImage
}));
const options = [];
if (this.isHotplugCdRomFeatureEnabled) {
options.push({
label: this.t('harvester.virtualMachine.volume.emptyImage'),
value: EMPTY_IMAGE,
disabled: false
});
}
options.push(...images);
return options;
},
imageName() {
@@ -179,6 +205,7 @@ export default {
'value.type'(neu) {
if (neu === 'cd-rom') {
this.value['bus'] = 'sata';
this.updateHotpluggable();
this.update();
}
},
@@ -221,12 +248,48 @@ export default {
return label;
},
update() {
this.value.hasDiskError = this.showDiskTooSmallError;
this.$emit('update');
},
updateHotpluggable() {
if (this.value.type !== 'cd-rom') {
this.value['hotpluggable'] = false;
} else {
this.value['hotpluggable'] = (this.value.bus === 'sata');
}
},
onTypeChange() {
if (this.value.image === EMPTY_IMAGE && this.value.type !== 'cd-rom') {
this.value['image'] = '';
}
this.updateHotpluggable();
this.update();
},
onBusChange() {
if (this.value.image === EMPTY_IMAGE && this.value.bus !== 'sata') {
this.value['image'] = '';
}
this.updateHotpluggable();
this.update();
},
onImageChange() {
if (this.value.image === EMPTY_IMAGE) {
this.value['type'] = 'cd-rom';
this.value['bus'] = 'sata';
this.value['size'] = `0${ GIBIBYTE }`;
this.update();
return;
}
const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE)?.find( (I) => this.value.image === I.id);
const isIsoImage = /iso$/i.test(imageResource?.imageSuffix);
const imageSize = Math.max(imageResource?.status?.size, imageResource?.status?.virtualSize);
@@ -234,6 +297,7 @@ export default {
if (isIsoImage) {
this.value['type'] = 'cd-rom';
this.value['bus'] = 'sata';
this.updateHotpluggable();
} else {
this.value['type'] = 'disk';
this.value['bus'] = 'virtio';
@@ -256,6 +320,10 @@ export default {
},
checkImageExists(imageId) {
if (imageId === EMPTY_IMAGE) {
return;
}
if (!!imageId && this.imagesOption.length > 0 && !findBy(this.imagesOption, 'value', imageId)) {
this.$store.dispatch('growl/error', {
title: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.title', { name: imageId }),
@@ -283,6 +351,7 @@ export default {
>
<LabeledInput
v-model:value="value.name"
:disabled="!isCreate && isExistingCdrom"
:label="t('harvester.fields.name')"
required
:mode="mode"
@@ -302,10 +371,11 @@ export default {
>
<LabeledSelect
v-model:value="value.type"
:disabled="!isCreate && isExistingCdrom"
:label="t('harvester.fields.type')"
:options="VOLUME_TYPE"
:mode="mode"
@update:value="update"
@update:value="onTypeChange"
/>
</InputOrDisplay>
</div>
@@ -323,7 +393,7 @@ export default {
>
<LabeledSelect
v-model:value="value.image"
:disabled="idx === 0 && !isCreate && !value.newCreateId && isVirtualType"
:disabled="(idx === 0 || isExistingCdrom) && (!isCreate && !value.newCreateId && isVirtualType)"
:label="t('harvester.fields.image')"
:options="imagesOption"
:mode="mode"
@@ -351,7 +421,7 @@ export default {
:label="t('harvester.fields.size')"
:mode="mode"
:required="validateRequired"
:disabled="isResizeDisabled"
:disabled="isResizeDisabled || isEmptyImage || (!isCreate && isExistingCdrom)"
:suffix="GIBIBYTE"
@update:value="update"
/>
@@ -374,7 +444,8 @@ export default {
:label="t('harvester.virtualMachine.volume.bus')"
:mode="mode"
:options="InterfaceOption"
@update:value="update"
:disabled="!isCreate && isExistingCdrom"
@update:value="onBusChange"
/>
</InputOrDisplay>
</div>