diff --git a/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue b/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue index b9565aff..9d87c2d1 100644 --- a/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue +++ b/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue @@ -18,6 +18,7 @@ import { getLatestMigration } from '../../utils/vm'; import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig'; import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume'; +import VMPerformanceOptions from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions'; import Network from '../../edit/kubevirt.io.virtualmachine/VirtualMachineNetwork'; import AccessCredentials from '../../edit/kubevirt.io.virtualmachine/VirtualMachineAccessCredentials'; import Events from './VirtualMachineTabs/VirtualMachineEvents'; @@ -40,6 +41,7 @@ export default { OverviewBasics, LabelValue, Volume, + VMPerformanceOptions, Network, OverviewKeypairs, CloudConfig, @@ -253,9 +255,15 @@ export default { :namespace="value.metadata.namespace" :vm="value" :resource-type="value.type" + /> + diff --git a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue index aa4fd34f..2500cb11 100644 --- a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue +++ b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue @@ -21,6 +21,7 @@ import { HCI } from '../types'; import VM_MIXIN from '../mixins/harvester-vm'; import Reserved from './kubevirt.io.virtualmachine/VirtualMachineReserved'; import Volume from './kubevirt.io.virtualmachine/VirtualMachineVolume'; +import VMPerformanceOptions from './kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions'; import Network from './kubevirt.io.virtualmachine/VirtualMachineNetwork'; import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory'; import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel'; @@ -37,6 +38,7 @@ export default { Tab, SSHKey, Volume, + VMPerformanceOptions, Tabbed, Network, Checkbox, @@ -390,9 +392,6 @@ export default { > + + - - -
-
-
- -
-
- - - +
+ +
+
+ + + + +
+ + +
+ + + +
- -
- - diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue index 29de9c19..1e0a83f7 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue @@ -1,5 +1,4 @@ - - diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts index 9e659173..73a80f8f 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; +import { load } from 'js-yaml'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { _EDIT } from '@shell/config/query-params'; @@ -24,6 +27,12 @@ const hardDisk = (overrides = {}) => ({ type: 'disk', bus: 'virtio', cache: '', io: '', dedicatedIOThread: false, ...overrides }); +const nativeTip = (wrapper) => wrapper.findAllComponents(Banner) + .find((b) => b.props('labelKey') === 'harvester.virtualMachine.volume.performance.ioMode.nativeRequiresNoCacheTip'); + +const filesystemWarning = (wrapper) => wrapper.findAllComponents(Banner) + .find((b) => b.props('labelKey') === 'harvester.virtualMachine.volume.performance.cacheMode.filesystemWarning'); + describe('component: DiskPerformanceOptions', () => { it('is hidden when the highPerformanceStorage feature is disabled', () => { const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk(), { featureEnabled: false })); @@ -37,6 +46,102 @@ describe('component: DiskPerformanceOptions', () => { expect(wrapper.find('.disk-performance').exists()).toBe(true); }); + it('shows the profile without a toggle, and the individual fields only for Custom', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk())); + + expect(wrapper.find('.expand-toggle').exists()).toBe(false); + expect(wrapper.find('[data-testid="input-disk-perf-profile"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="input-disk-cache"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="input-disk-dedicated-iothread"]').exists()).toBe(false); + }); + + it('shows the individual fields for a disk with custom options', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ cache: 'writeback', io: 'threads' }))); + + expect(wrapper.find('[data-testid="input-disk-cache"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="input-disk-io"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="input-disk-dedicated-iothread"]').exists()).toBe(true); + }); + + it('is hidden for a CD-ROM', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ type: 'cd-rom' }))); + + expect(wrapper.find('.disk-performance').exists()).toBe(false); + }); + + it('names the bus in the bus tip once the disk uses an option', () => { + const plain = mount(DiskPerformanceOptions, mountOptions(hardDisk({ bus: 'sata' }))); + const tuned = mount(DiskPerformanceOptions, mountOptions(hardDisk({ bus: 'sata', cache: 'writeback' }))); + + expect(plain.find('[data-testid="disk-perf-bus-tip"]').exists()).toBe(false); + expect(tuned.find('[data-testid="disk-perf-bus-tip"]').exists()).toBe(true); + }); + + it('says in the bus tip that a dedicated I/O thread needs the virtio bus', () => { + const translations = load(fs.readFileSync(path.join(__dirname, '../../../../l10n/en-us.yaml'), 'utf8')); + const { busTip } = translations.harvester.virtualMachine.volume.performance; + + expect(busTip).toContain('{bus}'); + expect(busTip).toContain('A dedicated I/O thread needs the virtio bus'); + }); + + it('shows the bus tip and the Filesystem warning inside a live region that is there before they appear', async() => { + const disk = { bus: 'sata', volumeMode: 'Filesystem' }; + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk(disk))); + const status = wrapper.find('[data-testid="disk-perf-warning-status"]'); + + expect(status.attributes('role')).toBe('status'); + expect(status.find('[data-testid="disk-perf-bus-tip"]').exists()).toBe(false); + expect(filesystemWarning(wrapper)).toBeUndefined(); + + await wrapper.setProps({ value: hardDisk({ ...disk, cache: 'none' }) }); + + expect(wrapper.find('[data-testid="disk-perf-warning-status"]').element).toBe(status.element); + expect(status.find('[data-testid="disk-perf-bus-tip"]').exists()).toBe(true); + expect(status.element.contains(filesystemWarning(wrapper)?.element)).toBe(true); + }); + + it('shows the Native I/O tip inside a live region that is there before Native I/O is picked', async() => { + const value = hardDisk({ cache: 'writeback', io: 'threads' }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + const status = wrapper.find('[data-testid="disk-perf-native-status"]'); + + expect(status.attributes('role')).toBe('status'); + expect(nativeTip(wrapper)).toBeUndefined(); + + wrapper.vm.onIoChange('native'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="disk-perf-native-status"]').element).toBe(status.element); + expect(status.element.contains(nativeTip(wrapper)?.element)).toBe(true); + }); + + it('keeps the live region and the Native I/O tip when picking Native I/O switches Custom to High Performance', async() => { + const value = hardDisk({ + cache: 'writeback', io: 'threads', dedicatedIOThread: true + }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + const status = wrapper.find('[data-testid="disk-perf-native-status"]'); + + expect(wrapper.vm.profile).toBe('custom'); + + wrapper.vm.onIoChange('native'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.profile).toBe('highPerformance'); + expect(wrapper.find('[data-testid="input-disk-cache"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="disk-perf-native-status"]').element).toBe(status.element); + expect(status.element.contains(nativeTip(wrapper)?.element)).toBe(true); + }); + + it('passes the Filesystem warning as a label key so its quotes are not double-escaped', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ cache: 'none', volumeMode: 'Filesystem' }))); + const banner = filesystemWarning(wrapper); + + expect(banner).toBeDefined(); + expect(banner?.props('label')).toBeFalsy(); + }); + it('applies the High Performance preset and emits update', () => { const value = hardDisk(); const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); @@ -173,8 +278,7 @@ describe('component: DiskPerformanceOptions', () => { it('passes the Native I/O hint as a label key so its quotes are not double-escaped', () => { const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ io: 'native' }))); - const banner = wrapper.findAllComponents(Banner) - .find((b) => b.props('labelKey') === 'harvester.virtualMachine.volume.performance.ioMode.nativeRequiresNoCacheTip'); + const banner = nativeTip(wrapper); expect(banner).toBeDefined(); expect(banner?.props('label')).toBeFalsy(); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/VMPerformanceOptions.test.js b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/VMPerformanceOptions.test.js index f8c0efe1..261b3acd 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/VMPerformanceOptions.test.js +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/VMPerformanceOptions.test.js @@ -1,19 +1,19 @@ import { mount } from '@vue/test-utils'; -import { _CREATE } from '@shell/config/query-params'; +import { _CREATE, _VIEW } from '@shell/config/query-params'; import { Banner } from '@components/Banner'; import VMPerformanceOptions from '../VMPerformanceOptions.vue'; const AUTO_ENABLED_TIP = 'harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.autoEnabledTip'; const NO_VIRTIO_TIP = 'harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.noVirtioTip'; -function mountOptions(props = {}) { +function mountOptions(props = {}, { featureEnabled = true } = {}) { return mount(VMPerformanceOptions, { props: { mode: _CREATE, ...props }, global: { mocks: { $store: { getters: { - 'harvester-common/getFeatureEnabled': () => true, + 'harvester-common/getFeatureEnabled': () => featureEnabled, 'i18n/t': (key) => key, 'i18n/exists': () => true, } @@ -24,35 +24,79 @@ function mountOptions(props = {}) { }); } -async function expand(wrapper) { - await wrapper.find('.expand-toggle').trigger('click'); -} - function bannerFor(wrapper, labelKey) { return wrapper.findAllComponents(Banner).find((b) => b.props('labelKey') === labelKey); } describe('component: VMPerformanceOptions', () => { - it('should pass the I/O threads policy hint as a label key so its quotes are not double-escaped', async() => { - const wrapper = mountOptions({ hasDedicatedIothread: true }); + it('should be hidden when the highPerformanceStorage feature is disabled', () => { + const wrapper = mountOptions({}, { featureEnabled: false }); - await expand(wrapper); + expect(wrapper.find('.vm-perf').exists()).toBe(false); + }); + + it('should show the section with its title and fields, without a toggle', () => { + const wrapper = mountOptions(); + + expect(wrapper.find('h3').text()).toBe('harvester.virtualMachine.volume.diskPerformance.title'); + expect(wrapper.find('.expand-toggle').exists()).toBe(false); + expect(wrapper.find('[data-testid="input-vm-block-multi-queue"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="input-vm-iothreads-policy"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="input-vm-iothread-count"]').exists()).toBe(false); + }); + + it('should only accept whole numbers as the supplemental pool thread count', () => { + const wrapper = mountOptions({ ioThreadsPolicy: 'supplementalPool', ioThreadCount: 4 }); + const input = wrapper.find('[data-testid="input-vm-iothread-count"] input'); + + expect(input.attributes('inputmode')).toBe('numeric'); + }); + + it.each([ + [_CREATE, true], + [_VIEW, false], + ])('in %s mode, should mark the supplemental pool thread count as required: %p', (mode, required) => { + const wrapper = mountOptions({ + mode, ioThreadsPolicy: 'supplementalPool', ioThreadCount: 4 + }); + + expect(wrapper.find('[data-testid="input-vm-iothread-count"] .required').exists()).toBe(required); + }); + + it.each([ + [{}, false], + [{ blockMultiQueue: true }, true], + [{ ioThreadsPolicy: 'auto' }, true], + ])('in view mode with %p, should show the section: %p', (props, shown) => { + const wrapper = mountOptions({ ...props, mode: _VIEW }); + + expect(wrapper.find('.vm-perf').exists()).toBe(shown); + }); + + it('should clear Block Multi-Queue when the last virtio disk goes away', async() => { + const wrapper = mountOptions({ blockMultiQueue: true, hasVirtioDisk: true }); + + await wrapper.setProps({ hasVirtioDisk: false }); + + expect(wrapper.emitted('update:blockMultiQueue')).toStrictEqual([[false]]); + }); + + it('should pass the I/O threads policy hint as a label key so its quotes are not double-escaped', () => { + const wrapper = mountOptions({ hasDedicatedIothread: true }); const banner = bannerFor(wrapper, AUTO_ENABLED_TIP); expect(banner).toBeDefined(); expect(banner.props('label')).toBeFalsy(); }); - it('should not show the I/O threads policy hint when a policy is set', async() => { + it('should not show the I/O threads policy hint when a policy is set', () => { const wrapper = mountOptions({ hasDedicatedIothread: true, ioThreadsPolicy: 'shared' }); expect(bannerFor(wrapper, AUTO_ENABLED_TIP)).toBeUndefined(); }); - it('should pass the no virtio disk hint as a label key', async() => { + it('should pass the no virtio disk hint as a label key', () => { const wrapper = mountOptions({ hasVirtioDisk: false }); - - await expand(wrapper); const banner = bannerFor(wrapper, NO_VIRTIO_TIP); expect(banner).toBeDefined(); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue index 8190f906..4124c0fb 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue @@ -7,7 +7,6 @@ import UnitInput from '@shell/components/form/UnitInput'; import { LabeledInput } from '@components/Form/LabeledInput'; import LabeledSelect from '@shell/components/form/LabeledSelect'; import ModalWithCard from '@shell/components/ModalWithCard'; -import VMPerformanceOptions from './VMPerformanceOptions'; import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map'; import { PVC, STORAGE_CLASS } from '@shell/config/types'; import { clone } from '@shell/utils/object'; @@ -24,10 +23,10 @@ import { EMPTY_IMAGE } from '../../../utils/vm'; const { READ_WRITE_MANY } = ACCESS_MODE; export default { - emits: ['update:value', 'update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'], + emits: ['update:value'], components: { - Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard, VMPerformanceOptions + Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard }, props: { @@ -78,22 +77,6 @@ export default { resourceType: { type: String, default: '' - }, - - // KubeVirt high-performance VM-level features - blockMultiQueue: { - type: Boolean, - default: false - }, - - ioThreadsPolicy: { - type: String, - default: '' - }, - - ioThreadCount: { - type: Number, - default: 2 } }, @@ -164,14 +147,6 @@ export default { isLHV2VolExpansionFeatureEnabled() { return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion'); }, - - hasVirtioDisk() { - return this.rows.some((R) => R.type === 'disk' && R.bus === 'virtio'); - }, - - hasDedicatedIOThread() { - return this.rows.some((R) => R.dedicatedIOThread); - }, }, watch: { @@ -533,18 +508,6 @@ export default { - - { expect(wrapper.emitted('update')).toHaveLength(1); }); + + it('should not render an empty row when there is no backup to show', () => { + const wrapper = mount(HarvesterEditContainer, mountOptions()); + const emptyRows = wrapper.findAll('.row').filter((row) => row.element.children.length === 0); + + expect(emptyRows).toHaveLength(0); + }); }); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts index e627543a..1cee8229 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/__tests__/HarvesterEditVMImage.test.ts @@ -133,4 +133,30 @@ describe('component: HarvesterEditVMImage', () => { expect(wrapper.emitted('update')).toHaveLength(1); }); + + it('should not render an empty row when there is no backup to show', () => { + const wrapper = mount(HarvesterEditVMImage, { + props: { + mode: _CREATE, + isVirtualType: false, + newCreateId: '123', + validateRequired: true, + idx: 1 + }, + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'harvester/all': jest.fn(), + 'i18n/t': jest.fn(), + } + }, + } + } + }); + const emptyRows = wrapper.findAll('.row').filter((row) => row.element.children.length === 0); + + expect(emptyRows).toHaveLength(0); + }); }); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/container.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/container.vue index 830de50f..6bf86f54 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/container.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/container.vue @@ -132,11 +132,11 @@ export default { -
-
+
+
-
-
+
+
+ + R.type === 'disk' && R.bus === 'virtio'); + }, + + hasDedicatedIOThread() { + return (this.diskRows || []).some((R) => R.dedicatedIOThread); + }, + needNewSecret() { // When creating a template it is always necessary to create a new secret. return this.isCreate || this.showYaml ? false : this.resourceType === HCI.VM_VERSION;