Files
harvester-ui-extension/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue
Alejandro BonillaandClaude Opus 5.5 39ed41e527 feat: move OS type, CPU model and system options to the VM Basics tab (#1179)
* feat: move OS type, CPU model and system options to the VM Basics tab

OS Type, CPU Model and the CPU/system checkboxes (CPU pinning, USB
tablet, TPM, EFI, Secure Boot and their persistent-state options) are
core machine settings, but they were buried at the bottom of Advanced
Options. Show them on the Basics tab, next to CPU and memory.

Install Guest Agent stays in Advanced Options because it changes the
cloud config. It now sits right below the User Data editor (the data it
modifies) through a new optional slot in the Cloud Config component.

Only the placement changes; behaviour and validation are untouched.

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

* feat: follow the review on the VM Basics layout

- Basics now reads OS Type and CPU Model, then CPU and memory, then the
  system checkboxes, the CPU Manager banner and the SSH key, in the order
  proposed in review. The checkboxes sit in one wrapper; USB Tablet drops
  its extra mt-20 and uses the plain `check` class like the others.
- Advanced Options puts Maintenance Strategy on the same row as Run
  Strategy; Reserved Memory moves to its own row.
- The moved labels leave harvester.virtualMachine.advancedOptions: TPM,
  EFI, Secure Boot and the persistent-state labels are now under
  harvester.virtualMachine.systemOptions (also used by the template
  version editor), and the CPU Manager banner text is under
  harvester.virtualMachine.cpuPinning.cpuManager.

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

* feat: match the VM template form and address review on the VM Basics layout

The VM template form still had OS Type, CPU Model and the CPU/system
checkboxes under Advanced Options, so creating a VM and creating a
template put them in different places. Use the same layout as the VM
form:

- Basics: OS Type and CPU Model, then CPU and memory, then the CPU
  Pinning, USB Tablet, TPM, EFI and Secure Boot checkboxes (plus the
  persistent-state options when they apply), then the SSH key.
- Advanced Options: Run Strategy and Maintenance Strategy on one row,
  Reserved Memory on its own row, then Show more and Cloud Config.
  Install Guest Agent sits right below the User Data editor through the
  after-user-data slot.

Review follow-ups on both forms:

- OS Type shows an info icon in create mode warning that selecting an
  image with an OS label on the Volumes tab may change the OS Type
  selection, now that OS Type comes before the volumes.
- The CPU Manager banner under CPU Pinning is info in create mode, where
  it only explains why the checkbox is disabled, and stays a warning in
  edit mode, where it is about the restart.

Only placement and presentation change; behaviour and validation are
untouched.

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

---------

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:38:16 +08:00

604 lines
17 KiB
Vue

<script>
import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { set } from '@shell/utils/object';
import { Banner } from '@components/Banner';
import KeyValue from '@shell/components/form/KeyValue';
import NodeScheduling from '@shell/components/form/NodeScheduling';
import PodAffinity from '@shell/components/form/PodAffinity';
import UnitInput from '@shell/components/form/UnitInput';
import { randomStr } from '@shell/utils/string';
import { _CONFIG, _EDIT, _VIEW } from '@shell/config/query-params';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view';
import { AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
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 Network from './kubevirt.io.virtualmachine/VirtualMachineNetwork';
import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem';
export default {
name: 'HarvesterEditVMTemplate',
emits: ['update:templateValue'],
components: {
Tab,
SSHKey,
Volume,
Tabbed,
Network,
Checkbox,
CpuMemory,
CpuModel,
CruResource,
CloudConfig,
LabeledSelect,
NameNsDescription,
NodeScheduling,
PodAffinity,
Reserved,
UnitInput,
Banner,
KeyValue,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
},
data() {
if (this.mode === _EDIT) {
this.value.cleanForNew();
}
const templateId = this.value.templateId || this.$route.query.templateId;
return {
templateId,
templateValue: null,
templateSpec: null,
versionName: '',
description: '',
defaultVersion: null,
isDefaultVersion: false,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
isConfig() {
return this.$route.query?.as === _CONFIG || this.isView;
},
realTemplateMode() {
return this.templateId ? _VIEW : this.mode;
},
secretNamePrefix() {
return this.templateValue?.metadata?.name;
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
templateId: {
async handler(neu) {
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
let templateValue = templates.find( (V) => V.id === neu) || null;
let templateSpec = templateValue?.spec;
if (!templateValue) {
templateSpec = {
description: '',
defaultVersionId: ''
};
templateValue = await this.$store.dispatch('harvester/create', {
metadata: {
name: '',
namespace: ''
},
spec: templateSpec,
type: HCI.VM_TEMPLATE
});
}
this.templateValue = templateValue;
this.templateSpec = templateSpec;
},
immediate: true
}
},
created() {
this.registerAfterHook(async() => {
if (this.isDefaultVersion) {
// Set the default version according to annotation:[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME]
const versions = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_VERSION, opt: { force: true } });
const version = versions.find( (V) => V?.metadata?.annotations?.[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME] === this.customName);
if (version) {
try {
this.templateValue.defaultVersionId = version.id;
const data = [{
op: 'replace', path: '/spec/defaultVersionId', value: version.id
}];
await this.templateValue.patch( data, { url: this.templateValue.linkFor('view') });
} catch (err) {
return Promise.reject(new Error(err.message));
}
}
}
});
},
mounted() {
this.imageId = this.diskRows[0]?.image || '';
this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm);
},
methods: {
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;
}
},
async saveVMT(buttonCb) {
this.parseVM();
const templates = await this.$store.dispatch('harvester/findAll', { type: HCI.VM_TEMPLATE });
const template = templates.find( (O) => O.metadata.name === this.templateValue.metadata.name);
try {
if (!this.templateId) {
if (this.templateValue?.metadata?.name) {
await this.templateValue.save();
} else {
this.errors = [this.t('validation.required', { key: this.t('harvester.vmTemplate.nameNsDescription.name') })];
buttonCb(false);
return;
}
} else {
template.save();
}
this.value.cleanForNew();
this.customName = randomStr(10);
this.value.metadata['annotations'] = {
...this.value.metadata.annotations,
[HCI_ANNOTATIONS.TEMPLATE_VERSION_CUSTOM_NAME]: this.customName
};
const name = this.templateValue.metadata.name || template.metadata.name;
const namespace = this.templateValue.metadata.namespace || template.metadata.namespace;
if (this.isCreate) {
this.value.metadata.namespace = namespace;
}
this.value.spec['templateId'] = `${ namespace }/${ name }`;
// inherit labels and annotations so the VM gets them when created from the template
this.value.spec.vm.metadata.labels = {
...this.value.spec.vm.metadata.labels,
...this.value.metadata.labels
};
this.value.spec.vm.metadata.annotations = {
...this.value.spec.vm.metadata.annotations,
...this.value.metadata.annotations
};
const res = await this.value.save();
await this.saveSecret(res);
await this.applyHooks(AFTER_SAVE_HOOKS);
this.done();
} catch (e) {
this.errors = [e];
buttonCb(false);
}
},
onTabChanged({ tab }) {
if (tab.name === 'advanced') {
this.$refs.yamlEditor?.refresh();
}
},
},
};
</script>
<template>
<CruResource
v-if="templateSpec && spec"
:done-route="doneRoute"
:resource="value"
:can-yaml="false"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="saveVMT"
@error="e=>errors=e"
>
<NameNsDescription
:value="templateValue"
:mode="realTemplateMode"
name-label="harvester.vmTemplate.nameNsDescription.name"
:namespaced="true"
@update:value="$emit('update:templateValue', $event)"
/>
<Checkbox
v-if="templateId"
v-model:value="isDefaultVersion"
class="mb-20"
:label="t('tableHeaders.defaultVersion')"
type="checkbox"
:mode="mode"
/>
<Tabbed
:side-tabs="true"
@changed="onTabChanged"
>
<Tab
name="Basics"
:label="t('harvester.vmTemplate.tabs.basics')"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="osType"
label-key="harvester.virtualMachine.osType"
:mode="mode"
:options="OS"
:tooltip="isCreate ? t('harvester.virtualMachine.osTypeTooltip') : null"
/>
</div>
<div class="col span-6">
<CpuModel
:value="spec.template.spec.domain.cpu?.model || ''"
:mode="mode"
@update:value="updateCpuModel"
/>
</div>
</div>
<CpuMemory
class="mb-20"
:cpu="cpu"
:memory="memory"
:max-cpu="maxCpu"
:max-memory="maxMemory"
:enable-hot-plug="cpuMemoryHotplugEnabled"
:disabled="isConfig"
@updateCpuMemory="updateCpuMemory"
/>
<div class="mb-20">
<Checkbox
v-if="value.cpuPinningFeatureEnabled"
v-model:value="cpuPinning"
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"
type="checkbox"
:label="t('harvester.virtualMachine.enableUsb')"
:mode="mode"
/>
<Checkbox
v-model:value="tpmEnabled"
class="check"
type="checkbox"
label-key="harvester.virtualMachine.systemOptions.tpm"
:mode="mode"
/>
<Checkbox
v-if="value.tpmPersistentStateFeatureEnabled && tpmEnabled"
v-model:value="tpmPersistentStateEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.systemOptions.tpmPersistentState')"
:mode="mode"
/>
<Checkbox
v-model:value="efiEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.systemOptions.efiEnabled')"
:mode="mode"
/>
<Checkbox
v-if="value.efiPersistentStateFeatureEnabled && efiEnabled"
v-model:value="efiPersistentStateEnabled"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.systemOptions.efiPersistentState')"
:mode="mode"
/>
<Checkbox
v-if="efiEnabled"
v-model:value="secureBoot"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.systemOptions.secureBoot')"
:mode="mode"
/>
</div>
<div class="mb-20">
<SSHKey
v-model:value="sshKey"
:create-namespace="true"
:namespace="templateValue.metadata.namespace"
:disable-create="isView"
:mode="mode"
@update:sshKey="updateSSHKey"
/>
</div>
</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"
:namespace="value.metadata.namespace"
:existing-volume-disabled="true"
/>
</Tab>
<Tab
name="Network"
:label="t('harvester.tab.network')"
:weight="-2"
>
<Network
v-model:value="networkRows"
v-model:network-boot-first="networkBootFirst"
:mode="mode"
/>
</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="filesystemEnabled"
name="filesystem"
:label="t('harvester.tab.filesystem')"
:weight="-8"
>
<Filesystem
v-model:value="filesystemRows"
:mode="mode"
:namespace="templateValue.metadata.namespace"
/>
</Tab>
<Tab
name="labels"
:label="t('generic.labels')"
:weight="-9"
>
<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="-10"
>
<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="-11"
>
<Banner color="info">
<t k="harvester.virtualMachine.annotations.banner" />
</Banner>
<KeyValue
key="annotations"
:value="value.annotations"
:protected-keys="value.systemAnnotations || []"
:toggle-filter="true"
: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="-99"
>
<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="maintenanceStrategy"
label-key="harvester.virtualMachine.maintenanceStrategy.label"
:options="maintenanceStrategies"
:get-option-label="getMaintenanceStrategyOptionLabel"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<Reserved
:reserved-memory="reservedMemory"
:mode="mode"
@updateReserved="updateReserved"
/>
</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">
<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"
@change="updateTerminationGracePeriodSeconds"
/>
</div>
</div>
</div>
<CloudConfig
ref="yamlEditor"
:mode="mode"
:user-script="userScript"
:namespace="templateValue.metadata.namespace"
:network-script="networkScript"
@updateUserData="updateUserData"
@updateNetworkData="updateNetworkData"
>
<template #after-user-data>
<Checkbox
v-model:value="installAgent"
class="check mb-20"
type="checkbox"
label-key="harvester.virtualMachine.installAgent"
:mode="mode"
/>
</template>
</CloudConfig>
</Tab>
</Tabbed>
</CruResource>
</template>