Compare commits

...
22 Commits
Author SHA1 Message Date
mergify[bot]andAndy Lee e82ba9d9f5 chore: bump version to v1.6.0-rc6 (#456) (#457)
(cherry picked from commit c289b001db)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-08-13 15:16:55 +08:00
mergify[bot]andYiya Chen f7fc001abd fix: fallback default value (#454) (#455)
(cherry picked from commit e8d63da1eb)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-08-12 13:55:37 +08:00
mergify[bot]andAndy Lee d1c676ee2c chore: update reviewer (#452) (#453)
(cherry picked from commit 4a456ac07d)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-08-05 12:41:13 +08:00
mergify[bot]andAndy Lee c562dd73ba chore: bump to 1.6.0-rc5 (#450) (#451)
(cherry picked from commit 57dfddd593)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-08-04 21:34:33 +08:00
mergify[bot]andAndy Lee 1cd5e5ca7d feat: add restore failed message in VM state description (#448) (#449)
(cherry picked from commit 66eac96575)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-08-04 14:11:58 +08:00
mergify[bot]andAndy Lee 1aaa8f71c4 fix: featureFlags is undefined when installing lower version of ui-ext (#443) (#447)
* fix: featureFlags is undefined if install lower version of ui-ext

Signed-off-by: Andy Lee <andy.lee@suse.com>

(cherry picked from commit 182d92d80b)

Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-08-01 15:41:10 +08:00
mergify[bot]andYiya Chen 003a09d0a9 fix: update cdiSettings (#444) (#445)
(cherry picked from commit 1b362f570b)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-08-01 12:15:10 +08:00
mergify[bot]andAndy Lee f08e632aad chore: bump version to v1.6.0-rc4 (#439) (#442)
* chore: bump version to v1.6.0-rc4



* fix: missing vm network migration setting



---------


(cherry picked from commit cf798048f8)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-30 16:07:47 +08:00
mergify[bot]andAndy Lee df1d7001bc fix: cpu/ memory columns in host detail VM tab (#436) (#437)
(cherry picked from commit 93c8399935)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-30 11:57:52 +08:00
mergify[bot]andAndy Lee 41f78c83a7 fix: isStopping state condition (#431) (#435)
* fix: isStopping state condition



* refactor: remove unused variable



---------


(cherry picked from commit b72f523ddf)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-29 13:02:30 +08:00
mergify[bot]andAndy Lee f1a53311ce chore: upgrade rancher/shell to 3.0.5-rc.7 (#429) (#432)
(cherry picked from commit e5ffb08df3)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-29 10:00:10 +08:00
mergify[bot]andAndy Lee e0c6e905a9 refactor: filter anchor link in setting description when searching (#428) (#430)
(cherry picked from commit 98a6322c11)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-28 18:26:19 +08:00
mergify[bot]andAndy Lee 1852e434f1 feat: add runStragety field when cloning a VM (#424) (#427)
* feat: add runStragety field when cloning a VM



* refactor: remove unneeded change



---------


(cherry picked from commit be7e4bd80b)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-28 14:31:18 +08:00
mergify[bot] 77c085d3a7 feat: expose CDI settings (#418) (#425)
* feat: add cdi settings tab



* feat: basic layout



* feat: add fields mutation



* refactor: rename keys



* refactor: add edit mode



* refactor: remove isCreate



* feat: filter volume mode options



---------

(cherry picked from commit ed2bc3100b)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-07-25 13:59:50 +08:00
mergify[bot]andYiya Chen 9dbe7d9015 fix: exception error (#422) (#423)
(cherry picked from commit b6ffb3e9f1)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-07-24 13:00:44 +08:00
mergify[bot]andAndy Lee 3f19494d98 chore: bump version to 1.6.0-rc3 (#420) (#421)
(cherry picked from commit 3d8a6bba7e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-23 15:11:41 +08:00
mergify[bot]andAndy Lee 296601d38c feat: CPU / Memory hotplug support (#413) (#419)
* feat: add maxCPU and maxMemory



* feat: add hotplug dialog



* feat: add restart message



* feat: let VM template support cpuMemoryHotplug



* feat: add feature flag



* feat: add max-hotplug-ratio setting



---------


(cherry picked from commit be9311dc0c)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-23 14:11:08 +08:00
mergify[bot]andAndy Lee 5d9ba584b8 refactor: remove unneeded translation keys (#416) (#417)
(cherry picked from commit e294f4c00f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-21 17:47:32 +08:00
eab4ba3d0f feat: Remove guest cluster in Rancher (#391) (#415)
* feat: Remove guest cluster in Rancher



* feat: add feature flag 1.6.0



* feat: update for review



* chore: fix for review



* chore: fix for review



* refactor: reduce redundant code



* chore: change text area to yaml editor



* refactor: change radio and yaml editor position



---------




(cherry picked from commit a9fa928912)

Signed-off-by: Nick Chung <nick.chung@suse.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Nick Chung <Nick.Chung@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-21 17:28:34 +08:00
mergify[bot]andYiya Chen 4e5320dc72 feat: disable vm volume resize (#409) (#412)
(cherry picked from commit ef2b4d1589)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-07-17 18:33:15 +08:00
mergify[bot]andAndy Lee ba7092f400 ci: disable subject-max-length commitlint rule (#408) (#411)
(cherry picked from commit a73e9f0ac1)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-07-17 13:54:15 +08:00
mergify[bot]andNick Chung eec7c3573d chore: disable vmstate-persistence and longhorn-static StorageClasses (backport #377) (#405)
* chore: disable vmstate-persistence and longhorn-static StorageClasses (#377)

* chore: disable vmstate-persistence and longhorn-static StorageClasses

Signed-off-by: Nick Chung <nick.chung@suse.com>

* chore: allow internal storage class deletions in image and volumn

Signed-off-by: Nick Chung <nick.chung@suse.com>

* chore: remove deletion tooltips in image and volume pages

Signed-off-by: Nick Chung <nick.chung@suse.com>

* chore: rollback style changes of image and volume lists

Signed-off-by: Nick Chung <nick.chung@suse.com>

---------

Signed-off-by: Nick Chung <nick.chung@suse.com>
(cherry picked from commit b775ce5f50)

* chore: fix missing semicolon in types.js

Signed-off-by: Nick Chung <Nick.Chung@suse.com>

---------

Signed-off-by: Nick Chung <nick.chung@suse.com>
Signed-off-by: Nick Chung <Nick.Chung@suse.com>
Co-authored-by: Nick Chung <Nick.Chung@suse.com>
2025-07-16 18:32:13 +08:00
43 changed files with 1440 additions and 322 deletions
+1 -2
View File
@@ -3,5 +3,4 @@ addReviewers: true
numberOfReviewers: 0
reviewers:
- a110605
- houhoucoop
- NickChungSUSE
- houhoucoop
+1 -1
View File
@@ -27,7 +27,7 @@ module.exports = {
'subject-case': [0, 'never'],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [2, 'always', 72],
'subject-max-length': [0, 'never'],
'body-leading-blank': [2, 'always'],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [2, 'always'],
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "harvester-ui-extension",
"version": "1.6.0-rc2",
"version": "1.6.0-rc6",
"private": false,
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.26.0",
"@rancher/shell": "3.0.5-rc.6",
"@rancher/shell": "3.0.5-rc.7",
"cache-loader": "^4.1.0",
"color": "4.2.3",
"ip": "2.0.1",
+5 -1
View File
@@ -98,8 +98,12 @@ export default {
return true;
}
const description = this.t(setting.description, this.getDocLinkParams(setting) || {}, true)?.toLowerCase() || '';
let description = this.t(setting.description, this.getDocLinkParams(setting) || {}, true)?.toLowerCase() || '';
// remove <a> tags from description
if (description.includes('<a')) {
description = description.replace(/<a[^>]*>(.*?)<\/a>/g, '$1');
}
// filter by description
if (description.includes(searchQuery)) {
return true;
@@ -0,0 +1,222 @@
<script>
import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio';
import { SECRET } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import FileSelector, { createOnSelected } from '@shell/components/form/FileSelector';
import YamlEditor from '@shell/components/YamlEditor';
export default {
name: 'HarvesterRancherCluster',
components: {
RadioGroup,
FileSelector,
YamlEditor
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
default: () => ({}),
},
registerBeforeHook: {
type: Function,
required: false,
default: () => {},
},
},
data() {
let parseDefaultValue = {};
try {
const data = this.value.value || this.value.default || '{}';
const parsed = JSON.parse(data);
parseDefaultValue = {
kubeConfig: '',
removeUpstreamClusterWhenNamespaceIsDeleted: parsed.removeUpstreamClusterWhenNamespaceIsDeleted || false
};
} catch (error) {
parseDefaultValue = {
kubeConfig: '',
removeUpstreamClusterWhenNamespaceIsDeleted: false
};
}
return {
parseDefaultValue,
errors: [],
existingSecret: null,
};
},
async created() {
await this.checkExistingSecret();
if (this.registerBeforeHook) {
this.registerBeforeHook(this.willSave, 'willSave');
}
},
methods: {
onKeySelected: createOnSelected('parseDefaultValue.kubeConfig'),
update() {
if (this.parseDefaultValue.removeUpstreamClusterWhenNamespaceIsDeleted) {
this.value['value'] = JSON.stringify({ removeUpstreamClusterWhenNamespaceIsDeleted: true });
} else {
this.value['value'] = this.value.default || '{}';
}
},
async checkExistingSecret() {
const inStore = this.$store.getters['currentProduct'].inStore;
await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
const secrets = this.$store.getters[`${ inStore }/all`](SECRET) || [];
this.existingSecret = secrets.find((secret) => secret.metadata.name === 'rancher-cluster-config' &&
secret.metadata.namespace === 'harvester-system'
);
// If the secret exists and has data, populate the kubeConfig
if (this.existingSecret?.data?.kubeConfig) {
const decodedContent = atob(this.existingSecret.data.kubeConfig);
this.parseDefaultValue.kubeConfig = decodedContent;
this.$nextTick(() => {
this.update();
});
}
},
async createOrUpdateRancherKubeConfigSecret() {
this.errors = [];
// Check if kubeConfig is provided
if (!this.parseDefaultValue.kubeConfig) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.setting.rancherCluster.kubeConfig') }, true));
return Promise.reject(this.errors);
}
try {
let secret;
if (this.existingSecret) {
secret = this.existingSecret;
secret.setData('kubeConfig', this.parseDefaultValue.kubeConfig);
} else {
const inStore = this.$store.getters['currentProduct'].inStore;
secret = await this.$store.dispatch(`${ inStore }/create`, {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: 'rancher-cluster-config',
namespace: 'harvester-system'
},
type: 'secret',
data: { kubeConfig: btoa(this.parseDefaultValue.kubeConfig) }
});
}
await secret.save();
return Promise.resolve();
} catch (err) {
this.errors = exceptionToErrorsArray(err);
return Promise.reject(this.errors);
}
},
async deleteRancherKubeConfigSecret() {
if (this.existingSecret) {
this.existingSecret.remove();
}
},
async willSave() {
// Only create or update secret if enabled
if (this.parseDefaultValue.removeUpstreamClusterWhenNamespaceIsDeleted) {
await this.createOrUpdateRancherKubeConfigSecret();
} else {
await this.deleteRancherKubeConfigSecret();
}
return Promise.resolve();
},
useDefault() {
this.parseDefaultValue = {
kubeConfig: '',
removeUpstreamClusterWhenNamespaceIsDeleted: false
};
}
},
watch: {
'parseDefaultValue.removeUpstreamClusterWhenNamespaceIsDeleted'(val, oldVal) {
if (val && !oldVal && this.existingSecret?.data?.kubeConfig) {
// Populate kubeConfig with the existing secret value
this.parseDefaultValue.kubeConfig = atob(this.existingSecret.data.kubeConfig);
}
},
'parseDefaultValue.kubeConfig'(val) {
this.$refs.yaml?.updateValue(val);
}
}
};
</script>
<template>
<div>
<div class="row mt-20">
<div class="col span-12">
<RadioGroup
v-model:value="parseDefaultValue.removeUpstreamClusterWhenNamespaceIsDeleted"
:label="t('harvester.setting.rancherCluster.removeUpstreamClusterWhenNamespaceIsDeleted')"
name="removeUpstreamClusterWhenNamespaceIsDeleted"
:options="[true, false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
@update:value="update"
/>
</div>
</div>
<div
v-if="parseDefaultValue.removeUpstreamClusterWhenNamespaceIsDeleted"
class="row mt-20"
>
<div class="col span-12">
<FileSelector
class="btn btn-sm bg-primary mb-10"
:label="t('generic.readFromFile')"
@selected="onKeySelected"
/>
<YamlEditor
ref="yaml"
v-model:value="parseDefaultValue.kubeConfig"
class="yaml-editor"
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
@update:value="update"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$yaml-height: 540px;
:deep() .yaml-editor{
flex: 1;
min-height: $yaml-height;
& .code-mirror .CodeMirror {
position: initial;
height: auto;
min-height: $yaml-height;
}
}
</style>
@@ -53,8 +53,6 @@ export default {
},
namespaceOptions() {
if (this.availableNamespaces.length === 0) return [];
const allSelected =
this.namespaces.length === this.filteredNamespaces.length &&
this.filteredNamespaces.every((ns) => this.namespaces.includes(ns));
+5 -1
View File
@@ -43,7 +43,11 @@ const FEATURE_FLAGS = {
'customSupportBundle',
'csiOnlineExpandValidation',
'vmNetworkMigration',
'kubeovnVpcSubnet'
'kubeovnVpcSubnet',
'rancherClusterSetting',
'cpuMemoryHotplug',
'cdiSettings',
'vmCloneRunStrategy',
]
};
+6 -1
View File
@@ -70,5 +70,10 @@ export const HCI = {
K8S_ARCH: 'kubernetes.io/arch',
IMAGE_DISPLAY_NAME: 'harvesterhci.io/imageDisplayName',
CUSTOM_IP: 'harvesterhci.io/custom-ip',
IMPORTED_IMAGE: 'migration.harvesterhci.io/imported'
IMPORTED_IMAGE: 'migration.harvesterhci.io/imported',
VM_CPU_MEMORY_HOTPLUG: 'harvesterhci.io/enableCPUAndMemoryHotplug',
FILESYSTEM_OVERHEAD: 'cdi.harvesterhci.io/filesystemOverhead',
CLONE_STRATEGY: 'cdi.harvesterhci.io/storageProfileCloneStrategy',
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
};
+6
View File
@@ -37,6 +37,8 @@ export const HCI_SETTING = {
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio'
};
export const HCI_ALLOWED_SETTINGS = {
@@ -108,6 +110,10 @@ export const HCI_ALLOWED_SETTINGS = {
featureFlag: 'upgradeConfigSetting',
docPath: 'UPGRADE_CONFIG_URL'
},
[HCI_SETTING.RANCHER_CLUSTER]: {
kind: 'custom', from: 'import', canReset: true, featureFlag: 'rancherClusterSetting'
},
[HCI_SETTING.MAX_HOTPLUG_RATIO]: { kind: 'number', featureFlag: 'cpuMemoryHotplug' },
[HCI_SETTING.VM_MIGRATION_NETWORK]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'vmNetworkMigration',
},
+5
View File
@@ -18,3 +18,8 @@ export const NETWORK_PROTOCOL = {
IPv4: 'IPv4',
IPv6: 'IPv6',
};
export const INTERNAL_STORAGE_CLASS = {
VMSTATE_PERSISTENCE: 'vmstate-persistence',
LONGHORN_STATIC: 'longhorn-static',
};
@@ -47,20 +47,26 @@ export default {
STATE,
NAME,
{
name: 'vmCPU',
labelKey: 'tableHeaders.cpu',
search: false,
sort: ['spec.template.spec.domain.cpu.cores'],
value: 'spec.template.spec.domain.cpu.cores',
width: 120
name: 'CPU',
label: 'CPU',
sort: ['displayCPU'],
value: 'displayCPU',
align: 'center',
dashIfEmpty: true,
},
{
name: 'vmRAM',
labelKey: 'glance.memory',
search: false,
sort: ['memorySort'],
value: 'spec.template.spec.domain.resources.limits.memory',
width: 120
name: 'Memory',
value: 'displayMemory',
sort: ['memorySort'],
align: 'center',
labelKey: 'tableHeaders.memory',
formatter: 'Si',
formatterOpts: {
opts: {
increment: 1024, addSuffix: true, maxExponent: 3, minExponent: 3, suffix: 'i',
},
needParseSi: true
},
},
{
name: 'ip',
@@ -172,6 +172,9 @@ export default {
:cpu="cpu"
:mode="mode"
:memory="memory"
:max-cpu="maxCpu"
:max-memory="maxMemory"
:enable-hot-plug="cpuMemoryHotplugEnabled"
/>
</div>
<div class="row">
@@ -172,6 +172,9 @@ export default {
:cpu="cpu"
:mode="mode"
:memory="memory"
:max-cpu="maxCpu"
:max-memory="maxMemory"
:enable-hot-plug="cpuMemoryHotplugEnabled"
/>
<div class="row mb-10">
@@ -5,6 +5,7 @@ import CreateEditView from '@shell/mixins/create-edit-view';
import HarvesterIpAddress from '../../../formatters/HarvesterIpAddress';
import VMConsoleBar from '../../../components/VMConsoleBar';
import { HCI } from '../../../types';
import { getVmCPUMemoryValues } from '../../../utils/cpuMemory';
const UNDEFINED = 'n/a';
@@ -91,9 +92,9 @@ export default {
},
flavor() {
const domain = this.value?.spec?.template?.spec?.domain;
const { cpu, memory } = getVmCPUMemoryValues(this.value);
return `${ domain.cpu?.cores } vCPU , ${ domain.resources?.limits?.memory } ${ this.t('harvester.virtualMachine.input.memory') }`;
return `${ cpu } vCPU , ${ memory } ${ this.t('harvester.virtualMachine.input.memory') }`;
},
kernelRelease() {
+20 -6
View File
@@ -1,12 +1,13 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapGetters } from 'vuex';
import { runStrategies as runStrategyOptions } from '../config/harvester-map';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'CloneVMModal',
@@ -14,7 +15,7 @@ export default {
emits: ['close'],
components: {
AsyncButton, Banner, Checkbox, Card, LabeledInput
AsyncButton, Banner, Checkbox, Card, LabeledInput, LabeledSelect
},
props: {
@@ -26,9 +27,11 @@ export default {
data() {
return {
name: '',
cloneData: true,
errors: []
name: '',
cloneData: true,
errors: [],
runStrategy: runStrategyOptions[1],
runStrategyOptions
};
},
@@ -38,6 +41,9 @@ export default {
actionResource() {
return this.resources[0];
},
vmCloneRunStrategyEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('vmCloneRunStrategy');
},
},
methods: {
@@ -58,7 +64,8 @@ export default {
// deep clone
try {
const res = await this.actionResource.doAction('clone', { targetVm: this.name }, {}, false);
const payload = this.vmCloneRunStrategyEnabled ? { targetVm: this.name, runStrategy: this.runStrategy } : { targetVm: this.name };
const res = await this.actionResource.doAction('clone', payload, {}, false);
if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', {
@@ -106,6 +113,13 @@ export default {
:label="t('harvester.modal.cloneVM.name')"
required
/>
<LabeledSelect
v-if="vmCloneRunStrategyEnabled"
v-model:value="runStrategy"
label-key="harvester.virtualMachine.runStrategy"
:options="runStrategyOptions"
:mode="mode"
/>
<Banner
v-for="(err, i) in errors"
:key="i"
-149
View File
@@ -1,149 +0,0 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
export default {
name: 'CloneVMModal',
emits: ['close'],
components: {
AsyncButton, Banner, Checkbox, Card, LabeledInput
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return {
name: '',
cloneData: true,
errors: []
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0];
},
},
methods: {
close() {
this.name = '';
this.$emit('close');
},
async create(buttonCb) {
// shadow clone
if (!this.cloneData) {
this.resources[0].goToClone();
buttonCb(false);
this.close();
return;
}
// deep clone
try {
const res = await this.actionResource.doAction('clone', { targetVm: this.name }, {}, false);
if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', {
title: this.t('harvester.notification.title.succeed'),
message: this.t('harvester.modal.cloneVM.message.success', { name: this.name })
}, { root: true });
this.close();
buttonCb(true);
} else {
const error = [res?.data] || exceptionToErrorsArray(res);
this['errors'] = error;
buttonCb(false);
}
} catch (err) {
const error = err?.data || err;
const message = exceptionToErrorsArray(error);
this['errors'] = message;
buttonCb(false);
}
}
},
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.cloneVM.title') }}
</template>
<template #body>
<Checkbox
v-model:value="cloneData"
class="mb-10"
label-key="harvester.modal.cloneVM.type"
/>
<LabeledInput
v-show="cloneData"
v-model:value="name"
class="mb-20"
:label="t('harvester.modal.cloneVM.name')"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
:label="err"
color="error"
/>
</template>
<template #actions>
<div class="actions">
<div class="buttons">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="create"
:action-label="cloneData ? t('harvester.modal.cloneVM.action.create') : t('harvester.modal.cloneVM.action.clone')"
:disabled="cloneData && !name"
@click="create"
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -0,0 +1,185 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapGetters } from 'vuex';
import { getVmCPUMemoryValues } from '../utils/cpuMemory';
import UnitInput from '@shell/components/form/UnitInput';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { GIBIBYTE } from '../utils/unit';
export default {
name: 'CPUMemoryHotplugModal',
emits: ['close'],
components: {
AsyncButton, Card, Banner, UnitInput
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
const { cpu, memory } = getVmCPUMemoryValues(this.resources[0] || {});
return {
cpu,
memory,
errors: [],
GIBIBYTE
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0] || {};
},
maxResourcesMessage() {
const { maxCpu, maxMemory } = getVmCPUMemoryValues(this.actionResource);
if (maxCpu && maxMemory) {
return this.t('harvester.modal.cpuMemoryHotplug.maxResourcesMessage', { maxCpu, maxMemory });
}
return '';
}
},
methods: {
close() {
this.cpu = '';
this.memory = '';
this.$emit('close');
},
change() {
if (parseInt(this.memory, 10) < 1 ) {
this.memory = '1Gi';
}
if (this.cpu < 1) {
this.cpu = 1;
}
},
async save(buttonCb) {
if (this.actionResource) {
try {
const res = await this.actionResource.doAction('cpuAndMemoryHotplug', { sockets: this.cpu, memory: this.memory });
if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.cpuMemoryHotplug.success', { vm: this.actionResource.nameDisplay })
}, { root: true });
this.close();
buttonCb(true);
} else {
const error = [res?.data] || exceptionToErrorsArray(res);
this['errors'] = error;
buttonCb(false);
}
} catch (err) {
const error = err?.data || err;
const message = exceptionToErrorsArray(error);
this['errors'] = message;
buttonCb(false);
}
}
},
}
};
</script>
<template>
<Card
ref="modal"
name="modal"
:show-highlight-border="false"
>
<template #title>
<h4
v-clean-html="t('harvester.modal.cpuMemoryHotplug.title')"
class="text-default-text"
/>
</template>
<template #body>
<Banner
v-if="maxResourcesMessage"
:label="maxResourcesMessage"
color="info"
/>
<UnitInput
v-model:value="cpu"
:label="t('harvester.virtualMachine.input.cpu')"
:delay="0"
required
suffix="C"
class="mb-20"
:mode="mode"
:min="1"
@update:value="change"
/>
<UnitInput
v-model:value="memory"
:label="t('harvester.virtualMachine.input.memory')"
:mode="mode"
:input-exponent="3"
:delay="0"
:min="1"
:increment="1024"
:output-modifier="true"
:disabled="disabled"
:suffix="GIBIBYTE"
class="mb-20"
@update:value="change"
/>
<Banner
v-for="(err, i) in errors"
:key="i"
:label="err"
color="error"
/>
</template>
<template #actions>
<div class="actions">
<div class="buttons">
<button
type="button"
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
@click="save"
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -10,6 +10,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { NAMESPACE, STORAGE_CLASS } from '@shell/config/types';
import { allHash } from '@shell/utils/promise';
import { isInternalStorageClass } from '../utils/storage-class';
export default {
name: 'HarvesterExportImageDialog',
@@ -105,11 +106,17 @@ export default {
}
const options = storages.filter((s) => !s.parameters?.backingImage).map((s) => {
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
const isInternal = isInternalStorageClass(s.metadata?.name);
if (isInternal) {
label += ` (${ this.t('harvester.storage.internal.label') })`;
}
return {
label,
value: s.name,
value: s.name,
disabled: isInternal,
};
}) || [];
@@ -0,0 +1,319 @@
<script>
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { VOLUME_SNAPSHOT_CLASS, HCI } from '../../types';
import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params';
export default {
name: 'CDISettings',
props: {
value: {
type: Object,
required: true
},
mode: {
type: String,
required: true
},
provisioner: {
type: String,
required: true
},
},
components: {
ArrayList,
Checkbox,
LabeledInput,
LabeledSelect,
},
emits: ['update:cdiSettings'],
async fetch() {
const hash = {
volumeSnapshotClasses: this.$store.dispatch(`${ this.inStore }/findAll`, { type: VOLUME_SNAPSHOT_CLASS }),
csiDriverConfigSetting: this.$store.dispatch(`${ this.inStore }/find`, { type: HCI.SETTING, id: HCI_SETTING.CSI_DRIVER_CONFIG }),
};
await allHash(hash);
if (this.mode === _CREATE ) {
this.setDefaultVolumeSnapshotClass();
} else {
this.initCDISettingsFromAnnotations();
}
},
data() {
return {
cdiSettings: {
volumeModeAccessModes: [],
volumeSnapshotClass: null,
cloneStrategy: null,
filesystemOverhead: null,
},
defaultAddValue: { volumeMode: null, accessModes: [] },
noneOption: { label: 'None', value: '' },
};
},
computed: {
inStore() {
return this.$store.getters['currentProduct'].inStore;
},
allVolumeModeOptions() {
return Object.values(VOLUME_MODE).map((mode) => ({ label: mode, value: mode }));
},
selectedVolumeModes() {
return this.cdiSettings.volumeModeAccessModes
.map((item) => item.volumeMode)
.filter(Boolean);
},
volumeModeOptions() {
return [this.noneOption, ...this.allVolumeModeOptions].filter( (option) => !this.selectedVolumeModes.includes(option.value) );
},
allVolumeModesSelected() {
return this.selectedVolumeModes.length === this.allVolumeModeOptions.length;
},
accessModeOptions() {
return [
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
];
},
cloneStrategyOptions() {
return [
this.noneOption,
{ label: this.t('harvester.storage.cdiSettings.cloneStrategy.copy'), value: 'copy' },
{ label: this.t('harvester.storage.cdiSettings.cloneStrategy.snapshot'), value: 'snapshot' },
{ label: this.t('harvester.storage.cdiSettings.cloneStrategy.csiClone'), value: 'csi-clone' },
];
},
volumeSnapshotClassOptions() {
const allClasses = this.$store.getters[`${ this.inStore }/all`](VOLUME_SNAPSHOT_CLASS) || [];
const filtered = allClasses.filter((c) => c.driver === this.provisioner);
return [this.noneOption, ...filtered.map((c) => ({ label: c.name, value: c.name }))];
},
isFilesystemOverheadValid() {
const val = this.cdiSettings.filesystemOverhead;
if (val === null || val === '') return true;
const regex = /^(0(\.\d{1,3})?|1(\.0{1,3})?)$/;
return regex.test(val);
},
isCustomClass() {
return this.mode === _CREATE || this.mode === _EDIT;
}
},
watch: {
provisioner() {
this.resetCdiSettings();
this.$nextTick(this.setDefaultVolumeSnapshotClass);
},
cdiSettings: {
handler(val) {
this.$emit('update:cdiSettings', val);
},
deep: true,
immediate: true,
}
},
methods: {
setDefaultVolumeSnapshotClass() {
try {
const setting = this.$store.getters[`${ this.inStore }/byId`](HCI.SETTING, HCI_SETTING.CSI_DRIVER_CONFIG);
const config = JSON.parse(setting?.value || setting?.default || '{}');
const defaultClass = config?.[this.provisioner]?.volumeSnapshotClassName || null;
const allClasses = this.$store.getters[`${ this.inStore }/all`](VOLUME_SNAPSHOT_CLASS) || [];
const matched = allClasses.find((cls) => cls.name === defaultClass && cls.driver === this.provisioner);
this.cdiSettings.volumeSnapshotClass = matched?.name || null;
} catch (e) {
console.error('Failed to parse CSI config:', e); // eslint-disable-line no-console
this.cdiSettings.volumeSnapshotClass = null;
}
},
initCDISettingsFromAnnotations() {
const annotations = this.value?.metadata?.annotations || {};
let volumeModeAccessModes = [];
const rawVolumeMode = annotations[HCI_ANNOTATIONS.VOLUME_MODE_ACCESS_MODES];
if (rawVolumeMode) {
try {
const parsed = JSON.parse(rawVolumeMode);
volumeModeAccessModes = Object.entries(parsed).map(([volumeMode, accessModes]) => ({
volumeMode,
accessModes: Array.isArray(accessModes) ? accessModes : [],
}));
} catch (e) {
console.error('Failed to parse annotation:', e); // eslint-disable-line no-console
}
}
if (volumeModeAccessModes.length) {
this.cdiSettings.volumeModeAccessModes = volumeModeAccessModes;
}
if (annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS]) {
this.cdiSettings.volumeSnapshotClass = annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS];
}
if (annotations[HCI_ANNOTATIONS.CLONE_STRATEGY]) {
this.cdiSettings.cloneStrategy = annotations[HCI_ANNOTATIONS.CLONE_STRATEGY];
}
if (annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD]) {
this.cdiSettings.filesystemOverhead = annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD];
}
},
resetCdiSettings() {
this.cdiSettings.volumeModeAccessModes = [this.defaultAddValue];
this.cdiSettings.volumeSnapshotClass = null;
this.cdiSettings.cloneStrategy = null;
this.cdiSettings.filesystemOverhead = null;
},
validateFilesystemOverhead(e) {
const val = e.target.value;
this.cdiSettings.filesystemOverhead = val;
}
}
};
</script>
<template>
<ArrayList
v-model:value="cdiSettings.volumeModeAccessModes"
:initial-empty-row="true"
:show-header="true"
:mode="mode"
:title="t('harvester.storage.cdiSettings.volumeModeAccessModes.label')"
:add-label="t('harvester.storage.cdiSettings.volumeModeAccessModes.add')"
:default-add-value="defaultAddValue"
:protip="t('harvester.storage.cdiSettings.volumeModeAccessModes.tooltip')"
:add-disabled="allVolumeModesSelected"
>
<template #column-headers>
<div class="column-headers">
<div
class="row"
:class="{ custom: isCustomClass }"
>
<label
class="col span-3 value text-label mb-10"
for="volumeMode"
>
{{ t('harvester.storage.cdiSettings.volumeModeAccessModes.volumeMode') }}
</label>
<label
class="col span-9 value text-label mb-10"
for="accessModes"
>
{{ t('harvester.storage.cdiSettings.volumeModeAccessModes.accessModes.label') }}
</label>
</div>
</div>
</template>
<template #columns="{ row }">
<div class="row">
<div class="col span-3">
<LabeledSelect
id="volumeMode"
v-model:value="row.value.volumeMode"
:mode="mode"
:options="volumeModeOptions"
/>
</div>
<div
id="accessModes"
class="col span-9"
>
<Checkbox
v-for="opt in accessModeOptions"
:key="opt.value"
v-model:value="row.value.accessModes"
:value-when-true="opt.value"
:label="opt.label"
type="checkbox"
:mode="mode"
/>
</div>
</div>
</template>
</ArrayList>
<LabeledSelect
v-model:value="cdiSettings.volumeSnapshotClass"
class="select mt-20 mb-20"
:label="t('harvester.storage.cdiSettings.volumeSnapshotClass.label')"
:tooltip="t('harvester.storage.cdiSettings.volumeSnapshotClass.tooltip')"
:mode="mode"
:options="volumeSnapshotClassOptions"
/>
<LabeledSelect
v-model:value="cdiSettings.cloneStrategy"
class="select mb-20"
:label="t('harvester.storage.cdiSettings.cloneStrategy.label')"
:tooltip="t('harvester.storage.cdiSettings.cloneStrategy.tooltip')"
:mode="mode"
:options="cloneStrategyOptions"
/>
<LabeledInput
v-model:value="cdiSettings.filesystemOverhead"
class="select mb-20"
:label="t('harvester.storage.cdiSettings.fileSystemOverhead.label')"
:tooltip="t('harvester.storage.cdiSettings.fileSystemOverhead.tooltip')"
:mode="mode"
type="number"
:min="0"
:max="1"
:step="0.001"
:placeholder="t('harvester.storage.cdiSettings.fileSystemOverhead.placeholder')"
:status="isFilesystemOverheadValid ? null : 'error'"
@input="validateFilesystemOverhead"
/>
</template>
<style scoped lang="scss">
.column-headers .row.custom {
max-width: calc(100% - 75px);
}
.row {
align-items: center;
}
.select {
max-width: 480px;
}
</style>
@@ -1,4 +1,5 @@
<script>
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view';
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
@@ -6,11 +7,9 @@ import ArrayList from '@shell/components/form/ArrayList';
import Tab from '@shell/components/Tabbed/Tab';
import Tabbed from '@shell/components/Tabbed';
import { RadioGroup } from '@components/Form/Radio';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import Loading from '@shell/components/Loading';
import { _CREATE, _VIEW } from '@shell/config/query-params';
import { mapFeature, UNSUPPORTED_STORAGE_DRIVERS } from '@shell/store/features';
import {
@@ -22,6 +21,7 @@ import { CSI_DRIVER } from '../../types';
import Tags from '../../components/DiskTags';
import { DATA_ENGINE_V1, DATA_ENGINE_V2 } from '../../models/harvester/persistentvolumeclaim';
import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass';
import CDISettings from './CDISettings';
export const LVM_TOPOLOGY_LABEL = 'topology.lvm.csi/node';
@@ -45,6 +45,7 @@ export default {
Tabbed,
Loading,
Tags,
CDISettings,
},
mixins: [CreateEditView],
@@ -117,6 +118,7 @@ export default {
key: '',
values: [],
},
cdiSettings: {},
};
},
@@ -124,10 +126,10 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }),
csiDrivers: this.$store.dispatch(`${ inStore }/findAll`, { type: CSI_DRIVER })
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }),
csiDrivers: this.$store.dispatch(`${ inStore }/findAll`, { type: CSI_DRIVER }),
};
if (this.value.longhornV2LVMSupport) {
@@ -197,6 +199,14 @@ export default {
return v2DataEngine.value === 'true' ? DATA_ENGINE_V2 : DATA_ENGINE_V1;
},
isCDISettingsFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('cdiSettings');
},
shouldShowCDISettingsTab() {
return this.isCDISettingsFeatureEnabled && this.provisioner !== `${ LONGHORN_DRIVER }_${ DATA_ENGINE_V1 }`;
},
},
watch: {
@@ -249,6 +259,10 @@ export default {
});
this.formatAllowedTopoloties();
if (this.shouldShowCDISettingsTab) {
this.formatCDISettings();
}
},
formatAllowedTopoloties() {
@@ -270,6 +284,42 @@ export default {
if (matchLabelExpressions.length > 0) {
this.value.allowedTopologies = [{ matchLabelExpressions: [...matchLabelExpressions, ...lvmMatchExpression] }];
}
},
formatCDISettings() {
const annotations = this.value.metadata.annotations || {};
const volumeModeAccessModes = {};
this.cdiSettings.volumeModeAccessModes.forEach((setting) => {
if (setting.volumeMode && Array.isArray(setting.accessModes) && setting.accessModes.length > 0) {
volumeModeAccessModes[setting.volumeMode] = setting.accessModes;
}
});
if (Object.keys(volumeModeAccessModes).length > 0) {
annotations[HCI_ANNOTATIONS.VOLUME_MODE_ACCESS_MODES] = JSON.stringify(volumeModeAccessModes);
} else {
delete annotations[HCI_ANNOTATIONS.VOLUME_MODE_ACCESS_MODES];
}
if (this.cdiSettings.volumeSnapshotClass) {
annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS] = this.cdiSettings.volumeSnapshotClass;
} else {
delete annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS];
}
if (this.cdiSettings.cloneStrategy) {
annotations[HCI_ANNOTATIONS.CLONE_STRATEGY] = this.cdiSettings.cloneStrategy;
} else {
delete annotations[HCI_ANNOTATIONS.CLONE_STRATEGY];
}
if (this.cdiSettings.filesystemOverhead) {
annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD] = this.cdiSettings.filesystemOverhead;
} else {
delete annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD];
}
this.value.metadata.annotations = annotations;
}
}
};
@@ -404,6 +454,19 @@ export default {
</template>
</ArrayList>
</Tab>
<Tab
v-if="shouldShowCDISettingsTab"
name="cdiSettings"
:label="t('harvester.storage.cdiSettings.label')"
:weight="-2"
>
<CDISettings
v-model:cdi-settings="cdiSettings"
:value="value"
:mode="mode"
:provisioner="value.provisioner"
/>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -17,6 +17,7 @@ import { VM_IMAGE_FILE_FORMAT } from '../validators/vm-image';
import { OS } from '../mixins/harvester-vm';
import { HCI } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { isInternalStorageClass } from '../utils/storage-class';
const ENCRYPT = 'encrypt';
const DECRYPT = 'decrypt';
@@ -168,11 +169,18 @@ export default {
return filteredStorages
.map((s) => {
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let disabled = false;
if (isInternalStorageClass(s.name)) {
label += ` (${ this.t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value: s.name,
disabled
};
}) || [];
},
@@ -259,6 +259,9 @@ export default {
<CpuMemory
:cpu="cpu"
:memory="memory"
:max-cpu="maxCpu"
:max-memory="maxMemory"
:enable-hot-plug="cpuMemoryHotplugEnabled"
:disabled="isConfig"
@updateCpuMemory="updateCpuMemory"
/>
@@ -23,6 +23,7 @@ import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { isInternalStorageClass } from '../utils/storage-class';
export default {
name: 'HarvesterVolume',
@@ -206,11 +207,18 @@ export default {
storageClassOptions() {
return this.storageClasses.filter((s) => !s.parameters?.backingImage).map((s) => {
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let disabled = false;
if (isInternalStorageClass(s.name)) {
label += ` (${ this.t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value: s.name,
disabled
};
}) || [];
},
@@ -2,23 +2,44 @@
import UnitInput from '@shell/components/form/UnitInput';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { GIBIBYTE } from '../../utils/unit';
import { Checkbox } from '@components/Form/Checkbox';
import { _VIEW } from '@shell/config/query-params';
import { HCI } from '../../types';
import { allHash } from '@shell/utils/promise';
import { HCI_SETTING } from '../../config/settings';
const DEFAULT_HOT_PLUG_TIMES = 4;
export default {
name: 'HarvesterEditCpuMemory',
emits: ['updateCpuMemory'],
components: { UnitInput, InputOrDisplay },
components: {
UnitInput, InputOrDisplay, Checkbox
},
props: {
cpu: {
type: Number,
default: null
},
maxCpu: {
type: Number,
default: null
},
memory: {
type: String,
default: null
},
maxMemory: {
type: String,
default: null
},
enableHotPlug: {
type: Boolean,
default: false
},
mode: {
type: String,
default: 'create',
@@ -29,21 +50,59 @@ export default {
}
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const res = await allHash({ settings: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SETTING }) });
this.settings = res.settings || [];
},
data() {
return {
GIBIBYTE,
localCpu: this.cpu,
localMemory: this.memory
localCpu: this.cpu,
localMemory: this.memory,
maxLocalCpu: this.maxCpu,
maxLocalMemory: this.maxMemory,
localEnableHotPlug: this.enableHotPlug,
settings: []
};
},
computed: {
cupDisplay() {
isView() {
return this.mode === _VIEW;
},
cpuDisplay() {
return `${ this.localCpu } C`;
},
maxCpuDisplay() {
return `${ this.maxLocalCpu } C`;
},
memoryDisplay() {
return `${ this.localMemory }`;
},
maxMemoryDisplay() {
return `${ this.maxLocalMemory }`;
},
cpuMemoryHotplugTooltip() {
return this.t('harvester.virtualMachine.hotplug.tooltip', { hotPlugTimes: this.maxHotplugRatio });
},
isCPUMemoryHotPlugFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('cpuMemoryHotplug');
},
maxHotplugRatio() {
const maxHotPlugRatioSetting = this.settings.find((s) => s.id === HCI_SETTING.MAX_HOTPLUG_RATIO);
const maxPlugRatio = maxHotPlugRatioSetting?.value ? parseInt(maxHotPlugRatioSetting.value, 10) : DEFAULT_HOT_PLUG_TIMES;
return maxPlugRatio;
}
},
@@ -55,71 +114,161 @@ export default {
if (neu && !neu.includes('null')) {
this.localMemory = neu;
}
}
},
maxCpu(neu) {
this.maxLocalCpu = neu;
},
maxMemory(neu) {
if (neu && !neu.includes('null')) {
this.maxLocalMemory = neu;
}
},
enableHotPlug(neu) {
this.localEnableHotPlug = neu;
},
},
methods: {
change() {
let memory = '';
if (String(this.localMemory).includes('Gi')) {
memory = this.localMemory;
hotPlugChanged(neu) {
// If hot plug is enabled, we need to update the maxCpu and maxMemory values
if (neu) {
this.maxLocalCpu = this.localCpu ? this.localCpu * this.maxHotplugRatio : null;
this.maxLocalMemory = this.localMemory ? `${ parseInt(this.localMemory, 10) * this.maxHotplugRatio }${ GIBIBYTE }` : null;
this.$emit('updateCpuMemory', this.localCpu, this.localMemory, this.maxLocalCpu, this.maxLocalMemory, neu);
} else {
memory = `${ this.localMemory }${ GIBIBYTE }`;
this.$emit('updateCpuMemory', this.localCpu, this.localMemory, '', null, neu);
}
if (memory.includes('null')) {
memory = null;
}
this.$emit('updateCpuMemory', this.localCpu, memory);
},
changeMemory() {
if (this.localEnableHotPlug) {
this.maxLocalMemory = this.localMemory ? `${ parseInt(this.localMemory, 10) * this.maxHotplugRatio }${ GIBIBYTE }` : null;
}
this.$emit('updateCpuMemory', this.localCpu, this.localMemory, this.maxLocalCpu, this.maxLocalMemory, this.localEnableHotPlug);
},
changeCPU() {
if (this.localEnableHotPlug) {
this.maxLocalCpu = this.localCpu ? this.localCpu * this.maxHotplugRatio : null;
}
this.$emit('updateCpuMemory', this.localCpu, this.localMemory, this.maxLocalCpu, this.maxLocalMemory, this.localEnableHotPlug);
},
changeMaxCPUMemory() {
this.$emit('updateCpuMemory', this.localCpu, this.localMemory, this.maxLocalCpu, this.maxLocalMemory, this.localEnableHotPlug);
},
}
};
</script>
<template>
<div class="row">
<div class="col span-6 mb-10">
<InputOrDisplay
name="CPU"
:value="cupDisplay"
:mode="mode"
>
<UnitInput
v-model:value="localCpu"
label="CPU"
suffix="C"
:delay="0"
required
:disabled="disabled"
<div>
<div class="row">
<div class="col span-6 mb-10">
<InputOrDisplay
name="CPU"
:value="cpuDisplay"
:mode="mode"
class="mb-20"
@update:value="change"
/>
</InputOrDisplay>
class="mb-10"
>
<UnitInput
v-model:value="localCpu"
label="CPU"
suffix="C"
:delay="0"
required
:disabled="disabled"
:mode="mode"
class="mb-20"
@update:value="changeCPU"
/>
</InputOrDisplay>
</div>
<div class="col span-6 mb-10">
<InputOrDisplay
:name="t('harvester.virtualMachine.input.memory')"
:value="memoryDisplay"
:mode="mode"
>
<UnitInput
v-model:value="localMemory"
:label="t('harvester.virtualMachine.input.memory')"
:mode="mode"
:input-exponent="3"
:delay="0"
:increment="1024"
:output-modifier="true"
:disabled="disabled"
required
:suffix="GIBIBYTE"
class="mb-20"
@update:value="changeMemory"
/>
</InputOrDisplay>
</div>
</div>
<div class="col span-6 mb-10">
<InputOrDisplay
:name="t('harvester.virtualMachine.input.memory')"
:value="memoryDisplay"
:mode="mode"
>
<UnitInput
v-model:value="localMemory"
:label="t('harvester.virtualMachine.input.memory')"
<div
v-if="isCPUMemoryHotPlugFeatureEnabled"
class="row"
>
<Checkbox
v-model:value="localEnableHotPlug"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.hotplug.title')"
:disabled="isView"
@update:value="hotPlugChanged"
/>
<i
v-clean-tooltip="{content: cpuMemoryHotplugTooltip, triggers: ['hover', 'touch', 'focus'] }"
v-stripped-aria-label="cpuMemoryHotplugTooltip"
class="icon icon-info"
tabindex="0"
/>
</div>
<div
v-if="localEnableHotPlug && isCPUMemoryHotPlugFeatureEnabled"
class="row"
>
<div class="col span-6 mb-10">
<InputOrDisplay
:name="t('harvester.virtualMachine.input.maxCpu')"
:value="maxCpuDisplay"
:mode="mode"
:input-exponent="3"
:delay="0"
:increment="1024"
:output-modifier="true"
:disabled="disabled"
required
:suffix="GIBIBYTE"
class="mb-20"
@update:value="change"
/>
</InputOrDisplay>
class="mt-20"
>
<UnitInput
v-model:value="maxLocalCpu"
:label="t('harvester.virtualMachine.input.maxCpu')"
suffix="C"
:delay="0"
:disabled="disabled"
:mode="mode"
class="mt-20"
@update:value="changeMaxCPUMemory"
/>
</InputOrDisplay>
</div>
<div class="col span-6 mb-10">
<InputOrDisplay
:name="t('harvester.virtualMachine.input.maxMemory')"
:value="maxMemoryDisplay"
:mode="mode"
class="mt-20"
>
<UnitInput
v-model:value="maxLocalMemory"
:label="t('harvester.virtualMachine.input.maxMemory')"
:mode="mode"
:input-exponent="3"
:delay="0"
:increment="1024"
:output-modifier="true"
:disabled="disabled"
:suffix="GIBIBYTE"
class="mt-20"
@update:value="changeMaxCPUMemory"
/>
</InputOrDisplay>
</div>
</div>
</div>
</template>
@@ -240,7 +240,7 @@ export default {
</script>
<template>
<div>
<div class="mt-20">
<LabeledSelect
v-model:value="checkedSsh"
:label="t('harvester.virtualMachine.input.sshKey')"
@@ -17,6 +17,7 @@ import { 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';
export default {
emits: ['update:value'],
@@ -266,7 +267,17 @@ export default {
isLonghornV2(volume) {
return volume?.pvc?.isLonghornV2 || volume?.pvc?.storageClass?.isLonghornV2;
}
},
isResizeDisabled(volume) {
if (this.isCreate) return false;
if (volume.newCreateId) return false;
const isStopped = this.vm.stateDisplay === OFF;
const isLonghornV2 = this.isLonghornV2(volume);
return !isStopped || isLonghornV2;
},
},
};
</script>
@@ -347,6 +358,7 @@ export default {
:is-edit="isEdit"
:is-view="isView"
:is-virtual-type="isVirtualType"
:is-resize-disabled="isResizeDisabled(rows[i])"
:mode="mode"
:idx="i"
:validate-required="validateRequired"
@@ -64,7 +64,12 @@ export default {
isVirtualType: {
type: Boolean,
default: true
}
},
isResizeDisabled: {
type: Boolean,
default: false
},
},
data() {
@@ -135,10 +140,6 @@ export default {
return this.$store.getters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
},
isLonghornV2() {
return this.value.pvc?.isLonghornV2 || this.value.pvc?.storageClass?.isLonghornV2;
},
selectedImage() {
return this.$store.getters['harvester/all'](HCI.IMAGE)?.find( (I) => this.value.image === I.id);
},
@@ -350,7 +351,7 @@ export default {
:label="t('harvester.fields.size')"
:mode="mode"
:required="validateRequired"
:disable="isLonghornV2"
:disabled="isResizeDisabled"
:suffix="GIBIBYTE"
@update:value="update"
/>
@@ -13,6 +13,7 @@ import { ucFirst } from '@shell/utils/string';
import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../../../../utils/unit';
import { isInternalStorageClass } from '../../../../utils/storage-class';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
export default {
@@ -55,7 +56,12 @@ export default {
isVirtualType: {
type: Boolean,
default: true
}
},
isResizeDisabled: {
type: Boolean,
default: false
},
},
data() {
@@ -118,19 +124,22 @@ export default {
storageClassOptions() {
return this.storageClasses.filter((s) => !s.parameters?.backingImage).map((s) => {
const label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let label = s.isDefault ? `${ s.name } (${ this.t('generic.default') })` : s.name;
let disabled = false;
if (isInternalStorageClass(s.name)) {
label += ` (${ this.t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value: s.name,
disabled
};
}) || [];
},
isLonghornV2() {
return this.value.pvc?.isLonghornV2 || this.value.pvc?.storageClass?.isLonghornV2;
},
isLonghornStorageClass() {
const selectedSC = this.storageClasses.find((sc) => sc.name === this.value?.storageClassName) || {};
@@ -302,7 +311,7 @@ export default {
:mode="mode"
:required="validateRequired"
:label="t('harvester.fields.size')"
:disabled="isLonghornV2"
:disabled="isResizeDisabled"
:suffix="GIBIBYTE"
@update:value="update"
/>
@@ -466,7 +466,9 @@ export default {
}
}
const errors = this.getAccessCredentialsValidation();
const cpuMemoryErrors = this.getCPUMemoryValidation();
const accessCredentialsErrors = this.getAccessCredentialsValidation();
const errors = [...cpuMemoryErrors, ...accessCredentialsErrors];
if (errors.length > 0) {
return Promise.reject(errors);
@@ -604,8 +606,11 @@ export default {
>
<CpuMemory
:cpu="cpu"
:max-cpu="maxCpu"
:memory="memory"
:max-memory="maxMemory"
:mode="mode"
:enable-hot-plug="cpuMemoryHotplugEnabled"
@updateCpuMemory="updateCpuMemory"
/>
+44 -3
View File
@@ -157,6 +157,10 @@ harvester:
hotplug:
success: 'Volume { diskName } is mounted to the virtual machine { vm }.'
title: Add Volume
cpuMemoryHotplug:
success: 'CPU and Memory are updated to the virtual machine { vm }.'
title: Edit CPU and Memory
maxResourcesMessage: 'You can increase the CPU to maximum { maxCpu }C and memory to maximum { maxMemory }.'
hotunplug:
success: 'Volume { name } is detached successfully.'
snapshot:
@@ -213,6 +217,7 @@ harvester:
suspendSchedule: Suspend
restoreExistingVM: Replace Existing
migrate: Migrate
cpuAndMemoryHotplug: Edit CPU and Memory
abortMigration: Abort Migration
createTemplate: Generate Template
enableMaintenance: Enable Maintenance Mode
@@ -571,6 +576,10 @@ harvester:
virtualMachine:
label: Virtual Machines
osType: OS Type
hotplug:
title: Enable CPU and memory hotplug
tooltip: The default maximum CPU and maximum memory are {hotPlugTimes} times based on CPU and memory.
restartVMMessage: Restart action is required for the virtual machine configuration change to take effect
instance:
singleInstance:
multipleInstance:
@@ -716,6 +725,9 @@ harvester:
invalidUser: Invalid Username.
input:
name: Name
cpu: CPU
maxCpu: Maximum CPU
maxMemory: Maximum Memory
memory: Memory
image: Image
sshKey: SSHKey
@@ -1017,7 +1029,7 @@ harvester:
progress: Restore in progress
complete: Restore completed
subnet:
subnet:
cidrBlock:
tooltip: The subnet range in CIDR notation. Note that the CIDR blocks of different Subnets' within the same VPC cannot overlap.
label: CIDR Block
@@ -1068,8 +1080,6 @@ harvester:
placeholder: e.g. 169.254.0.1/16
remoteVpc:
label: Remote VPC
network:
label: Virtual Machine Networks
tabs:
@@ -1126,6 +1136,9 @@ harvester:
publicCertificate: Public Certificate
privateKey: Private Key
ca: CA
rancherCluster:
kubeConfig: Rancher KubeConfig
removeUpstreamClusterWhenNamespaceIsDeleted: Remove Upstream Cluster When Namespace Is Deleted
storageNetwork:
range:
placeholder: e.g. 172.16.0.0/24
@@ -1369,6 +1382,32 @@ harvester:
allowedTopologies:
title: Allowed Topologies
tooltip: Allowed Topologies helps scheduling virtual machines on hosts which match all of below expressions.
internal:
label: Internal Storage Class
cannotDeleteTooltip: Internal storage class volumes cannot be deleted
cannotDeleteOrDefaultTooltip: Internal storage classes cannot be deleted or set as default
cdiSettings:
label: CDI Settings
volumeModeAccessModes:
label: Volume Mode / Access Modes
tooltip: Specifies the default volume mode and access modes.
add: Add
volumeMode: Volume Mode
accessModes:
label: Access Modes
volumeSnapshotClass:
label: Volume Snapshot Class
tooltip: Sets the Volume Snapshot Class name to be used when taking snapshots of PVCs under this StorageClass.
cloneStrategy:
label: Clone Strategy
tooltip: Defines the clone strategy to use for volumes created with this StorageClass.
copy: Copy
snapshot: Snapshot
csiClone: CSI Clone
fileSystemOverhead:
label: File System Overhead
tooltip: Specifies the percentage of filesystem overhead to consider when calculating PVC size.
placeholder: e.g. 0.05 (up to 3 decimal places, between 0 and 1)
vlanConfig:
title: Network Configuration
@@ -1704,6 +1743,8 @@ advancedSettings:
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
'harv-upgrade-config': 'Configure image preloading and VM restore options for upgrades. See related fields in <a href="{url}" target="_blank" rel="noopener">settings/upgrade-config</a>'
'harv-vm-migration-network': 'Segregated network for VM migration traffic.'
'harv-rancher-cluster': 'Configure Rancher cluster integration settings for guest cluster management.'
'harv-max-hotplug-ratio': 'The ratio for kubevirt to limit the maximum CPU and memory that can be hotplugged to a VM. The value could be an integer between 1 and 20, default to 4.'
typeLabel:
kubevirt.io.virtualmachine: |-
+29 -1
View File
@@ -83,6 +83,34 @@ export default {
:rows="rows"
:schema="schema"
:headers="headers"
/>
>
<template #cell:name="{ row }">
<td>
<div>
<router-link
v-if="row?.detailLocation"
:to="row.detailLocation"
>
{{ row.nameDisplay }}
<i
v-if="row.isInternalStorageClass && typeof row.isInternalStorageClass === 'function' ? row.isInternalStorageClass() : false"
v-clean-tooltip="t('harvester.storage.internal.cannotDeleteOrDefaultTooltip')"
class="icon icon-info text-info"
style="margin-left: 0.4em;"
/>
</router-link>
<span v-else>
{{ row.nameDisplay }}
<i
v-if="row.isInternalStorageClass && typeof row.isInternalStorageClass === 'function' ? row.isInternalStorageClass() : false"
v-clean-tooltip="t('harvester.storage.internal.cannotDeleteOrDefaultTooltip')"
class="icon icon-info text-info"
style="margin-left: 0.4em;"
/>
</span>
</div>
</td>
</template>
</ResourceTable>
</div>
</template>
@@ -3,6 +3,8 @@ import ResourceTable from '@shell/components/ResourceTable';
import { Banner } from '@components/Banner';
import { defaultTableSortGenerationFn } from '@shell/components/ResourceTable.vue';
import FilterLabel from '../components/FilterLabel';
import { HCI as HCI_ANNOTATIONS } from '../config/labels-annotations';
import { isInternalStorageClass } from '../utils/storage-class';
export default {
name: 'ListHarvesterImage',
@@ -53,6 +55,13 @@ export default {
return base;
},
isInternalStorageClass(row) {
const name = row?.spec?.targetStorageClassName ||
row?.metadata?.annotations?.[HCI_ANNOTATIONS.STORAGE_CLASS];
return isInternalStorageClass(name);
},
}
};
</script>
@@ -8,6 +8,7 @@ import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
import HarvesterVolumeState from '../formatters/HarvesterVolumeState';
import { allSettled } from '../utils/promise';
import { HCI, VOLUME_SNAPSHOT } from '../types';
import { INTERNAL_STORAGE_CLASS } from '../config/types';
const schema = {
id: HCI.VOLUME,
@@ -130,6 +131,10 @@ export default {
getVMName(row) {
return row.attachVM?.metadata?.name || '';
},
isInternalStorageClass(storageClassName) {
return this.$store.getters['type-map/labelFor'](INTERNAL_STORAGE_CLASS, storageClassName);
}
},
@@ -170,7 +175,7 @@ export default {
</router-link>
</div>
</template>
<template #col:name="{row}">
<template #col:name="{ row }">
<td>
<span>
<router-link
@@ -22,8 +22,8 @@ export const VM_HEADERS = [
{
name: 'CPU',
label: 'CPU',
sort: ['spec.template.spec.domain.cpu.cores'],
value: 'spec.template.spec.domain.cpu.cores',
sort: ['displayCPU'],
value: 'displayCPU',
align: 'center',
dashIfEmpty: true,
},
+71 -9
View File
@@ -3,10 +3,9 @@ import jsyaml from 'js-yaml';
import isEqual from 'lodash/isEqual';
import isEmpty from 'lodash/isEmpty';
import difference from 'lodash/difference';
import { sortBy } from '@shell/utils/sort';
import { set } from '@shell/utils/object';
import { getVmCPUMemoryValues } from '../../utils/cpuMemory';
import { allHash } from '@shell/utils/promise';
import { randomStr } from '@shell/utils/string';
import { base64Decode } from '@shell/utils/crypto';
@@ -164,6 +163,9 @@ export default {
deleteAgent: true,
memory: null,
cpu: '',
maxMemory: null,
maxCpu: '',
cpuMemoryHotplugEnabled: false,
reservedMemory: null,
accessCredentials: [],
efiEnabled: false,
@@ -348,8 +350,11 @@ export default {
const runStrategy = spec.runStrategy || 'RerunOnFailure';
const machineType = spec.template.spec.domain?.machine?.type || this.machineTypes[0];
const cpu = spec.template.spec.domain?.cpu?.cores;
const memory = spec.template.spec.domain.resources.limits.memory;
const {
cpu, memory, maxCpu, maxMemory, isHotplugEnabled
} = getVmCPUMemoryValues(vm);
const cpuMemoryHotplugEnabled = isHotplugEnabled;
const reservedMemory = vm.metadata?.annotations?.[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
const terminationGracePeriodSeconds = spec.template.spec?.terminationGracePeriodSeconds || this.defaultTerminationSetting;
@@ -409,6 +414,9 @@ export default {
this['cpu'] = cpu;
this['memory'] = memory;
this['maxCpu'] = maxCpu;
this['maxMemory'] = maxMemory;
this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled;
this['reservedMemory'] = reservedMemory;
this['machineType'] = machineType;
this['terminationGracePeriodSeconds'] = terminationGracePeriodSeconds;
@@ -633,20 +641,26 @@ export default {
this.spec.template.spec.domain.machine['type'] = this.machineType;
}
this.spec.template.spec.domain.cpu.cores = this.cpu;
this.spec.template.spec.domain.resources.limits.cpu = this.cpu ? this.cpu.toString() : this.cpu;
this.spec.template.spec.domain.resources.limits.memory = this.memory;
this.setCPUAndMemory();
// update terminationGracePeriodSeconds
this.spec.template.spec.terminationGracePeriodSeconds = this.terminationGracePeriodSeconds;
// parse reserved memory
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
// parse reserved memory
if (!this.reservedMemory) {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
} else {
vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY] = this.reservedMemory;
}
// add or remove cpu memory hotplug annotation
if (this.cpuMemoryHotplugEnabled) {
vm.metadata.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG] = this.cpuMemoryHotplugEnabled.toString();
} else {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG];
}
if (this.maintenanceStrategy === 'Migrate') {
delete vm.metadata.labels[HCI_ANNOTATIONS.VM_MAINTENANCE_MODE_STRATEGY];
} else {
@@ -654,6 +668,36 @@ export default {
}
},
setCPUAndMemory() {
if (this.cpuMemoryHotplugEnabled) {
// set CPU
this.spec.template.spec.domain.cpu.sockets = this.cpu;
this.spec.template.spec.domain.cpu.cores = 1;
// set max CPU
set(this.spec.template.spec, 'domain.cpu.maxSockets', this.maxCpu);
// domain.resources.limits.cpu and memory are defined by k8s which requires string values
// see https://kubernetes.io/docs/tasks/configure-pod-container/assign-cpu-resource/
set(this.spec.template.spec, 'domain.resources.limits.cpu', this.maxCpu?.toString());
// set memory
set(this.spec.template.spec, 'domain.memory.guest', this.memory);
// set max memory
set(this.spec.template.spec, 'domain.memory.maxGuest', this.maxMemory);
set(this.spec.template.spec, 'domain.resources.limits.memory', this.maxMemory);
} else {
this.spec.template.spec.domain.cpu.sockets = 1;
this.spec.template.spec.domain.cpu.cores = this.cpu;
this.spec.template.spec.domain.resources.limits.cpu = this.cpu?.toString();
this.spec.template.spec.domain.resources.limits.memory = this.memory;
// clean
delete this.spec.template.spec.resources;
delete this.spec.template.spec.domain.memory;
delete this.spec.template.spec.domain.cpu.maxSockets;
}
},
parseDiskRows(disk) {
const disks = [];
const volumes = [];
@@ -952,9 +996,12 @@ export default {
this['sshKey'] = neu;
},
updateCpuMemory(cpu, memory) {
updateCpuMemory(cpu, memory, maxCpu = '', maxMemory = null, cpuMemoryHotplugEnabled = false) {
this['cpu'] = cpu;
this['memory'] = memory;
this['maxCpu'] = maxCpu;
this['maxMemory'] = maxMemory;
this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled;
},
parseDisk(R, index) {
@@ -1311,6 +1358,21 @@ export default {
}
},
getCPUMemoryValidation() {
const errors = [];
const { cpu, memory } = this;
if ((!cpu)) {
errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.cpu') }, true));
}
if ((!memory)) {
errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.memory') }, true));
}
return errors;
},
getAccessCredentialsValidation() {
const errors = [];
@@ -4,6 +4,7 @@ import { HCI } from '../../types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../config/harvester';
import { LONGHORN_DRIVER } from '@shell/config/types';
import { DATA_ENGINE_V1, DATA_ENGINE_V2 } from '../../models/harvester/persistentvolumeclaim';
import { isInternalStorageClass } from '../../utils/storage-class';
export const LVM_DRIVER = 'lvm.driver.harvesterhci.io';
@@ -85,4 +86,22 @@ export default class HciStorageClass extends StorageClass {
get thirdPartyStorageFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
}
isInternalStorageClass() {
return isInternalStorageClass(this.metadata?.name);
}
get availableActions() {
let out = super.availableActions || [];
out = out.map((action) => {
if ((action.action === 'setDefault' || action.action === 'setAsDefault' || action.action === 'promptRemove') && this.isInternalStorageClass()) {
return { ...action, enabled: false };
}
return action;
});
return out;
}
}
@@ -25,6 +25,10 @@ export default class HciVmBackup extends HarvesterResource {
return detailLocation;
}
get disableResourceDetailDrawerConfigTab() {
return true;
}
get doneOverride() {
const route = this.currentRoute();
const detailLocation = clone(this._detailLocation);
@@ -13,6 +13,7 @@ import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
import { BACKUP_TYPE } from '../config/types';
import { HCI } from '../types';
import HarvesterResource from './harvester';
import { getVmCPUMemoryValues } from '../utils/cpuMemory';
export const OFF = 'Off';
@@ -31,8 +32,6 @@ const PAUSED_VM_MODAL_MESSAGE =
const POD_STATUS_NOT_SCHEDULABLE = 'POD_NOT_SCHEDULABLE';
const POD_STATUS_CONTAINER_FAILING = 'POD_CONTAINER_FAILING';
// eslint-disable-next-line no-unused-vars
const POD_STATUS_NOT_READY = 'POD_NOT_READY';
const POD_STATUS_FAILED = 'POD_FAILED';
const POD_STATUS_CRASHLOOP_BACKOFF = 'POD_CRASHLOOP_BACKOFF';
@@ -169,6 +168,12 @@ export default class VirtVm extends HarvesterResource {
icon: 'icon icon-storage',
label: this.t('harvester.action.editVMQuota')
},
{
action: 'cpuMemoryHotplug',
enabled: !!this.actions?.cpuAndMemoryHotplug,
icon: 'icon icon-os-management',
label: this.t('harvester.action.cpuAndMemoryHotplug')
},
{
action: 'createSchedule',
enabled: this.schedulingVMBackupFeatureEnabled,
@@ -476,6 +481,13 @@ export default class VirtVm extends HarvesterResource {
});
}
cpuMemoryHotplug(resources = this) {
this.$dispatch('promptModal', {
resources,
component: 'HarvesterCPUMemoryHotPlugDialog'
});
}
abortMigrationVM() {
this.doActionGrowl('abortMigration', {});
}
@@ -728,6 +740,7 @@ export default class VirtVm extends HarvesterResource {
if (this &&
!this.isVMExpectedRunning &&
this.isVMCreated &&
this.vmi?.status?.phase !== undefined &&
this.vmi?.status?.phase !== VMIPhase.Succeeded &&
this.vmi?.status?.phase !== VMIPhase.Pending
) {
@@ -1091,19 +1104,6 @@ export default class VirtVm extends HarvesterResource {
maxLength: 63,
translationKey: 'harvester.fields.name'
},
{
nullable: false,
path: 'spec.template.spec.domain.cpu.cores',
min: 1,
required: true,
translationKey: 'harvester.fields.cpu'
},
{
nullable: false,
path: 'spec.template.spec.domain.resources.limits.memory',
required: true,
translationKey: 'harvester.fields.memory'
},
{
nullable: false,
path: 'spec.template.spec',
@@ -1127,12 +1127,11 @@ export default class VirtVm extends HarvesterResource {
}
get memorySort() {
const memory =
this?.spec?.template?.spec?.domain?.resources?.requests?.memory || 0;
const memory = getVmCPUMemoryValues(this).memory;
const formatSize = parseSi(memory);
return parseInt(formatSize);
return parseInt(formatSize, 10);
}
get ingoreVMMessage() {
@@ -1158,14 +1157,29 @@ export default class VirtVm extends HarvesterResource {
}
get stateDescription() {
const conditions = get(this, 'status.conditions');
const restartRequired = findBy(conditions, 'type', 'RestartRequired');
if (restartRequired && restartRequired.status === 'True') {
return this.t('harvester.virtualMachine.hotplug.restartVMMessage');
}
const restoreConditions = this.restoreResource?.status?.conditions || [];
const restoreError = restoreConditions.find((s) => s.error === true && s.reason === 'Error' && s.type === 'Ready');
if (restoreError?.message) {
return restoreError.message;
}
return this.ingoreVMMessage ? '' : super.stateDescription;
}
get displayCPU() {
return getVmCPUMemoryValues(this).cpu;
}
get displayMemory() {
return (
this.spec.template.spec.domain.resources?.limits?.memory ||
this.spec.template.spec.domain.resources?.requests?.memory
);
return getVmCPUMemoryValues(this).memory;
}
get isQemuInstalled() {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "harvester",
"description": "Rancher UI Extension for Harvester",
"version": "1.6.0-rc2",
"version": "1.6.0-rc6",
"private": false,
"rancher": {
"annotations": {
+36
View File
@@ -0,0 +1,36 @@
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export function getVmCPUMemoryValues(vm) {
if (!vm) {
return {
cpu: 0,
memory: null,
isHotplugEnabled: false
};
}
const isHotplugEnabled = isCPUMemoryHotPlugEnabled(vm);
if (isHotplugEnabled) {
return {
cpu: vm.spec.template.spec.domain.cpu.sockets,
memory: vm.spec.template.spec.domain?.memory?.guest || null,
maxCpu: vm.spec.template.spec.domain.cpu?.maxSockets || 0,
maxMemory: vm.spec.template.spec.domain?.memory?.maxGuest || null,
isHotplugEnabled
};
} else {
return {
cpu: vm.spec.template.spec.domain.cpu.cores,
memory: vm.spec.template.spec.domain.resources?.limits?.memory || null,
isHotplugEnabled
};
}
}
export function isCPUMemoryHotPlugEnabled(vm) {
return vm?.metadata?.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG] === 'true' ||
!!vm?.spec?.template?.spec?.domain.cpu?.maxSockets ||
!!vm?.spec?.template?.spec?.domain?.memory?.maxGuest ||
false;
}
+17 -13
View File
@@ -8,7 +8,6 @@ export const docLink = (suffix, version) => {
};
export function getVersion(v) {
// e.g v1.4.0
if (process.env.VUE_APP_SERVER_VERSION) {
return process.env.VUE_APP_SERVER_VERSION;
}
@@ -18,20 +17,21 @@ export function getVersion(v) {
return `v${ semver.major(v) }.${ semver.minor(v) }.${ semver.patch(v) }`;
} catch (error) {
// fallback to the latest version
return latestVersion(Object.keys(RELEASE_FEATURES));
return getLatestVersion();
}
}
function latestVersion(versions) {
return versions.sort((a, b) => semver.compare(a, b)).pop();
function getLatestVersion() {
const allVersions = Object.keys(RELEASE_FEATURES).filter(semver.valid).sort(semver.rcompare);
return allVersions[0] || '';
}
// v1.3.3 => latest v1.3.x, v1.4.2 => latest v1.4.x
function latestMinorVersion(v) {
const minor = `v${ semver.major(v) }.${ semver.minor(v) }`;
const minorVersions = Object.keys(RELEASE_FEATURES).filter((version) => version.startsWith(minor));
function getLatestCompatibleVersion(version) {
const allVersions = Object.keys(RELEASE_FEATURES).filter(semver.valid);
const compatible = allVersions.filter((v) => semver.lte(v, version)).sort(semver.rcompare);
return latestVersion(minorVersions);
return compatible[0] || getLatestVersion();
}
export const featureEnabled = (featureKey, serverVersion) => {
@@ -46,12 +46,16 @@ export const featureEnabled = (featureKey, serverVersion) => {
return false;
}
let releasedFeatures = RELEASE_FEATURES[version];
const compatibleVersion = getLatestCompatibleVersion(version);
const releasedFeatures = RELEASE_FEATURES?.[compatibleVersion];
if (!releasedFeatures) {
const fallback = latestMinorVersion(version);
if (!Array.isArray(releasedFeatures)) {
// eslint-disable-next-line no-console
console.error(
`Feature flags for version ${ version } are not defined. Please upgrade Harvester UI extension and check the support matrix.`
);
releasedFeatures = RELEASE_FEATURES[fallback];
return false;
}
return releasedFeatures.includes(featureKey);
+6
View File
@@ -0,0 +1,6 @@
import { INTERNAL_STORAGE_CLASS } from '../config/types';
export function isInternalStorageClass(name) {
return name === INTERNAL_STORAGE_CLASS.VMSTATE_PERSISTENCE ||
name === INTERNAL_STORAGE_CLASS.LONGHORN_STATIC;
}
+9 -9
View File
@@ -3133,15 +3133,15 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@rancher/icons@2.0.29":
version "2.0.29"
resolved "https://registry.yarnpkg.com/@rancher/icons/-/icons-2.0.29.tgz#6546d69768c706bebd66bfa73b36aef3d105987a"
integrity sha512-qBBqfazS9y5VjV7fJDPNXmxd9AP/2uiE05mKFWP41kpbO+tEb62RnUBXCm14XLeScDZQcOuiAKVHMmvCFzF0BA==
"@rancher/icons@2.0.37":
version "2.0.37"
resolved "https://registry.yarnpkg.com/@rancher/icons/-/icons-2.0.37.tgz#61dd947009a4a3d27b2e4dca630da05583925744"
integrity sha512-T2FqiZraz9yte9tNMCGqIDQVPwXKTAPUTE8eAaQjhOJEbwfk/ttXDIKGYZ9mHApJX6s8JYVXAbRPcgXgc4zKpA==
"@rancher/shell@3.0.5-rc.6":
version "3.0.5-rc.6"
resolved "https://registry.yarnpkg.com/@rancher/shell/-/shell-3.0.5-rc.6.tgz#8d842cf1b0f7e3e52af53538953cf9dc5bfd0639"
integrity sha512-B9+2WE1a06wRvCN7xvxp/HBdjrib04szIbc1pNBeMdDNCqQmZMvt2dftjYb44Tvg+4Ji8G+tcK8Q3Rry6NDJsw==
"@rancher/shell@3.0.5-rc.7":
version "3.0.5-rc.7"
resolved "https://registry.yarnpkg.com/@rancher/shell/-/shell-3.0.5-rc.7.tgz#a3c3f555c9459b312546fb577e9916cd48126fb2"
integrity sha512-YxiRteWwTnuCGEh2AqIyH6Vx/PJXSwugi7dpf2U+XnQLqQHYM8pFo38jG8Tmj95KqZsRMLXdesRow/Icq96D5w==
dependencies:
"@aws-sdk/client-ec2" "3.658.1"
"@aws-sdk/client-eks" "3.1.0"
@@ -3153,7 +3153,7 @@
"@babel/preset-typescript" "7.16.7"
"@novnc/novnc" "1.2.0"
"@popperjs/core" "2.11.8"
"@rancher/icons" "2.0.29"
"@rancher/icons" "2.0.37"
"@types/is-url" "1.2.30"
"@types/node" "20.10.8"
"@types/semver" "^7.5.8"