From 840f9de73e4432e3a8e89bc51aeccdfed762bab6 Mon Sep 17 00:00:00 2001 From: Alejandro Bonilla Date: Tue, 3 Nov 2026 23:23:30 -0500 Subject: [PATCH] fix: show quotes in high-performance storage hints and keep Custom profile (#1204) * fix: show quotes in high-performance storage hints and keep Custom profile The Native I/O and I/O threads policy hints were passed to Banner as :label="t(...)". t() escapes HTML and Banner escapes the label again, so the quotes showed up as ". Use label-key instead, which renders the string once. Picking the Custom performance profile and then choosing options that match the Default or High Performance preset flipped the selector back to that preset. Keep an explicit Custom choice and only infer the profile from the disk values otherwise. Related: harvester/harvester#11550 Signed-off-by: Alejandro Bonilla * fix: only keep Custom profile when the user picked it syncProfile() skipped re-detection whenever the profile was Custom, including a Custom that was inferred from the disk values. In an existing-volume row, switching from a volume with a WriteBack override to one with High Performance values left the selector on Custom. Track the user's explicit Custom choice in its own flag and use that in the guard, so an inferred Custom still follows the disk values. Related: harvester/harvester#11550 Signed-off-by: Alejandro Bonilla * test: cover the label-key hints of VMPerformanceOptions Check that the I/O threads policy and no virtio disk hints are passed to Banner as label keys, so their quotes are not escaped twice, and that the I/O threads policy hint is hidden once a policy is set. Signed-off-by: Alejandro Bonilla --------- Signed-off-by: Alejandro Bonilla --- .../DiskPerformanceOptions.vue | 27 +++++--- .../VMPerformanceOptions.vue | 4 +- .../__tests__/DiskPerformanceOptions.test.ts | 57 +++++++++++++++++ .../__tests__/VMPerformanceOptions.test.js | 61 +++++++++++++++++++ 4 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/VMPerformanceOptions.test.js diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions.vue index 7abbf961..c37c693a 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions.vue @@ -40,6 +40,8 @@ export default { // Bus the disk used before the "High Performance" preset switched it to // virtio, so returning to "Default" can put it back. busBeforeHighProfile: null, + // True only while the user has explicitly picked "Custom" in the selector. + userSelectedCustom: false, }; }, @@ -103,13 +105,13 @@ export default { watch: { // Keep the profile selector in sync if the row is repopulated (e.g. editing an existing VM). 'value.cache'() { - this.profile = this.detectProfile(); + this.syncProfile(); }, 'value.io'() { - this.profile = this.detectProfile(); + this.syncProfile(); }, 'value.dedicatedIOThread'() { - this.profile = this.detectProfile(); + this.syncProfile(); }, }, @@ -128,6 +130,16 @@ export default { return CUSTOM; }, + syncProfile() { + // An explicit "Custom" choice sticks, even if the options happen to + // match the Default or High Performance preset. An inferred "Custom" + // still follows the disk values. + if (this.userSelectedCustom) { + return; + } + this.profile = this.detectProfile(); + }, + cacheLabel(value) { if (value === '') { return this.t('harvester.virtualMachine.volume.performance.cacheMode.default'); @@ -140,6 +152,7 @@ export default { const previous = this.profile; this.profile = profile; + this.userSelectedCustom = profile === CUSTOM; if (profile === DEFAULT) { this.value.cache = ''; @@ -231,7 +244,7 @@ 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 08491e4b..29de9c19 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue @@ -135,7 +135,7 @@ export default {
@@ -171,7 +171,7 @@ export default { 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 59f20006..9e659173 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,5 +1,7 @@ import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { _EDIT } from '@shell/config/query-params'; +import { Banner } from '@components/Banner'; import DiskPerformanceOptions from '../DiskPerformanceOptions.vue'; const mountOptions = (value, { featureEnabled = true } = {}) => ({ @@ -122,4 +124,59 @@ describe('component: DiskPerformanceOptions', () => { expect(disabledByValue['writeback']).toBe(true); expect(disabledByValue['writethrough']).toBe(true); }); + + it.each([ + ['Default', { + cache: '', io: '', dedicatedIOThread: false + }], + ['High Performance', { + cache: 'none', io: 'native', dedicatedIOThread: true + }], + ])('stays on Custom when the options match the %s preset', async(_label, opts) => { + const value = hardDisk({ cache: 'writeback' }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onProfileChange('custom'); + await wrapper.setProps({ value: { ...value, ...opts } }); + await nextTick(); + + expect(wrapper.vm.profile).toBe('custom'); + }); + + it('detects the profile from an existing disk', async() => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk())); + + expect(wrapper.vm.profile).toBe('default'); + + await wrapper.setProps({ + value: hardDisk({ + cache: 'none', io: 'native', dedicatedIOThread: true + }) + }); + + expect(wrapper.vm.profile).toBe('highPerformance'); + }); + + it('updates an automatically detected Custom profile when preset values are loaded', async() => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ cache: 'writeback' }))); + + expect(wrapper.vm.profile).toBe('custom'); + + await wrapper.setProps({ + value: hardDisk({ + cache: 'none', io: 'native', dedicatedIOThread: true + }) + }); + + expect(wrapper.vm.profile).toBe('highPerformance'); + }); + + 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'); + + 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 new file mode 100644 index 00000000..f8c0efe1 --- /dev/null +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/VMPerformanceOptions.test.js @@ -0,0 +1,61 @@ +import { mount } from '@vue/test-utils'; +import { _CREATE } 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 = {}) { + return mount(VMPerformanceOptions, { + props: { mode: _CREATE, ...props }, + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => true, + 'i18n/t': (key) => key, + 'i18n/exists': () => true, + } + }, + t: (key) => key, + }, + }, + }); +} + +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 }); + + await expand(wrapper); + 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() => { + 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() => { + const wrapper = mountOptions({ hasVirtioDisk: false }); + + await expand(wrapper); + const banner = bannerFor(wrapper, NO_VIRTIO_TIP); + + expect(banner).toBeDefined(); + expect(banner.props('label')).toBeFalsy(); + }); +});