Files
harvester-ui-extension/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue
T
Alejandro BonillaandClaude 528255dea4 feat: expose KubeVirt high-performance storage features in VM and Volume UI (#1133)
* feat: expose KubeVirt high-performance storage features in VM and Volume UI

Adds UI to configure KubeVirt high-performance disk/storage settings that
were previously only reachable via VMBuilder/Terraform:

- Per-volume "Storage Performance Options": a performance profile
  (Default / High Performance / Custom) exposing per-disk cache, io and
  dedicatedIOThread on all disk types (VM image/root, new, existing,
  container) and on the standalone Volume form.
- VM-wide "High Performance (I/O Threads and Multi-Queue)": blockMultiQueue
  and ioThreadsPolicy (+ supplemental-pool thread count), shown only in the
  VM section since these are domain-level and cannot be set per-PVC.
- Compatibility guardrails: io=native forces cache=none; cache=none on
  Filesystem volumeMode warns; blockMultiQueue requires a virtio disk.

Ref: harvester/harvester#11550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix: address review feedback on high-performance storage UI

Resolves the Copilot review comments on #1133:

- Gate the whole feature behind a new `highPerformanceStorage` release
  feature flag (v1.9.0) so older supported clusters never render the
  controls or receive unsupported KubeVirt fields.
- Force Cache Mode to "none" whenever Native I/O is selected, including
  from the "Default" ('') cache value, and disable every non-"none" cache
  option (Default included) while Native is active. Previously the empty
  cache value slipped past the guardrail and produced an invalid combo.
- Clear an already-enabled blockMultiQueue when the last virtio disk is
  removed, so a disabled checkbox can no longer persist an invalid setting.
- Strip stale cache/io/dedicatedIOThread fields from the merged disk spec
  when a regenerated disk no longer requests them, so switching a disk back
  to "Default" (or unchecking Dedicated I/O Thread) while editing no longer
  silently preserves the previous values.
- Expose the disclosure toggle's state to assistive tech via aria-expanded
  and an expand/collapse aria-label.
- Add unit tests for the profile transitions and the Native/cache guardrails.

Ref: harvester/harvester#11550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix: address second-round review on high-performance storage UI

Resolves the two Copilot findings on #1133 and votdev's UX feedback on
harvester/harvester#11550:

- Restore the disk's original bus when the performance profile goes back
  to "Default". The "High Performance" preset switches the bus to virtio,
  but leaving the preset only cleared cache/io/dedicatedIOThread, so a
  SATA/SCSI disk was silently left on virtio — a bus change that can make
  an existing guest unbootable. The pre-preset bus is now remembered and
  put back, unless the user picked a different bus themselves in the
  meantime.
- Pass the VM-wide performance values from the VM detail page to the
  shared Volume component. The mixin already parsed them via
  getInitConfig(), but the detail caller never forwarded them, so
  VMPerformanceOptions fell back to its prop defaults and hid itself in
  view mode — configured blockMultiQueue / ioThreadsPolicy settings were
  invisible. They are also refreshed in the value watcher so the panel
  does not go stale.
- Make the VM-wide "High Performance" panel expandable/collapsible,
  matching the per-volume "Storage Performance Options" disclosure. It
  stays collapsed unless the VM already has something configured, so the
  average user is not faced with specialist tuning controls by default.
- Fix the unit tests to use Vue Test Utils v2 mount options. They used
  the v1 top-level `mocks` key, which VTU 2.x ignores, so every case
  failed on mount. The repo has no jest config or test script, so this
  was not caught. Add coverage for the bus save/restore behaviour.

Ref: harvester/harvester#11550

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

---------

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 17:48:18 +08:00

1207 lines
34 KiB
Vue

<script>
import { isEqual } from 'lodash';
import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed';
import { clone, set } from '@shell/utils/object';
import Tab from '@shell/components/Tabbed/Tab';
import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource';
import { RadioGroup } from '@components/Form/Radio';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import UnitInput from '@shell/components/form/UnitInput';
import NodeScheduling from '@shell/components/form/NodeScheduling';
import PodAffinity from '@shell/components/form/PodAffinity';
import VGpuDevices from './VirtualMachineVGpuDevices/index';
import UsbDevices from './VirtualMachineUSBDevices/index';
import KeyValue from '@shell/components/form/KeyValue';
import { clear } from '@shell/utils/array';
import { saferDump } from '@shell/utils/create-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
import CreateEditView from '@shell/mixins/create-edit-view';
import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
import VM_MIXIN from '../../mixins/harvester-vm';
import { HCI } from '../../types';
import RestartVMDialog from '../../dialog/RestartVMDialog';
import PciDevices from './VirtualMachinePciDevices/index';
import AccessCredentials from './VirtualMachineAccessCredentials';
import CloudConfig from './VirtualMachineCloudConfig';
import WindowsSysprep from './VirtualMachineWindowsSysprep';
import CpuMemory from './VirtualMachineCpuMemory';
import CpuModel from './VirtualMachineCpuModel';
import Network from './VirtualMachineNetwork';
import Volume from './VirtualMachineVolume';
import SSHKey from './VirtualMachineSSHKey';
import Reserved from './VirtualMachineReserved';
import Filesystem from './VirtualMachineFilesystem';
import { Banner } from '@components/Banner';
import MessageLink from '@shell/components/MessageLink';
export default {
name: 'HarvesterEditVM',
emits: ['update:value'],
components: {
Tab,
Tabbed,
Checkbox,
RadioGroup,
CruResource,
LabeledInput,
LabeledSelect,
NameNsDescription,
Volume,
SSHKey,
Network,
CpuMemory,
CpuModel,
CloudConfig,
WindowsSysprep,
NodeScheduling,
PodAffinity,
AccessCredentials,
Reserved,
PciDevices,
RestartVMDialog,
UnitInput,
VGpuDevices,
KeyValue,
Banner,
MessageLink,
UsbDevices,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
},
data() {
const cloneVM = clone(this.value);
const isRestartImmediately = this.value.actualState === 'Running';
const hostname = this.value.spec.template.spec.hostname || '';
// Display name can contain arbitrary strings. There is no XSS risk because the value is
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
// this field. See harvester/harvester#10423 for details.
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return {
cloneVM,
count: 2,
templateId: '',
templateVersionId: '',
namePrefix: '',
isSingle: true,
isOpen: false,
hostname,
isRestartImmediately,
customizeDisplayName,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
// VM display name is stored as an annotation; bind a dedicated input to it
// so we don't have to mutate metadata.name (which would break the k8s PUT).
displayName: {
get() {
return this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME] || '';
},
set(val) {
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, val);
},
},
to() {
return {
name: 'harvester-c-cluster-resource',
params: { cluster: this.$store.getters['clusterId'], resource: HCI.HOST },
};
},
machineTypeOptions() {
return this.machineTypes.map((type) => {
if (!type) return { label: 'None', value: '' };
return { label: type, value: type };
});
},
templateOptions() {
return this.templates.map( (T) => {
const isReady = this.hasAvailableVersion(T.id);
return {
label: T.id,
value: T.id,
disabled: !isReady
};
});
},
versionOptions() {
const defaultVersion = this.curTemplateResource?.defaultVersion;
return this.versions.filter( (O) => O.templateId === this.templateId).map( (T) => {
const version = T.version;
const label = defaultVersion === version ? `${ version } (${ this.t('generic.default') })` : version;
const value = T.id;
return {
label,
value,
disabled: !T.isReady
};
});
},
curTemplateResource() {
return this.templates.find( (O) => O.id === this.templateId);
},
nameLabel() {
return this.isSingle ? 'harvester.virtualMachine.instance.single.nameLabel' : 'harvester.virtualMachine.instance.multiple.nameLabel';
},
hostnameLabel() {
return this.isSingle ? 'harvester.virtualMachine.instance.single.host.label' : 'harvester.virtualMachine.instance.multiple.host.label';
},
hostPlaceholder() {
return this.isSingle ? this.t('harvester.virtualMachine.instance.single.host.placeholder') : this.t('harvester.virtualMachine.instance.multiple.host.placeholder');
},
secretNamePrefix() {
return this.value?.metadata?.name;
},
isQemuInstalled() {
return this.value.isQemuInstalled;
},
hasRestartAction() {
return this.value.hasAction('restart');
},
hasStartAction() {
return this.value.hasAction('start');
},
enableCpuPinningCheckbox() {
if (this.mode === 'create') {
return this.nodes.some((node) => node.isCPUManagerEnabled); // any one of nodes has label cpuManager=true
}
return true;
},
isValidationPassed() {
// check if any disk hasDiskError is true
const hasError = this.diskRows.some((disk) => disk.hasDiskError === true);
return !hasError;
},
showCpuPinningBanner() {
if (!this.value.cpuPinningFeatureEnabled) {
return false;
}
if (this.mode === 'edit') {
return this.cpuPinning !== !!this.cloneVM.spec.template.spec.domain.cpu.dedicatedCpuPlacement;
}
if (this.mode === 'create') {
return this.nodes.every((node) => !node.isCPUManagerEnabled); // no node enabled CPU manager
}
return false;
},
vGPUAsPCIDeviceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('vGPUAsPCIDevice');
},
usbPassthroughEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough');
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
templateId: {
async handler(id, old) {
if (!id) {
return;
}
if (id !== old && !this.templateVersionId) {
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
this.templateVersionId = templates.find( (O) => O.id === id)?.spec?.defaultVersionId;
}
},
immediate: false
},
templateVersionId: {
async handler(id) {
if (!id) {
return;
}
const versions = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_VERSION });
const curVersion = versions.find( (V) => V.id === id);
if (!curVersion?.spec?.vm) return;
const cloneVersionVM = clone(curVersion.spec.vm);
delete cloneVersionVM.spec?.template?.spec?.accessCredentials;
delete cloneVersionVM.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_NAMES];
delete cloneVersionVM.spec?.template?.metadata?.annotations?.[HCI_ANNOTATIONS.DYNAMIC_SSHKEYS_USERS];
const claimTemplate = parseVolumeClaimTemplates(cloneVersionVM);
const deleteDataSource = claimTemplate.map((volume) => {
if (volume?.spec?.dataSource) {
delete volume.spec.dataSource;
}
return volume;
});
delete cloneVersionVM.metadata.annotations[HCI_ANNOTATIONS.MAC_ADDRESS];
cloneVersionVM.metadata.annotations[HCI_ANNOTATIONS.VOLUME_CLAIM_TEMPLATE] = JSON.stringify(deleteDataSource);
// Update instance labels, labels and annotations
this.value.spec.template.metadata.labels = cloneVersionVM.spec.template.metadata.labels || {};
this.value.metadata.labels = {
...(curVersion.metadata?.labels || {}),
...(cloneVersionVM.metadata?.labels || {})
};
this.value.metadata.annotations = {
...(curVersion.metadata?.annotations || {}),
...(cloneVersionVM.metadata?.annotations || {})
};
this.getInitConfig({
value: cloneVersionVM, existUserData: true, fromTemplate: true
});
this['hasCreateVolumes'] = []; // When using the template, all volume names need to be newly created
}
},
useTemplate(neu) {
if (neu === false) {
this.templateId = '';
this.templateVersionId = '';
this.value.applyDefaults();
this.getInitConfig({ value: this.value, init: this.isCreate });
}
},
customizeDisplayName(neu) {
if (!neu) {
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, '');
}
},
},
created() {
this.registerAfterHook(async() => {
await this.restartVM();
const id = `${ this.value.metadata.namespace }/${ this.value.metadata.name }`;
const res = this.$store.getters['harvester/byId'](HCI.VM, id);
try {
await this.saveSecret(res);
await this.saveAccessCredentials(res);
if (this.isWindows) {
await this.saveSysprepConfig(res);
}
} catch (e) {
this.errors.push(...exceptionToErrorsArray(e));
}
if (!this.errors.length && this.isSingle) {
this.done();
}
});
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
mounted() {
this.imageId = this.$route.query?.image || this.imageId;
const diskRows = this.getDiskRows(this.value);
this['diskRows'] = diskRows;
this['filesystemRows'] = this.getFilesystemRows(this.value);
const templateId = this.$route.query.templateId;
const templateVersionId = this.$route.query.versionId;
if (templateId && templateVersionId) {
this.templateId = templateId;
this.templateVersionId = templateVersionId;
this.useTemplate = true;
}
},
methods: {
cancelAction() {
const { fromPage = HCI.VM } = this.$route?.query; // default back to VM list page
const cancelOverride = {
name: this.doneRoute,
params: { resource: fromPage }
};
this.$router.replace(cancelOverride);
},
saveVM(buttonCb) {
clear(this.errors);
this.validateCPUMemory();
this.validateWindowsSysprep();
// block create VM flow if has validation errors
if (this.errors.length) {
buttonCb(false);
return;
}
if (this.isSingle) {
this.saveSingle(buttonCb);
} else {
this.saveMultiple(buttonCb);
}
},
validateCPUMemory() {
const { cpu, memory } = this;
if ((!cpu)) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.cpu') }, true));
}
if ((!memory)) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.memory') }, true));
}
},
validateWindowsSysprep() {
if (!this.isWindows) {
return;
}
if (this.sysprep?.secretName?.trim?.() && !this.sysprep?.xmlContent?.trim?.()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
return;
}
const sysprepValidationError = this.$refs.sysprepConfig?.xmlContentValidationError;
if (sysprepValidationError) {
this.errors.push(sysprepValidationError);
}
},
async saveSingle(buttonCb) {
this.parseVM();
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
await this._save(this.value, buttonCb);
if (!this.errors.length) {
buttonCb(true);
} else {
buttonCb(false);
}
},
async saveMultiple(buttonCb) {
this.namePrefix = this.value.metadata.name || '';
const join = this.namePrefix.endsWith('-') ? '' : '-';
const baseHostname = this.hostname ? this.hostname : this.namePrefix;
if (this.count < 1) {
this.errors = [this.t('harvester.virtualMachine.instance.multiple.countTip')];
buttonCb(false);
return;
}
if (!this.value.metadata.name) {
this.errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
buttonCb(false);
return;
}
const cloneValue = clone(this.value);
const cloneSpec = clone(this.spec);
for (let i = 1; i <= this.count; i++) {
this.value['spec'] = cloneValue.spec;
this['spec'] = cloneSpec;
const suffix = i < 10 ? `0${ i }` : i;
this.value.cleanForNew();
this.value.metadata.name = `${ this.namePrefix }${ join }${ suffix }`;
this.value.spec.template.spec['hostname'] = `${ baseHostname }${ join }${ suffix }`;
this.secretName = '';
await this.parseVM();
const basicValue = await this.$store.dispatch('harvester/clone', { resource: this.value });
await this._save(basicValue, buttonCb);
if (i === this.count && !this.errors.length) {
buttonCb(true);
this.done();
} else if (i === this.count) {
this.value.metadata.name = this.namePrefix;
buttonCb(false);
}
}
},
async _save(value, buttonCb) {
try {
await this.applyHooks(BEFORE_SAVE_HOOKS);
await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS);
} catch (e) {
// Reset the generated secret name(s) so a retried create (e.g. after correcting an
// invalid/duplicate VM name) always regenerates them from the current name instead of
// reusing names derived from this failed attempt. Only do this for create: in edit mode
// secretName/sysprep.secretName may reference an existing secret loaded from the VM,
// which must not be discarded. See harvester/harvester#11174.
if (this.isCreate) {
this.secretName = '';
this.secretNamePrefixUsed = '';
if (this.sysprepSecretNamePrefixUsed !== '') {
this.sysprep.secretName = '';
this.sysprepSecretNamePrefixUsed = '';
}
}
this.errors.push(...exceptionToErrorsArray(e));
buttonCb(false);
}
},
_compareDisksIgnoreStorage(disksA = [], disksB = []) {
if (disksA.length !== disksB.length) return false;
const sortedA = [...disksA].sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
const sortedB = [...disksB].sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
for (let i = 0; i < sortedA.length; i++) {
const cloneA = clone(sortedA[i]);
const cloneB = clone(sortedB[i]);
// remove the storage field before comparison
delete cloneA?.spec?.resources?.requests?.storage;
delete cloneB?.spec?.resources?.requests?.storage;
if (!isEqual(cloneA, cloneB)) return false;
}
return true;
},
_shouldPromptRestart(oldVM, newVM) {
const specChanged = !isEqual(oldVM?.spec, newVM?.spec);
// check if the disk defined in annotations has changed, ignoring storage size
const oldDisks = parseVolumeClaimTemplates(oldVM);
const newDisks = parseVolumeClaimTemplates(newVM);
const diskChanged = !this._compareDisksIgnoreStorage(oldDisks, newDisks);
return specChanged || diskChanged;
},
restartVM() {
if (this.mode !== 'edit' || !this.value.isRunning) return;
const needsRestart = this._shouldPromptRestart(this.cloneVM, this.value);
// if no restart is needed (either no changes or only size changed)
if (!needsRestart) return;
return new Promise((resolve) => {
this.isOpen = true;
this.$nextTick(() => {
if (this?.$refs?.restartDialog) {
this.$refs.restartDialog.resolve = resolve;
} else {
return resolve();
}
});
});
},
updateBeforeSave() {
if (this.isSingle) {
if (!this.value.spec.template.spec.hostname) {
this.value.spec.template.spec['hostname'] = this.value.metadata.name;
this.spec.template.spec['hostname'] = this.value.metadata.name;
}
}
const errors = this.getAccessCredentialsValidation();
if (errors.length > 0) {
return Promise.reject(errors);
} else {
return Promise.resolve();
}
},
validateCount(count) {
if (count > 10) {
this['count'] = 10;
}
},
updateTemplateId() {
this.templateVersionId = '';
},
onTabChanged({ tab }) {
if (tab.name === 'advanced') {
this.$refs.yamlEditor?.refresh();
this.$refs.sysprepConfig?.refresh();
}
},
hasAvailableVersion(templateId) {
let hasAvailableVersion = false;
this.versions.filter( (O) => O.templateId === templateId).find( (T) => {
if (T.isReady) {
hasAvailableVersion = true;
}
});
return hasAvailableVersion;
},
generateYaml() {
this.showYaml = this?.$refs?.vmCruResource?.showYaml || false;
this.parseVM();
const out = saferDump(this.value);
return out;
},
updateCpuModel(value) {
if (!this.spec?.template?.spec?.domain?.cpu) {
set(this.spec, 'template.spec.domain.cpu', {});
}
if (value && value !== '') {
set(this.spec.template.spec.domain.cpu, 'model', value);
} else {
delete this.spec.template.spec.domain.cpu.model;
}
},
},
};
</script>
<template>
<CruResource
v-if="spec"
id="vm"
ref="vmCruResource"
:done-route="doneRoute"
:resource="value"
:cancel-event="true"
:mode="mode"
:validation-passed="isValidationPassed"
:can-yaml="isSingle ? true : false"
:errors="errors"
:generate-yaml="generateYaml"
:apply-hooks="applyHooks"
@finish="saveVM"
@cancel="cancelAction"
@error="e=>errors=e"
>
<RadioGroup
v-if="isCreate"
v-model:value="isSingle"
class="mb-20 vm-radio-group"
name="createInstanceMode"
:options="[true,false]"
:labels="[t('harvester.virtualMachine.instance.single.label'), t('harvester.virtualMachine.instance.multiple.label')]"
/>
<NameNsDescription
:value="value"
:mode="mode"
:has-extra="!isSingle"
:name-label="nameLabel"
:namespaced="true"
:name-placeholder="isSingle ? 'nameNsDescription.name.placeholder' : 'harvester.virtualMachine.instance.multiple.nameNsDescription'"
:extra-columns="isSingle ? [] :['type']"
@update:value="$emit('update:value', $event)"
>
<template #type>
<LabeledInput
v-if="!isSingle"
v-model:value.number="count"
:min="2"
type="number"
:label="t('harvester.virtualMachine.instance.multiple.count')"
required
@update:value="validateCount"
/>
</template>
</NameNsDescription>
<div v-if="isSingle">
<div class="row mb-20">
<div class="col span-12">
<Checkbox
v-model:value="customizeDisplayName"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.input.customizeDisplayName')"
:mode="mode"
/>
</div>
</div>
<div
v-if="customizeDisplayName"
class="row mb-20"
>
<div class="col span-6">
<LabeledInput
v-model:value="displayName"
:mode="mode"
:label="t('harvester.virtualMachine.input.displayName')"
:placeholder="t('harvester.virtualMachine.input.displayNamePlaceholder')"
/>
</div>
</div>
</div>
<Checkbox
v-if="isCreate"
v-model:value="useTemplate"
class="check mb-20"
type="checkbox"
label-key="harvester.virtualMachine.useTemplate.label"
/>
<div
v-if="useTemplate"
class="row mb-20"
>
<div class="col span-6">
<LabeledSelect
v-model:value="templateId"
label-key="harvester.virtualMachine.useTemplate.template.label"
:options="templateOptions"
@update:value="updateTemplateId"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="templateVersionId"
label-key="harvester.virtualMachine.useTemplate.version.label"
:options="versionOptions"
/>
</div>
</div>
<Tabbed
:side-tabs="true"
@changed="onTabChanged"
>
<Tab
name="basics"
:label="t('harvester.virtualMachine.detail.tabs.basics')"
>
<CpuMemory
class="mb-20"
:cpu="cpu"
:max-cpu="maxCpu"
:memory="memory"
:max-memory="maxMemory"
:mode="mode"
:enable-hot-plug="cpuMemoryHotplugEnabled"
@updateCpuMemory="updateCpuMemory"
/>
<SSHKey
v-model:value="sshKey"
class="mb-20"
:create-namespace="true"
:namespace="value.metadata.namespace"
:mode="mode"
:disabled="isWindows || isEdit"
@update:sshKey="updateSSHKey"
@register-after-hook="registerAfterHook"
/>
</Tab>
<Tab
name="Volume"
:label="t('harvester.tab.volume')"
:weight="-1"
>
<Volume
v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode"
:custom-volume-mode="customVolumeMode"
:namespace="value.metadata.namespace"
:resource-type="value.type"
:vm="value"
:validate-required="true"
/>
</Tab>
<Tab
name="Network"
:label="t('harvester.tab.network')"
:weight="-2"
>
<Network
v-model:value="networkRows"
:mode="mode"
:is-single="isSingle"
/>
</Tab>
<Tab
name="nodeScheduling"
:label="t('workload.container.titles.nodeScheduling')"
:weight="-3"
>
<template #default>
<NodeScheduling
:mode="mode"
:value="spec.template.spec"
:nodes="nodesIdOptions"
/>
</template>
</Tab>
<Tab
:label="t('harvester.tab.vmScheduling')"
name="vmScheduling"
:weight="-4"
>
<template #default>
<PodAffinity
:mode="mode"
:value="spec.template.spec"
:nodes="nodes"
:all-namespaces-option-available="true"
:namespaces="filteredNamespaces"
:overwrite-labels="affinityLabels"
/>
</template>
</Tab>
<Tab
v-if="enabledPCI"
:label="t('harvester.tab.pciDevices')"
name="pciDevices"
:weight="-5"
>
<PciDevices
:mode="mode"
:value="spec.template.spec"
:vm="value"
/>
</Tab>
<Tab
v-if="enabledSriovgpu && !vGPUAsPCIDeviceEnabled"
:label="t('harvester.tab.vGpuDevices')"
name="vGpuDevices"
:weight="-6"
>
<VGpuDevices
:mode="mode"
:value="spec.template.spec"
:vm="value"
/>
</Tab>
<Tab
v-if="enabledPCI && usbPassthroughEnabled"
:label="t('harvester.tab.usbDevices')"
name="usbDevices"
:weight="-7"
>
<UsbDevices
:mode="mode"
:value="spec.template.spec"
:vm="value"
/>
</Tab>
<Tab
v-if="isEdit"
:label="t('harvester.tab.accessCredentials')"
name="accessCredentials"
:weight="-8"
>
<AccessCredentials
v-model:value="accessCredentials"
:mode="mode"
:resource-type="value"
:is-qemu-installed="isQemuInstalled"
/>
</Tab>
<Tab
v-if="filesystemEnabled"
name="filesystem"
:label="t('harvester.tab.filesystem')"
:weight="-9"
>
<Filesystem
v-model:value="filesystemRows"
:mode="isCreate ? mode : 'view'"
:namespace="value.metadata.namespace"
/>
</Tab>
<Tab
name="labels"
:label="t('generic.labels')"
:weight="-10"
>
<Banner color="info">
<t k="harvester.virtualMachine.labels.banner" />
</Banner>
<KeyValue
key="labels"
:value="value.labels"
:add-label="t('labels.addLabel')"
:mode="mode"
:read-allowed="false"
:value-can-be-empty="true"
@update:value="value.setLabels($event)"
/>
</Tab>
<Tab
name="instanceLabel"
:label="t('harvester.tab.instanceLabel')"
:weight="-11"
>
<Banner color="info">
<t k="harvester.virtualMachine.instanceLabels.banner" />
</Banner>
<KeyValue
key="instance-labels"
:value="value.instanceLabels"
:protected-keys="value.systemLabels || []"
:toggle-filter="toggler"
:add-label="t('labels.addLabel')"
:mode="mode"
:read-allowed="false"
:value-can-be-empty="true"
@update:value="value.setInstanceLabels($event)"
/>
</Tab>
<Tab
name="annotations"
:label="t('harvester.tab.annotations')"
:weight="-12"
>
<Banner color="info">
<t k="harvester.virtualMachine.annotations.banner" />
</Banner>
<KeyValue
key="annotations"
:value="value.annotations"
:protected-keys="value.systemAnnotations || []"
:toggle-filter="toggler"
:add-label="t('labels.addAnnotation')"
:mode="mode"
:read-allowed="false"
:value-can-be-empty="true"
@update:value="value.setAnnotations($event)"
/>
</Tab>
<Tab
name="advanced"
:label="t('harvester.tab.advanced')"
:weight="-13"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="runStrategy"
label-key="harvester.virtualMachine.runStrategy"
:options="runStrategies"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="osType"
label-key="harvester.virtualMachine.osType"
:options="OS"
:disabled="!isCreate"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="maintenanceStrategy"
label-key="harvester.virtualMachine.maintenanceStrategy.label"
:options="maintenanceStrategies"
:get-option-label="getMaintenanceStrategyOptionLabel"
:mode="mode"
/>
</div>
<div class="col span-6">
<Reserved
:reserved-memory="reservedMemory"
:mode="mode"
@updateReserved="updateReserved"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<CpuModel
v-model:value="cpuModel"
:mode="mode"
@update:value="updateCpuModel"
/>
</div>
</div>
<div class="row mb-20">
<a
v-if="showAdvanced"
v-t="'harvester.generic.showMore'"
role="button"
@click="toggleAdvanced"
/>
<a
v-else
v-t="'harvester.generic.showMore'"
role="button"
@click="toggleAdvanced"
/>
</div>
<div
v-if="showAdvanced"
class="mb-20"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="hostname"
:label-key="hostnameLabel"
:placeholder="hostPlaceholder"
:mode="mode"
/>
</div>
<div
v-if="!value.vmMachineTypeAutoFeatureEnabled"
class="col span-6"
>
<LabeledSelect
v-model:value="machineType"
label-key="harvester.virtualMachine.input.MachineType"
:options="machineTypeOptions"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<UnitInput
v-model:value="terminationGracePeriodSeconds"
:suffix="terminationGracePeriodSeconds == 1 ? 'Second' : 'Seconds'"
:label="t('harvester.virtualMachine.terminationGracePeriodSeconds.label')"
:mode="mode"
positive
@update:value="updateTerminationGracePeriodSeconds"
/>
</div>
</div>
</div>
<CloudConfig
ref="yamlEditor"
:user-script="userScript"
:mode="mode"
:os-type="osType"
:namespace="value.metadata.namespace"
:network-script="networkScript"
@updateUserData="updateUserData"
@updateNetworkData="updateNetworkData"
@updateDataTemplateId="updateDataTemplateId"
/>
<WindowsSysprep
v-if="isWindows"
ref="sysprepConfig"
v-model:value="sysprep"
:mode="mode"
:namespace="value.metadata.namespace"
/>
<Checkbox
v-if="value.cpuPinningFeatureEnabled"
v-model:value="cpuPinning"
:disabled="!enableCpuPinningCheckbox"
class="check"
type="checkbox"
tooltip-key="harvester.virtualMachine.cpuPinning.tooltip"
label-key="harvester.virtualMachine.cpuPinning.label"
:mode="mode"
/>
<Checkbox
v-model:value="installUSBTablet"
class="check mt-20"
type="checkbox"
tooltip-key="harvester.virtualMachine.usbTip"
label-key="harvester.virtualMachine.enableUsb"
:mode="mode"
/>
<Checkbox
v-model:value="installAgent"
class="check"
type="checkbox"
:disabled="isWindows"
label-key="harvester.virtualMachine.installAgent"
:mode="mode"
@update:value="updateAgent"
/>
<Checkbox
v-model:value="tpmEnabled"
class="check"
type="checkbox"
label-key="harvester.virtualMachine.advancedOptions.tpm"
:mode="mode"
/>
<Checkbox
v-if="value.tpmPersistentStateFeatureEnabled && tpmEnabled"
v-model:value="tpmPersistentStateEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.advancedOptions.tpmPersistentState')"
:mode="mode"
/>
<Checkbox
v-model:value="efiEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.advancedOptions.efiEnabled')"
:mode="mode"
/>
<Checkbox
v-if="value.efiPersistentStateFeatureEnabled && efiEnabled"
v-model:value="efiPersistentStateEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.advancedOptions.efiPersistentState')"
:mode="mode"
/>
<Checkbox
v-if="efiEnabled"
v-model:value="secureBoot"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.advancedOptions.secureBoot')"
:mode="mode"
/>
<Banner
v-if="showCpuPinningBanner"
color="warning"
>
<MessageLink
v-if="mode === 'create'"
:to="to"
prefix-label="harvester.virtualMachine.advancedOptions.cpuManager.prefix"
middle-label="harvester.virtualMachine.advancedOptions.cpuManager.middle"
suffix-label="harvester.virtualMachine.advancedOptions.cpuManager.suffix"
/>
<span v-if="mode==='edit'">
{{ t('harvester.virtualMachine.cpuPinning.restartVMMessage') }}
</span>
</Banner>
</Tab>
</Tabbed>
<RestartVMDialog
v-if="isOpen"
ref="restartDialog"
:vm="value"
/>
</CruResource>
</template>
<style lang="scss" scoped>
#vm {
:deep() .vm-radio-group .radio-group {
display: flex;
.radio-container {
margin-right: 30px;
}
}
.restart {
display: flex;
justify-content: flex-end;
}
.banner-right {
width: auto;
display: flex;
justify-items: center;
}
}
</style>