Compare commits

...
19 Commits
Author SHA1 Message Date
mergify[bot]andAndy Lee e4c85f510e chore: bump to v1.7.0-rc4 (#621) (#629)
(cherry picked from commit 3d7b96d86d)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-28 13:23:55 +08:00
mergify[bot]andCaio Torres f391f018de fix: create new secret on vm creation (#614) (#628)
* fix: create new secret on vm creation



* fix: ensure parseVM result is immutable



---------


(cherry picked from commit 0b37467f76)

Signed-off-by: Caio Torres <caio.torres@suse.com>
Co-authored-by: Caio Torres <caio.torres@suse.com>
2025-11-27 17:42:46 +08:00
renovate[bot] 4b2e92ea15 deps: update dependency node-forge to v1.3.2 [security] (#627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 14:31:50 +08:00
mergify[bot]andAndy Lee 2f956d5946 feat: allow user to attach volume to muliple VMs (#620) (#622)
(cherry picked from commit d94003f8c2)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-27 14:30:44 +08:00
renovate[bot] 5f8d556ea2 deps: update patch digest dependencies (#619)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-23 09:32:21 +00:00
mergify[bot] 396ab48f1c chore: bump version to v1.7.0-rc3 (#612) (#613) 2025-11-20 10:25:03 +08:00
mergify[bot]andYiya Chen 6b8c079018 fix: condition render namespaceOptions (#607) (#611)
(cherry picked from commit 1b183febdc)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-11-19 17:40:03 +08:00
mergify[bot]andAndy Lee 7f638e86c8 fix: change migConfiguration model to inherit from harvester resource (#608) (#609)
(cherry picked from commit 70d3b656f7)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-19 17:35:52 +08:00
mergify[bot]andAndy Lee 0c4955a766 feat: create related image storageclass before OS upgrade (#595) (#606)
* feat: create related image SC before upgrade



* refactor: update spec.targetStorageClassName



* refactor: based on comment



---------


(cherry picked from commit 10d19cd329)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-17 17:34:51 +08:00
mergify[bot]andYiya Chen 3f4ff30275 feat: modified placeholder (#599) (#605)
(cherry picked from commit 1715ae754c)

Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-11-17 17:08:20 +08:00
renovate[bot] 8e0332a364 deps: update dependency @types/node to v20.19.25 (#604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 13:03:17 +00:00
6700b2055e feat: add support for configuring transparent hugepages (#414) (#598)
* feat: add support for configuring transparent hugepages

Related-to: https://github.com/harvester/harvester/issues/5006



* fix: return empty object if hugepages can't be found for node

Related-to: https://github.com/harvester/harvester/issues/5006




---------




(cherry picked from commit 30de2b1a18)

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Tim Serong <tserong@suse.com>
Co-authored-by: Moritz Röhrich <moritz.rohrich@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-12 14:15:27 +08:00
mergify[bot]andAndy Lee e486852f7a chore: bump version to v1.7.0-rc2 (#596) (#597)
(cherry picked from commit 6fedcc353c)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-12 09:21:01 +08:00
mergify[bot]andYiya Chen c19341bec9 feat: support for HotPlugNICs from Kubevirt (#582) (#594)
* refactor: rename hotplug volume
* feat: add hotplug NIC
* feat: add hot unplug
* refactor: rename NIC
* feat: get latest status
* feat: disable not ready options
* feat: filter out system networks
* refactor: update wordings

---------


(cherry picked from commit f9bff21e84)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-11-11 11:46:43 +08:00
mergify[bot]andAndy Lee 8cbb9d6b18 chore: update yarn.lock for @rancher/shell v3.0.8-rc.8 (#591) (#592)
(cherry picked from commit 6735826e15)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-10 16:19:48 +08:00
mergify[bot]andAndy Lee 00f0953592 chore: bump shell version to v3.0.8-rc.8 (#588) (#590)
(cherry picked from commit 9e17e239cf)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-10 15:05:04 +08:00
mergify[bot]andAndy Lee 58507f0b2e ci: lint last commit if is empty string or all zero (#584) (#586)
(cherry picked from commit db58024351)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-06 15:53:50 +08:00
mergify[bot]andTim Serong 7785d7f469 feat: enable snapshot and clone for LHv2 (#379) (#587)
Now that Longhorn supports volume clone with the V2 data engine, we
can enable volume snapshot and clone.

Related issue: https://github.com/harvester/harvester/issues/6710


(cherry picked from commit a1cf41bda9)

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Tim Serong <tserong@suse.com>
2025-11-06 15:53:20 +08:00
mergify[bot]andAndy Lee 2c043e0a8e chore: bump version to v1.7.0-rc1 (#583) (#585)
(cherry picked from commit 81bf19419c)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-04 16:12:14 +08:00
29 changed files with 1878 additions and 737 deletions
+16 -3
View File
@@ -41,8 +41,14 @@ jobs:
FROM="$GITHUB_BASE_SHA" FROM="$GITHUB_BASE_SHA"
TO="$GITHUB_HEAD_SHA" TO="$GITHUB_HEAD_SHA"
elif [ -n "$GITHUB_BEFORE" ] && [ -n "$GITHUB_AFTER" ]; then elif [ -n "$GITHUB_BEFORE" ] && [ -n "$GITHUB_AFTER" ]; then
FROM="$GITHUB_BEFORE" if [ "$GITHUB_BEFORE" = "0000000000000000000000000000000000000000" ]; then
TO="$GITHUB_AFTER" # first push to HEAD
FROM=""
TO="$GITHUB_AFTER"
else
FROM="$GITHUB_BEFORE"
TO="$GITHUB_AFTER"
fi
else else
echo "No valid commit range found, skipping commitlint." echo "No valid commit range found, skipping commitlint."
exit 0 exit 0
@@ -51,7 +57,14 @@ jobs:
echo "FROM=$FROM" echo "FROM=$FROM"
echo "TO=$TO" echo "TO=$TO"
npx commitlint --from "$FROM" --to "$TO" --verbose if [ -z "$FROM" ]; then
echo "Linting last commit $TO"
npx commitlint --last --verbose
else
echo "Linting commits from $FROM to $TO"
npx commitlint --from "$FROM" --to "$TO" --verbose
fi
env: env:
GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+4 -4
View File
@@ -1,13 +1,13 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.7.0-dev", "version": "1.7.0-rc4",
"private": false, "private": false,
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-class-static-block": "7.28.3", "@babel/plugin-transform-class-static-block": "7.28.3",
"@rancher/shell": "3.0.5-rc.8", "@rancher/shell": "3.0.8-rc.8",
"cache-loader": "^4.1.0", "cache-loader": "^4.1.0",
"color": "4.2.3", "color": "4.2.3",
"ip": "2.0.1", "ip": "2.0.1",
@@ -24,9 +24,9 @@
"glob": "7.2.3", "glob": "7.2.3",
"glob-parent": "6.0.2", "glob-parent": "6.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"@types/lodash": "4.17.20", "@types/lodash": "4.17.21",
"merge": "2.1.1", "merge": "2.1.1",
"node-forge": "1.3.1", "node-forge": "1.3.2",
"nth-check": "2.1.1", "nth-check": "2.1.1",
"qs": "6.14.0", "qs": "6.14.0",
"roarr": "7.21.2", "roarr": "7.21.2",
@@ -18,16 +18,20 @@ export default {
await this.$store.dispatch('harvester/findAll', { type: NAMESPACE }); await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
try { if (this.customSupportBundleFeatureEnabled) {
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/namespaces?link=supportbundle'); try {
const response = await this.$store.dispatch('harvester/request', { url }); const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/namespaces?link=supportbundle');
const response = await this.$store.dispatch('harvester/request', { url });
this.defaultNamespaces = response.data || []; this.defaultNamespaces = response.data || [];
} catch (error) { } catch (error) {
this.defaultNamespaces = [];
}
} else {
this.defaultNamespaces = []; this.defaultNamespaces = [];
} finally {
this.loading = false;
} }
this.loading = false;
}, },
data() { data() {
@@ -42,24 +46,38 @@ export default {
}, },
computed: { computed: {
customSupportBundleFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('customSupportBundle');
},
allNamespaces() { allNamespaces() {
return this.$store.getters['harvester/all'](NAMESPACE).map((ns) => ns.id); return this.$store.getters['harvester/all'](NAMESPACE).map((ns) => ns.id);
}, },
filteredNamespaces() { filteredNamespaces() {
if (!this.customSupportBundleFeatureEnabled) {
return this.allNamespaces;
}
const defaultIds = this.defaultNamespaces.map((ns) => ns.id); const defaultIds = this.defaultNamespaces.map((ns) => ns.id);
return this.allNamespaces.filter((ns) => !defaultIds.includes(ns)); return this.allNamespaces.filter((ns) => !defaultIds.includes(ns));
}, },
namespaceOptions() { namespaceOptions() {
const mappedNamespaces = this.filteredNamespaces.map((ns) => ({ label: ns, value: ns }));
if (!this.customSupportBundleFeatureEnabled) {
return mappedNamespaces;
}
const allSelected = const allSelected =
this.namespaces.length === this.filteredNamespaces.length && this.namespaces.length === this.filteredNamespaces.length &&
this.filteredNamespaces.every((ns) => this.namespaces.includes(ns)); this.filteredNamespaces.every((ns) => this.namespaces.includes(ns));
const controlOption = allSelected ? { label: this.t('harvester.modal.bundle.namespaces.unselectAll'), value: UNSELECT_ALL } : { label: this.t('harvester.modal.bundle.namespaces.selectAll'), value: SELECT_ALL }; const controlOption = allSelected ? { label: this.t('harvester.modal.bundle.namespaces.unselectAll'), value: UNSELECT_ALL } : { label: this.t('harvester.modal.bundle.namespaces.selectAll'), value: SELECT_ALL };
return [controlOption, ...this.filteredNamespaces]; return [controlOption, ...mappedNamespaces];
} }
}, },
+1
View File
@@ -9,4 +9,5 @@ export const DOC = {
SUPPORT_BUNDLE_NAMESPACES: `/advanced/index/#support-bundle-namespaces`, SUPPORT_BUNDLE_NAMESPACES: `/advanced/index/#support-bundle-namespaces`,
VPC_CONFIGURATION_EXAMPLES: `/networking/kubeovn-vpc#vpc-peering-configuration-examples`, VPC_CONFIGURATION_EXAMPLES: `/networking/kubeovn-vpc#vpc-peering-configuration-examples`,
NETWORK_POLICY: `/networking/kubeovn-vm-isolation/#network-policies`, NETWORK_POLICY: `/networking/kubeovn-vm-isolation/#network-policies`,
TRANSPARENT_HUGEPAGES: `https://docs.kernel.org/admin-guide/mm/transhuge.html`,
}; };
+2 -1
View File
@@ -53,7 +53,8 @@ const FEATURE_FLAGS = {
'vmMachineTypeAuto', 'vmMachineTypeAuto',
'lhV2VolExpansion', 'lhV2VolExpansion',
'l2VlanTrunkMode', 'l2VlanTrunkMode',
'kubevirtMigration' 'kubevirtMigration',
'hotplugNic'
] ]
}; };
@@ -0,0 +1,119 @@
<script>
import LabelValue from '@shell/components/LabelValue';
import { HCI } from '../../types';
import { DOC } from '../../config/doc-links';
export default {
name: 'HarvesterHugepages',
components: { LabelValue },
props: {
node: {
type: Object,
required: true,
},
},
computed: {
docsTransparentHugepagesLink() {
return DOC.TRANSPARENT_HUGEPAGES;
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HUGEPAGES });
this.hugepages = hash.find((node) => {
return node.id === this.node.id;
}) || {};
},
data() {
return { hugepages: {} };
},
};
</script>
<template>
<div>
<template v-if="hugepages.status">
<h2>{{ t('harvester.host.hugepages.meminfo') }}</h2>
<div class="row mb-20">
<div class="col span-6">
<LabelValue
:name="t('harvester.host.hugepages.status.anon')"
:value="hugepages.status.meminfo.anonHugePages"
/>
</div>
<div class="col span-6">
<LabelValue
:name="t('harvester.host.hugepages.status.size')"
:value="hugepages.status.meminfo.hugepageSize"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.total')"
:value="hugepages.status.meminfo.hugePagesTotal"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.free')"
:value="hugepages.status.meminfo.hugePagesFree"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.rsvd')"
:value="hugepages.status.meminfo.hugePagesRsvd"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.surp')"
:value="hugepages.status.meminfo.hugePagesSurp"
/>
</div>
</div>
<div>
<hr class="divider" />
<h3>
<t
k="harvester.host.hugepages.transparent.title"
:raw="true"
:url="docsTransparentHugepagesLink"
/>
</h3>
<div class="row mb-20">
<div class="col span-4">
<LabelValue
:name="t('harvester.host.hugepages.transparent.enabled')"
:value="hugepages.spec.transparent.enabled"
/>
</div>
<div class="col span-4">
<LabelValue
:name="t('harvester.host.hugepages.transparent.shmemEnabled')"
:value="hugepages.spec.transparent.shmemEnabled"
/>
</div>
<div class="col span-4">
<LabelValue
:name="t('harvester.host.hugepages.transparent.defrag')"
:value="hugepages.spec.transparent.defrag"
/>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -27,6 +27,7 @@ import Instance from './VirtualMachineInstance';
import Disk from './HarvesterHostDisk'; import Disk from './HarvesterHostDisk';
import VlanStatus from './VlanStatus'; import VlanStatus from './VlanStatus';
import HarvesterKsmtuned from './HarvesterKsmtuned.vue'; import HarvesterKsmtuned from './HarvesterKsmtuned.vue';
import HarvesterHugepages from './HarvesterHugepages.vue';
import HarvesterSeeder from './HarvesterSeeder'; import HarvesterSeeder from './HarvesterSeeder';
const LONGHORN_SYSTEM = 'longhorn-system'; const LONGHORN_SYSTEM = 'longhorn-system';
@@ -46,6 +47,7 @@ export default {
VlanStatus, VlanStatus,
LabelValue, LabelValue,
HarvesterKsmtuned, HarvesterKsmtuned,
HarvesterHugepages,
Loading, Loading,
SortableTable, SortableTable,
HarvesterSeeder, HarvesterSeeder,
@@ -209,6 +211,12 @@ export default {
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED); return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED);
}, },
hasHugepagesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.HUGEPAGES);
},
hasBlockDevicesSchema() { hasBlockDevicesSchema() {
return !!this.$store.getters['harvester/schemaFor'](HCI.BLOCK_DEVICE); return !!this.$store.getters['harvester/schemaFor'](HCI.BLOCK_DEVICE);
}, },
@@ -468,6 +476,16 @@ export default {
/> />
</Tab> </Tab>
<Tab
v-if="hasHugepagesSchema"
name="hugepages"
:weight="0"
:show-header="false"
:label="t('harvester.host.tabs.hugepages')"
>
<HarvesterHugepages :node="value" />
</Tab>
<Tab <Tab
v-if="seederEnabled" v-if="seederEnabled"
name="seeder" name="seeder"
@@ -213,6 +213,7 @@ export default {
const diskRows = this.getDiskRows(neu); const diskRows = this.getDiskRows(neu);
this['diskRows'] = diskRows; this['diskRows'] = diskRows;
this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false });
}, },
deep: true deep: true
} }
@@ -265,6 +266,7 @@ export default {
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
mode="view" mode="view"
:vm="value"
/> />
</Tab> </Tab>
@@ -32,7 +32,7 @@ export default {
resources: { resources: {
type: Array, type: Array,
required: true required: true
} },
}, },
data() { data() {
@@ -43,7 +43,7 @@ export default {
...mapState('action-menu', ['modalData']), ...mapState('action-menu', ['modalData']),
title() { title() {
return this.modalData.title || 'dialog.promptRemove.title'; return this.modalData?.title || 'dialog.promptRemove.title';
}, },
formattedType() { formattedType() {
@@ -51,7 +51,7 @@ export default {
}, },
warningMessage() { warningMessage() {
if (this.modalData.warningMessage) return this.modalData.warningMessage; if (this.modalData?.warningMessage) return this.modalData.warningMessage;
const isPlural = this.type.endsWith('s'); const isPlural = this.type.endsWith('s');
const thisOrThese = isPlural ? 'these' : 'this'; const thisOrThese = isPlural ? 'these' : 'this';
@@ -145,6 +145,7 @@ export default {
try { try {
for (const resource of this.resources) { for (const resource of this.resources) {
await resource.remove(); await resource.remove();
if (this.modalData?.extraActionAfterRemove) await this.modalData.extraActionAfterRemove();
} }
buttonDone(true); buttonDone(true);
this.close(); this.close();
@@ -0,0 +1,212 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapGetters } from 'vuex';
import { NETWORK_ATTACHMENT } from '@shell/config/types';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { NETWORK_TYPE } from '../config/types';
export default {
name: 'AddHotplugNic',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
try {
this.allVMNetworks = await this.$store.dispatch('harvester/findAll', { type: NETWORK_ATTACHMENT });
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.allVMNetworks = [];
}
},
data() {
return {
interfaceName: '',
networkName: '',
macAddress: '',
allVMNetworks: [],
errors: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources?.[0];
},
isFormValid() {
return this.interfaceName !== '' && this.networkName !== '';
},
vmNetworksOption() {
return this.allVMNetworks
.filter((network) => {
const labels = network.metadata?.labels || {};
const type = labels[HCI_ANNOTATIONS.NETWORK_TYPE];
const isValidType = [
NETWORK_TYPE.L2VLAN,
NETWORK_TYPE.UNTAGGED,
NETWORK_TYPE.L2TRUNK_VLAN,
].includes(type);
return isValidType && !network.isSystem;
})
.map((network) => {
const label = network.isNotReady ? `${ network.id } (${ this.t('generic.notReady') })` : network.id;
return ({
label,
value: network.id || '',
disabled: network.isNotReady,
});
});
}
},
methods: {
close() {
this.interfaceName = '';
this.networkName = '';
this.macAddress = '';
this.errors = [];
this.$emit('close');
},
async save(buttonCb) {
if (!this.actionResource) {
buttonCb(false);
return;
}
const payload = {
interfaceName: this.interfaceName,
networkName: this.networkName
};
if (this.macAddress) {
payload.macAddress = this.macAddress;
}
try {
const res = await this.actionResource.doAction('addNic', payload);
if ([200, 204].includes(res?._status)) {
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.hotplugNic.success', {
interfaceName: this.interfaceName,
vm: this.actionResource.nameDisplay
})
}, { root: true });
this.close();
buttonCb(true);
} else {
this.errors = exceptionToErrorsArray(res);
buttonCb(false);
}
} catch (err) {
this.errors = exceptionToErrorsArray(err);
buttonCb(false);
}
}
}
};
</script>
<template>
<Card
ref="modal"
name="modal"
:show-highlight-border="false"
>
<template #title>
<h4
v-clean-html="t('harvester.modal.hotplugNic.title')"
class="text-default-text"
/>
</template>
<template #body>
<LabeledInput
v-model:value="interfaceName"
:label="t('generic.name')"
required
/>
<LabeledSelect
v-model:value="networkName"
class="mt-20"
:label="t('harvester.modal.hotplugNic.vmNetwork')"
:options="vmNetworksOption"
required
/>
<LabeledInput
v-model:value="macAddress"
class="mt-20"
label-key="harvester.modal.hotplugNic.macAddress"
:tooltip="t('harvester.modal.hotplugNic.macAddressTooltip', _, true)"
/>
<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"
:disabled="!isFormValid"
@click="save"
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -11,7 +11,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
export default { export default {
name: 'HotplugModal', name: 'HotplugVolumeModal',
emits: ['close'], emits: ['close'],
@@ -62,7 +62,7 @@ export default {
return false; return false;
} }
return !pvc.attachVM; return true;
}) })
.map((pvc) => { .map((pvc) => {
return { return {
@@ -90,7 +90,7 @@ export default {
if (res._status === 200 || res._status === 204) { if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', { this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'), title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.hotplug.success', { diskName: this.diskName, vm: this.actionResource.nameDisplay }) message: this.t('harvester.modal.hotplugVolume.success', { diskName: this.diskName, vm: this.actionResource.nameDisplay })
}, { root: true }); }, { root: true });
this.close(); this.close();
@@ -122,7 +122,7 @@ export default {
> >
<template #title> <template #title>
<h4 <h4
v-clean-html="t('harvester.modal.hotplug.title')" v-clean-html="t('harvester.modal.hotplugVolume.title')"
class="text-default-text" class="text-default-text"
/> />
</template> </template>
@@ -1,13 +1,12 @@
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import { exceptionToErrorsArray } from '@shell/utils/error'; import { exceptionToErrorsArray } from '@shell/utils/error';
import { Card } from '@components/Card'; import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
export default { export default {
name: 'HarvesterHotUnplugModal', name: 'HarvesterHotUnplug',
emits: ['close'], emits: ['close'],
@@ -35,8 +34,25 @@ export default {
actionResource() { actionResource() {
return this.resources[0]; return this.resources[0];
}, },
diskName() {
return this.modalData.diskName; name() {
return this.modalData.name;
},
isVolume() {
return this.modalData.type === 'volume';
},
titleKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.title' : 'harvester.virtualMachine.hotUnplug.detachNIC.title';
},
actionLabelKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel' : 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabel';
},
successMessageKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.success' : 'harvester.virtualMachine.hotUnplug.detachNIC.success';
} }
}, },
@@ -47,14 +63,20 @@ export default {
async save(buttonCb) { async save(buttonCb) {
try { try {
const res = await this.actionResource.doAction('removeVolume', { diskName: this.diskName }); let res;
if (this.isVolume) {
res = await this.actionResource.doAction('removeVolume', { diskName: this.name });
} else {
res = await this.actionResource.doAction('removeNic', { interfaceName: this.name });
}
if (res._status === 200 || res._status === 204) { if (res._status === 200 || res._status === 204) {
this.$store.dispatch( this.$store.dispatch(
'growl/success', 'growl/success',
{ {
title: this.t('generic.notification.title.succeed'), title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.hotunplug.success', { name: this.diskName }) message: this.t(this.successMessageKey, { name: this.name })
}, },
{ root: true } { root: true }
); );
@@ -64,14 +86,14 @@ export default {
} else { } else {
const error = [res?.data] || exceptionToErrorsArray(res); const error = [res?.data] || exceptionToErrorsArray(res);
this['errors'] = error; this.errors = error;
buttonCb(false); buttonCb(false);
} }
} catch (err) { } catch (err) {
const error = err?.data || err; const error = err?.data || err;
const message = exceptionToErrorsArray(error); const message = exceptionToErrorsArray(error);
this['errors'] = message; this.errors = message;
buttonCb(false); buttonCb(false);
} }
} }
@@ -87,7 +109,7 @@ export default {
> >
<template #title> <template #title>
<h4 <h4
v-clean-html="t('harvester.virtualMachine.unplug.title', { name: diskName })" v-clean-html="t(titleKey, { name })"
class="text-default-text" class="text-default-text"
/> />
<Banner <Banner
@@ -111,9 +133,9 @@ export default {
<AsyncButton <AsyncButton
mode="apply" mode="apply"
:action-label="t('harvester.virtualMachine.unplug.actionLabel')" :action-label="t(actionLabelKey)"
:waiting-label="t('harvester.virtualMachine.unplug.actionLabel')" :waiting-label="t(actionLabelKey)"
:success-label="t('harvester.virtualMachine.unplug.actionLabel')" :success-label="t(actionLabelKey)"
@click="save" @click="save"
/> />
</div> </div>
@@ -132,4 +154,8 @@ export default {
justify-content: flex-end; justify-content: flex-end;
width: 100%; width: 100%;
} }
::v-deep(.card-title) {
display: block;
}
</style> </style>
@@ -0,0 +1,157 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { HCI } from '../../types';
import { DOC } from '../../config/doc-links';
export const hugepagesTHPEnabledMode = [{
label: 'Always',
value: 'always',
}, {
label: 'Madvise',
value: 'madvise',
}, {
label: 'Never',
value: 'never',
}];
export const hugepagesTHPShmemEnabledMode = [{
label: 'Always',
value: 'always',
}, {
label: 'Within Size',
value: 'within_size',
}, {
label: 'Advise',
value: 'advise',
}, {
label: 'Never',
value: 'never',
}, {
label: 'Deny',
value: 'deny',
}, {
label: 'Force',
value: 'force',
}];
export const hugepagesTHPDefragMode = [{
label: 'Always',
value: 'always',
}, {
label: 'Defer',
value: 'defer',
}, {
label: 'Defer+Madvise',
value: 'defer+madvise',
}, {
label: 'Madvise',
value: 'madvise',
}, {
label: 'Never',
value: 'never'
}];
export default {
name: 'HarvesterHugepages',
components: { LabeledSelect },
props: {
node: {
type: Object,
required: true,
},
registerBeforeHook: {
type: Function,
required: true,
},
},
computed: {
docsTransparentHugepagesLink() {
return DOC.TRANSPARENT_HUGEPAGES;
},
},
methods: {
async saveHugepages() {
this.hugepages['spec'] = this.spec;
await this.hugepages.save().catch((reason) => {
if (reason?.type === 'error') {
this.$store.dispatch('growl/error', {
title: this.t('harvester.notification.title.error'),
message: reason?.message,
}, { root: true });
return Promise.reject(new Error('saveHugepages error'));
}
});
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HUGEPAGES });
this.hugepages = hash.find((node) => {
return node.id === this.node.id;
});
this.spec = this.hugepages.spec;
},
data() {
return {
hugepages: {},
spec: { transparent: {} },
hugepagesTHPEnabledMode,
hugepagesTHPShmemEnabledMode,
hugepagesTHPDefragMode,
};
},
created() {
this.registerBeforeHook(this.saveHugepages, 'saveHugepages');
},
};
</script>
<template>
<div>
<div>
<hr class="divider" />
<h3>
<t
k="harvester.host.hugepages.transparent.title"
:raw="true"
:url="docsTransparentHugepagesLink"
/>
</h3>
<div class="row mb-20">
<div class="col span-4">
<LabeledSelect
v-model:value="spec.transparent.enabled"
:label="t('harvester.host.hugepages.transparent.enabled')"
:options="hugepagesTHPEnabledMode"
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="spec.transparent.shmemEnabled"
:label="t('harvester.host.hugepages.transparent.shmemEnabled')"
:options="hugepagesTHPShmemEnabledMode"
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="spec.transparent.defrag"
:label="t('harvester.host.hugepages.transparent.defrag')"
:options="hugepagesTHPDefragMode"
/>
</div>
</div>
</div>
</div>
</template>
@@ -28,6 +28,7 @@ import { HCI } from '../../types';
import HarvesterDisk from './HarvesterDisk'; import HarvesterDisk from './HarvesterDisk';
import HarvesterSeeder from './HarvesterSeeder'; import HarvesterSeeder from './HarvesterSeeder';
import HarvesterKsmtuned from './HarvesterKsmtuned'; import HarvesterKsmtuned from './HarvesterKsmtuned';
import HarvesterHugepages from './HarvesterHugepages';
import Tags from '../../components/DiskTags'; import Tags from '../../components/DiskTags';
import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
@@ -50,6 +51,7 @@ export default {
ArrayListGrouped, ArrayListGrouped,
HarvesterDisk, HarvesterDisk,
HarvesterKsmtuned, HarvesterKsmtuned,
HarvesterHugepages,
ButtonDropdown, ButtonDropdown,
KeyValue, KeyValue,
Banner, Banner,
@@ -225,6 +227,12 @@ export default {
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED); return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED);
}, },
hasHugepagesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.HUGEPAGES);
},
hasBlockDevicesSchema() { hasBlockDevicesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
@@ -647,6 +655,17 @@ export default {
</template> </template>
</ArrayListGrouped> </ArrayListGrouped>
</Tab> </Tab>
<Tab
v-if="hasHugepagesSchema"
name="Hugepages"
:weight="70"
:label="t('harvester.host.tabs.hugepages')"
>
<HarvesterHugepages
:node="value"
:register-before-hook="registerBeforeHook"
/>
</Tab>
<Tab <Tab
v-if="hasKsmtunedSchema" v-if="hasKsmtunedSchema"
name="Ksmtuned" name="Ksmtuned"
@@ -371,7 +371,7 @@ export default {
<div class="key"> <div class="key">
<input <input
v-model="scope.row.value" v-model="scope.row.value"
:placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')" :placeholder="t('harvester.subnet.excludeIPs.placeholder')"
/> />
</div> </div>
</template> </template>
@@ -1,6 +1,5 @@
<script> <script>
import InfoBox from '@shell/components/InfoBox'; import InfoBox from '@shell/components/InfoBox';
import { NETWORK_ATTACHMENT } from '@shell/config/types'; import { NETWORK_ATTACHMENT } from '@shell/config/types';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
@@ -14,6 +13,13 @@ export default {
components: { InfoBox, Base }, components: { InfoBox, Base },
props: { props: {
vm: {
type: Object,
default: () => {
return {};
}
},
mode: { mode: {
type: String, type: String,
default: 'create' default: 'create'
@@ -32,10 +38,15 @@ export default {
} }
}, },
async fetch() {
await this.fetchHotunplugData();
},
data() { data() {
return { return {
rows: this.addKeyId(clone(this.value)), nameIdx: 1,
nameIdx: 1 rows: this.addKeyId(clone(this.value)),
hotunpluggableNics: new Set(),
}; };
}, },
@@ -64,15 +75,57 @@ export default {
return out; return out;
}, },
canCheckHotunplug() {
return !!this.vm?.actions?.findHotunpluggableNics;
},
vmState() {
return this.vm?.stateDisplay;
}
}, },
watch: { watch: {
value(neu) { value(neu) {
this.rows = neu; this.rows = this.mergeHotplugData(clone(neu));
}, },
vmState(newState, oldState) {
if (newState !== oldState) {
this.fetchHotunplugData();
}
}
}, },
methods: { methods: {
async fetchHotunplugData() {
if (!this.canCheckHotunplug) {
this.rows = this.mergeHotplugData(clone(this.value));
return;
}
try {
const resp = await this.vm.doAction('findHotunpluggableNics');
this.hotunpluggableNics = new Set(resp?.interfaces || []);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to fetch hot-unpluggable NICs:', e);
this.hotunpluggableNics = new Set();
}
this.rows = this.mergeHotplugData(clone(this.value));
},
mergeHotplugData(networks) {
return (networks || []).map((network) => ({
...network,
isHotunpluggable: this.hotunpluggableNics.has(network.name),
rowKeyId: network.rowKeyId || randomStr(10)
}));
},
add(type) { add(type) {
const name = this.generateName(); const name = this.generateName();
@@ -118,7 +171,11 @@ export default {
update() { update() {
this.$emit('update:value', this.rows); this.$emit('update:value', this.rows);
} },
unplugNIC(network) {
this.vm.unplugNIC(network.name);
},
} }
}; };
</script> </script>
@@ -129,17 +186,28 @@ export default {
v-for="(row, i) in rows" v-for="(row, i) in rows"
:key="i" :key="i"
> >
<button <div class="box-title mb-10">
v-if="!isView" <h3>
type="button" {{ t('harvester.virtualMachine.network.title') }}
class="role-link remove-vol" </h3>
@click="remove(row)" <button
> v-if="!isView"
<i class="icon icon-x" /> type="button"
</button> class="role-link btn btn-sm remove"
@click="remove(row)"
<h3> {{ t('harvester.virtualMachine.network.title') }} </h3> >
<i class="icon icon-x" />
</button>
<button
v-if="vm.hotplugNicFeatureEnabled && row.isHotunpluggable && isView"
type="button"
class="role-link btn btn-sm remove"
:disabled="!canCheckHotunplug"
@click="unplugNIC(row)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachNIC.actionLabel') }}
</button>
</div>
<Base <Base
v-model:value="rows[i]" v-model:value="rows[i]"
:rows="rows" :rows="rows"
@@ -162,16 +230,13 @@ export default {
</template> </template>
<style lang='scss' scoped> <style lang='scss' scoped>
.infoBox{ .box-title{
position: relative; display: flex;
} justify-content: space-between;
align-items: center;
.remove-vol { h3 {
position: absolute; margin-bottom: 0;
top: 10px; }
right: 16px;
padding:0px;
max-height: 28px;
min-height: 28px;
} }
</style> </style>
@@ -303,55 +303,57 @@ export default {
v-for="(volume, i) in rows" v-for="(volume, i) in rows"
:key="volume.id" :key="volume.id"
> >
<InfoBox class="box"> <InfoBox>
<button <div class="box-title mb-10">
v-if="!isView" <h3>
type="button" <span
class="role-link btn btn-sm remove" v-if="volume.to && isVirtualType"
@click="removeVolume(volume)" class="title"
>
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
type="button"
class="role-link btn remove"
@click="unplugVolume(volume)"
>
{{ t('harvester.virtualMachine.unplug.detachVolume') }}
</button>
<h3>
<span
v-if="volume.to && isVirtualType"
class="title"
>
<router-link :to="volume.to">
{{ t('harvester.virtualMachine.volume.edit') }} {{ headerFor(volume.source) }}
</router-link>
<BadgeStateFormatter
v-if="volume.pvc"
class="ml-10 state"
:arbitrary="true"
:row="volume.pvc"
:value="volume.pvc.state"
/>
<a
v-if="dev && !!volume.pvc && !!volume.pvc.resourceExternalLink"
v-clean-tooltip="t(volume.pvc.resourceExternalLink.tipsKey || 'generic.resourceExternalLinkTips')"
class="ml-5 resource-external"
rel="nofollow noopener noreferrer"
target="_blank"
:href="volume.pvc.resourceExternalLink.url"
> >
<i class="icon icon-external-link" /> <router-link :to="volume.to">
</a> {{ t('harvester.virtualMachine.volume.edit') }} {{ headerFor(volume.source) }}
</span> </router-link>
<span v-else> <BadgeStateFormatter
{{ headerFor(volume.source, !!volume?.volumeBackups) }} v-if="volume.pvc"
</span> class="ml-10 state"
</h3> :arbitrary="true"
:row="volume.pvc"
:value="volume.pvc.state"
/>
<a
v-if="dev && !!volume.pvc && !!volume.pvc.resourceExternalLink"
v-clean-tooltip="t(volume.pvc.resourceExternalLink.tipsKey || 'generic.resourceExternalLinkTips')"
class="ml-5 resource-external"
rel="nofollow noopener noreferrer"
target="_blank"
:href="volume.pvc.resourceExternalLink.url"
>
<i class="icon icon-external-link" />
</a>
</span>
<span v-else>
{{ headerFor(volume.source, !!volume?.volumeBackups) }}
</span>
</h3>
<button
v-if="!isView"
type="button"
class="role-link btn btn-sm remove"
@click="removeVolume(volume)"
>
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
type="button"
class="role-link btn btn-sm remove"
@click="unplugVolume(volume)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachVolume.actionLabel') }}
</button>
</div>
<div> <div>
<component <component
:is="componentFor(volume.source)" :is="componentFor(volume.source)"
@@ -495,25 +497,24 @@ export default {
</template> </template>
<style lang='scss' scoped> <style lang='scss' scoped>
.box { .box-title {
position: relative; display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin-bottom: 0;
}
} }
.title { .title {
display: flex; display: flex;
align-items: center;
.state { .state {
font-size: 16px; font-size: 16px;
} }
} }
.remove {
position: absolute;
top: 10px;
right: 10px;
padding: 0px;
}
.bootOrder { .bootOrder {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -99,7 +99,6 @@ export default {
this.allPVCs this.allPVCs
.filter( (pvc) => { .filter( (pvc) => {
let isAvailable = true; let isAvailable = true;
let isBeingUsed = false;
this.rows.forEach( (O) => { this.rows.forEach( (O) => {
if (O.volumeName === pvc.metadata.name) { if (O.volumeName === pvc.metadata.name) {
@@ -111,17 +110,16 @@ export default {
return false; return false;
} }
// already used as image volume
if (this.idx > 0 && pvc.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]) {
return false;
}
if (pvc.isGoldenImageVolume) { if (pvc.isGoldenImageVolume) {
return false; return false;
} }
if (pvc.attachVM && isAvailable && pvc.attachVM?.id === this.vm?.id && this.isEdit) { return isAvailable && pvc.isAvailable;
isBeingUsed = false;
} else if (pvc.attachVM) {
isBeingUsed = true;
}
return isAvailable && !isBeingUsed && pvc.isAvailable;
}) })
.map((pvc) => { .map((pvc) => {
return { return {
@@ -490,6 +490,7 @@ export default {
if (this.isSingle) { if (this.isSingle) {
if (!this.value.spec.template.spec.hostname) { if (!this.value.spec.template.spec.hostname) {
this.value.spec.template.spec['hostname'] = this.value.metadata.name; this.value.spec.template.spec['hostname'] = this.value.metadata.name;
this.spec.template.spec['hostname'] = this.value.metadata.name;
} }
} }
+38 -11
View File
@@ -154,15 +154,19 @@ harvester:
nodeTimeout: nodeTimeout:
label: Node Collection Timeout label: Node Collection Timeout
tooltip: Minutes allowed for collecting logs/configurations on nodes.<br/>See docs support-bundle-node-collection-timeout for detail. tooltip: Minutes allowed for collecting logs/configurations on nodes.<br/>See docs support-bundle-node-collection-timeout for detail.
hotplug: hotplugVolume:
success: 'Volume { diskName } is mounted to the virtual machine { vm }.' success: 'Volume { diskName } is mounted to the virtual machine { vm }.'
title: Add Volume title: Add Volume
hotplugNic:
success: 'The settings have been saved, but the network interface {interfaceName} will be attached only after the virtual machine is migrated.'
title: Add Network Interface
vmNetwork: Virtual Machine Network
macAddress: MAC Address
macAddressTooltip: If left blank, the MAC address will be automatically generated.
cpuMemoryHotplug: cpuMemoryHotplug:
success: 'CPU and Memory are updated to the virtual machine { vm }.' success: 'CPU and Memory are updated to the virtual machine { vm }.'
title: Edit CPU and Memory title: Edit CPU and Memory
maxResourcesMessage: 'You can increase the CPU to maximum { maxCpu }C and memory to maximum { maxMemory }.' maxResourcesMessage: 'You can increase the CPU to maximum { maxCpu }C and memory to maximum { maxMemory }.'
hotunplug:
success: 'Volume { name } is detached successfully.'
snapshot: snapshot:
title: Take Snapshot title: Take Snapshot
name: Name name: Name
@@ -226,7 +230,8 @@ harvester:
disableCPUManager: Disable CPU Manager disableCPUManager: Disable CPU Manager
cordon: Cordon cordon: Cordon
uncordon: Uncordon uncordon: Uncordon
addHotplug: Add Volume addHotplugVolume: Add Volume
addHotplugNic: Hotplug Network Interface
exportImage: Export Image exportImage: Export Image
viewlogs: View Logs viewlogs: View Logs
cancelExpand: Cancel Expand cancelExpand: Cancel Expand
@@ -442,6 +447,7 @@ harvester:
storage: Storage storage: Storage
labels: Labels labels: Labels
ksmtuned: Ksmtuned ksmtuned: Ksmtuned
hugepages: Hugepages
seeder: Out-of-band Access seeder: Out-of-band Access
detail: detail:
kvm: kvm:
@@ -514,6 +520,20 @@ harvester:
fullScans: Full Scans fullScans: Full Scans
stableNodeChains: Stable Node Chains stableNodeChains: Stable Node Chains
stableNodeDups: Stable Node Dups stableNodeDups: Stable Node Dups
hugepages:
meminfo: Meminfo
transparent:
title: Transparent Hugepages <a href="{url}" target="_blank"><i class="icon icon-info" /></a>
enabled: Enabled
shmemEnabled: Shared Memory Enabled
defrag: Defragmentation
status:
anon: Anonymous Hugepages (bytes)
size: Default Hugepage Size (bytes)
total: Total Hugepages
free: Free Hugepages
rsvd: Reserved Hugepages
surp: Surplus Hugepages
disk: disk:
add: Add Disk add: Add Disk
path: path:
@@ -582,6 +602,16 @@ harvester:
title: Enable CPU and memory hotplug title: Enable CPU and memory hotplug
tooltip: The default maximum CPU and maximum memory are {hotPlugTimes} times based on CPU and memory. 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 restartVMMessage: Restart action is required for the virtual machine configuration change to take effect
hotUnplug:
actionLabel: Detach
detachVolume:
title: 'Are you sure that you want to detach volume {name}?'
actionLabel: Detach Volume
success: 'Volume { name } is detached successfully.'
detachNIC:
title: 'Are you sure that you want to detach network interface {name}?'
actionLabel: Detach Network Interface
success: 'The settings have been saved, but the network interface {name} will be detached only after the virtual machine is migrated.'
instance: instance:
singleInstance: singleInstance:
multipleInstance: multipleInstance:
@@ -613,11 +643,6 @@ harvester:
title: 'Select the volume you want to delete:' title: 'Select the volume you want to delete:'
deleteAll: Delete All deleteAll: Delete All
tips: "Warn: The snapshots of the virtual machine will be deleted with virtual machine and the snapshots of volume will be deleted with volume." tips: "Warn: The snapshots of the virtual machine will be deleted with virtual machine and the snapshots of volume will be deleted with volume."
unplug:
title: 'Are you sure that you want to detach volume {name} ?'
actionLabel: Detach
detachVolume:
Detach Volume
restartTip: |- restartTip: |-
{restart, select, {restart, select,
true {Restart} true {Restart}
@@ -1064,6 +1089,8 @@ harvester:
placeholder: e.g. 172.16.0.0/16 placeholder: e.g. 172.16.0.0/16
excludeIPs: excludeIPs:
tooltip: The IP address list to reserve from automatic assignment. The gateway IP address is always excluded and will be automatically added to the list. tooltip: The IP address list to reserve from automatic assignment. The gateway IP address is always excluded and will be automatically added to the list.
placeholder: single IP or 192.168.0.100..192.168.0.200
acl: acl:
label: Access Control List label: Access Control List
tooltip: The ACL to apply to this Subnet. Must be one of the ACLs in the same namespace. tooltip: The ACL to apply to this Subnet. Must be one of the ACLs in the same namespace.
@@ -1179,7 +1206,7 @@ harvester:
vlan: VLAN ID vlan: VLAN ID
exclude: exclude:
label: Exclude IPs label: Exclude IPs
placeholder: e.g. 172.16.0.1 placeholder: CIDR format, e.g. 172.16.0.10/32
invalid: '"Exclude list" is invalid.' invalid: '"Exclude list" is invalid.'
addIp: Add Exclude IP addIp: Add Exclude IP
warning: 'WARNING: <br/> Any change to storage-network requires shutting down all virtual machines before applying this setting. <br/> Users have to ensure the cluster network is configured and VLAN Configuration will cover all nodes and ensure the network connectivity is working and expected in all nodes.' warning: 'WARNING: <br/> Any change to storage-network requires shutting down all virtual machines before applying this setting. <br/> Users have to ensure the cluster network is configured and VLAN Configuration will cover all nodes and ensure the network connectivity is working and expected in all nodes.'
@@ -1695,7 +1722,7 @@ harvester:
available: Available available: Available
total: Total total: Total
vGPUID: vGPU ID vGPUID: vGPU ID
goSriovGPU: goSriovGPU:
prefix: Please enable the supported GPU devices in prefix: Please enable the supported GPU devices in
middle: SR-IOV GPU Devices middle: SR-IOV GPU Devices
suffix: page to manage the vGPU MIG configurations. suffix: page to manage the vGPU MIG configurations.
+22 -13
View File
@@ -273,7 +273,7 @@ export default {
needNewSecret() { needNewSecret() {
// When creating a template it is always necessary to create a new secret. // When creating a template it is always necessary to create a new secret.
return this.isCreate ? true : this.showYaml ? false : this.resourceType === HCI.VM_VERSION; return this.showYaml ? false : this.resourceType === HCI.VM_VERSION || this.isCreate;
}, },
defaultTerminationSetting() { defaultTerminationSetting() {
@@ -708,17 +708,7 @@ export default {
const volumeClaimTemplates = []; const volumeClaimTemplates = [];
disk.forEach( (R, index) => { disk.forEach( (R, index) => {
const prefixName = this.value.metadata?.name || ''; const dataVolumeName = this.parseDataVolumeName(R);
let dataVolumeName = '';
if (R.source === SOURCE_TYPE.ATTACH_VOLUME) {
dataVolumeName = R.volumeName;
} else if (this.isClone || !this.hasCreateVolumes.includes(R.realName)) {
dataVolumeName = `${ prefixName }-${ R.name }-${ randomStr(5).toLowerCase() }`;
} else {
dataVolumeName = R.realName;
}
const _disk = this.parseDisk(R, index); const _disk = this.parseDisk(R, index);
const _volume = this.parseVolume(R, dataVolumeName); const _volume = this.parseVolume(R, dataVolumeName);
@@ -1013,6 +1003,25 @@ export default {
this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled; this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled;
}, },
parseDataVolumeName(R) {
const prefixName = this.value.metadata?.name || '';
let dataVolumeName = '';
if (!R.dataVolumeName) {
if (R.source === SOURCE_TYPE.ATTACH_VOLUME) {
dataVolumeName = R.volumeName;
} else if (this.isClone || !this.hasCreateVolumes.includes(R.realName)) {
dataVolumeName = `${ prefixName }-${ R.name }-${ randomStr(5).toLowerCase() }`;
} else {
dataVolumeName = R.realName;
}
R.dataVolumeName = dataVolumeName;
}
return R.dataVolumeName;
},
parseDisk(R, index) { parseDisk(R, index) {
const out = { name: R.name }; const out = { name: R.name };
@@ -1638,7 +1647,7 @@ export default {
secretRef: { secretRef: {
handler(secret) { handler(secret) {
if (secret && this.resourceType !== HCI.BACKUP) { if (secret && this.resourceType !== HCI.BACKUP && this.resourceType !== HCI.VM) {
this.secretName = secret?.metadata.name; this.secretName = secret?.metadata.name;
} }
}, },
@@ -1,12 +1,12 @@
import SteveModel from '@shell/plugins/steve/steve-class';
import { escapeHtml } from '@shell/utils/string'; import { escapeHtml } from '@shell/utils/string';
import { colorForState } from '@shell/plugins/dashboard-store/resource-class'; import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import HarvesterResource from './harvester';
/** /**
* Class representing vGPU MIGConfiguration resource. * Class representing vGPU MIGConfiguration resource.
* @extends SteveModal * @extends HarvesterResource
*/ */
export default class MIGCONFIGURATION extends SteveModel { export default class MIGCONFIGURATION extends HarvesterResource {
get _availableActions() { get _availableActions() {
let out = super._availableActions; let out = super._availableActions;
@@ -35,15 +35,10 @@ export default class HciPv extends HarvesterResource {
get availableActions() { get availableActions() {
let out = super._availableActions; let out = super._availableActions;
// Longhorn V2 provisioner do not support volume clone feature yet const clone = out.find((action) => action.action === 'goToClone');
if (this.isLonghornV2) {
out = out.filter((action) => action.action !== 'goToClone');
} else {
const clone = out.find((action) => action.action === 'goToClone');
if (clone) { if (clone) {
clone.action = 'goToCloneVolume'; clone.action = 'goToCloneVolume';
}
} }
const exportImageAction = { const exportImageAction = {
@@ -65,10 +60,6 @@ export default class HciPv extends HarvesterResource {
takeSnapshotAction, takeSnapshotAction,
...out ...out
]; ];
// TODO: remove this block if Longhorn V2 engine supports restore volume snapshot
if (this.isLonghornV2) {
out = out.filter((action) => action.action !== takeSnapshotAction.action);
}
} else { // v1.4 / v1.3 } else { // v1.4 / v1.3
if (!this.isLonghorn || !this.isLonghornV2) { if (!this.isLonghorn || !this.isLonghornV2) {
out = [ out = [
@@ -29,6 +29,16 @@ export default class NetworkAttachmentDef extends SteveModel {
} }
} }
get isSystem() {
const systemNamespaces = this.$rootGetters['systemNamespaces'];
if (systemNamespaces.includes(this.metadata?.namespace)) {
return true;
}
return false;
}
get isIpamStatic() { get isIpamStatic() {
return this.parseConfig.ipam?.type === 'static'; return this.parseConfig.ipam?.type === 'static';
} }
@@ -87,17 +87,11 @@ const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims'];
export default class VirtVm extends HarvesterResource { export default class VirtVm extends HarvesterResource {
get availableActions() { get availableActions() {
let out = super._availableActions; const out = super._availableActions;
const clone = out.find((action) => action.action === 'goToClone');
// VM attached with Longhorn V2 volume doesn't support clone feature if (clone) {
if (this.longhornV2Volumes.length > 0) { clone.action = 'goToCloneVM';
out = out.filter((action) => action.action !== 'goToClone');
} else {
const clone = out.find((action) => action.action === 'goToClone');
if (clone) {
clone.action = 'goToCloneVM';
}
} }
return [ return [
@@ -159,7 +153,7 @@ export default class VirtVm extends HarvesterResource {
}, },
{ {
action: 'takeVMSnapshot', action: 'takeVMSnapshot',
enabled: (!!this.actions?.snapshot || !!this.action?.backup) && !this.longhornV2Volumes.length, enabled: (!!this.actions?.snapshot || !!this.action?.backup),
icon: 'icon icon-snapshot', icon: 'icon icon-snapshot',
label: this.t('harvester.action.vmSnapshot') label: this.t('harvester.action.vmSnapshot')
}, },
@@ -206,10 +200,16 @@ export default class VirtVm extends HarvesterResource {
label: this.t('harvester.action.abortMigration') label: this.t('harvester.action.abortMigration')
}, },
{ {
action: 'addHotplug', action: 'addHotplugVolume',
enabled: !!this.actions?.addVolume, enabled: !!this.actions?.addVolume,
icon: 'icon icon-plus', icon: 'icon icon-plus',
label: this.t('harvester.action.addHotplug') label: this.t('harvester.action.addHotplugVolume')
},
{
action: 'addHotplugNic',
enabled: this.hotplugNicFeatureEnabled && !!this.actions?.addNic,
icon: 'icon icon-plus',
label: this.t('harvester.action.addHotplugNic')
}, },
{ {
action: 'createTemplate', action: 'createTemplate',
@@ -395,8 +395,20 @@ export default class VirtVm extends HarvesterResource {
this.$dispatch('promptModal', { this.$dispatch('promptModal', {
resources, resources,
diskName, name: diskName,
component: 'HarvesterUnplugVolume' type: 'volume',
component: 'HarvesterHotUnplug',
});
}
unplugNIC(networkName) {
const resources = this;
this.$dispatch('promptModal', {
resources,
name: networkName,
type: 'network',
component: 'HarvesterHotUnplug',
}); });
} }
@@ -504,10 +516,17 @@ export default class VirtVm extends HarvesterResource {
}); });
} }
addHotplug(resources = this) { addHotplugVolume(resources = this) {
this.$dispatch('promptModal', { this.$dispatch('promptModal', {
resources, resources,
component: 'HarvesterAddHotplugModal' component: 'HarvesterAddHotplugVolumeModal'
});
}
addHotplugNic(resources = this) {
this.$dispatch('promptModal', {
resources,
component: 'HarvesterAddHotplugNic'
}); });
} }
@@ -1240,6 +1259,10 @@ export default class VirtVm extends HarvesterResource {
return this.$rootGetters['harvester-common/getFeatureEnabled']('vmMachineTypeAuto'); return this.$rootGetters['harvester-common/getFeatureEnabled']('vmMachineTypeAuto');
} }
get hotplugNicFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('hotplugNic');
}
get isBackupTargetUnavailable() { get isBackupTargetUnavailable() {
const allSettings = this.$rootGetters['harvester/all'](HCI.SETTING) || []; const allSettings = this.$rootGetters['harvester/all'](HCI.SETTING) || [];
const backupTargetSetting = allSettings.find( (O) => O.id === 'backup-target'); const backupTargetSetting = allSettings.find( (O) => O.id === 'backup-target');
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "harvester", "name": "harvester",
"description": "Rancher UI Extension for Harvester", "description": "Rancher UI Extension for Harvester",
"version": "1.7.0-dev", "version": "1.7.0-rc4",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
@@ -12,6 +12,7 @@ import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../../config/harvester'
import ImagePercentageBar from '@shell/components/formatter/ImagePercentageBar'; import ImagePercentageBar from '@shell/components/formatter/ImagePercentageBar';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import { STORAGE_CLASS } from '@shell/config/types';
const IMAGE_METHOD = { const IMAGE_METHOD = {
NEW: 'new', NEW: 'new',
@@ -32,6 +33,7 @@ export default {
async fetch() { async fetch() {
await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE }); await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE });
await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
const value = await this.$store.dispatch('harvester/create', { const value = await this.$store.dispatch('harvester/create', {
type: HCI.UPGRADE, type: HCI.UPGRADE,
@@ -63,6 +65,7 @@ export default {
sourceType: UPLOAD, sourceType: UPLOAD,
uploadController: null, uploadController: null,
uploadResult: null, uploadResult: null,
storageClassValue: null,
imageValue: null, imageValue: null,
enableLogging: true, enableLogging: true,
IMAGE_METHOD, IMAGE_METHOD,
@@ -79,7 +82,6 @@ export default {
skipSingleReplicaDetachedVolFeatureEnabled() { skipSingleReplicaDetachedVolFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('skipSingleReplicaDetachedVol'); return this.$store.getters['harvester-common/getFeatureEnabled']('skipSingleReplicaDetachedVol');
}, },
allOSImages() { allOSImages() {
return this.$store.getters['harvester/all'](HCI.IMAGE).filter((I) => I.isOSImage) || []; return this.$store.getters['harvester/all'](HCI.IMAGE).filter((I) => I.isOSImage) || [];
}, },
@@ -116,7 +118,7 @@ export default {
}, },
fileName() { fileName() {
return this.file?.name || ''; return this.preprocessImageName(this.file?.name || '');
}, },
canEnableLogging() { canEnableLogging() {
@@ -181,6 +183,38 @@ export default {
}); });
}, },
async createImageStorageClass(imageName = '') {
// delete related SC if existed
await this.deleteImageStorageClass(imageName);
const storageClassPayload = {
apiVersion: 'storage.k8s.io/v1',
type: STORAGE_CLASS,
metadata: { name: imageName },
volumeBindingMode: 'Immediate',
reclaimPolicy: 'Delete',
allowVolumeExpansion: true, // must be boolean type
provisioner: 'driver.longhorn.io',
};
this.storageClassValue = await this.$store.dispatch('harvester/create', storageClassPayload);
if (this.storageClassValue && this.storageClassValue.save) {
await this.storageClassValue.save();
}
},
async deleteImageStorageClass(imageName = '') {
const inStore = this.$store.getters['currentProduct'].inStore;
const storageClasses = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS);
const targetSC = storageClasses.find((sc) => sc.id === imageName);
if (targetSC && targetSC.remove) {
await targetSC.remove();
}
},
async initImageValue() { async initImageValue() {
this.imageValue = await this.$store.dispatch('harvester/create', { this.imageValue = await this.$store.dispatch('harvester/create', {
type: HCI.IMAGE, type: HCI.IMAGE,
@@ -191,6 +225,7 @@ export default {
annotations: {} annotations: {}
}, },
spec: { spec: {
backend: 'cdi',
sourceType: UPLOAD, sourceType: UPLOAD,
displayName: '', displayName: '',
checksum: this.imageValue?.spec?.checksum || '', checksum: this.imageValue?.spec?.checksum || '',
@@ -203,8 +238,9 @@ export default {
this.file = {}; this.file = {};
this.errors = []; this.errors = [];
const imageDisplayName = this.imageValue?.spec?.displayName || '';
if (!this.imageValue.spec.displayName && this.createNewImage) { if (!imageDisplayName && this.createNewImage) {
this.errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('generic.name') })); this.errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('generic.name') }));
buttonCb(false); buttonCb(false);
@@ -212,24 +248,31 @@ export default {
} }
try { try {
// Save the image first if creating a new one
if (this.imageSource === IMAGE_METHOD.NEW) { if (this.imageSource === IMAGE_METHOD.NEW) {
this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True'; this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True';
if (this.sourceType === UPLOAD && this.uploadImageId !== '') { // upload new image if (this.sourceType === UPLOAD && this.uploadImageId !== '') { // upload new image
this.value.spec.image = this.uploadImageId; this.value.spec.image = this.uploadImageId;
} else if (this.sourceType === DOWNLOAD) { // give URL to download new image } else if (this.sourceType === DOWNLOAD) { // give URL to download new image
this.imageValue.spec.sourceType = DOWNLOAD; // check if URL is provided
if (!this.imageValue.spec.url) { if (!this.imageValue.spec.url) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.imageUrl')); this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.imageUrl'));
buttonCb(false); buttonCb(false);
return; return;
} }
// create related image storage class first
await this.createImageStorageClass(imageDisplayName);
this.imageValue.spec.sourceType = DOWNLOAD;
this.imageValue.spec.targetStorageClassName = imageDisplayName;
res = await this.imageValue.save(); res = await this.imageValue.save();
this.value.spec.image = res.id; this.value.spec.image = res.id;
} }
} else if (this.imageSource === IMAGE_METHOD.EXIST) { } else if (this.imageSource === IMAGE_METHOD.EXIST) { // select existing image
if (!this.imageId) { if (!this.imageId) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.chooseFile')); this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.chooseFile'));
buttonCb(false); buttonCb(false);
@@ -239,7 +282,7 @@ export default {
this.value.spec.image = this.imageId; this.value.spec.image = this.imageId;
} }
// enable logging or skip single replica detection if checked
if (this.canEnableLogging) { if (this.canEnableLogging) {
this.value.spec.logEnabled = this.enableLogging; this.value.spec.logEnabled = this.enableLogging;
} }
@@ -252,11 +295,13 @@ export default {
} catch (e) { } catch (e) {
this.errors = [e?.message] || exceptionToErrorsArray(e); this.errors = [e?.message] || exceptionToErrorsArray(e);
buttonCb(false); buttonCb(false);
// if anything failed, delete the created image storage class
await this.deleteImageStorageClass(imageDisplayName);
} }
}, },
async uploadFile(file) { async uploadFile(file) {
const fileName = file.name; const fileName = this.preprocessImageName(file.name);
if (!fileName) { if (!fileName) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.unknownImageName')); this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.unknownImageName'));
@@ -280,6 +325,10 @@ export default {
this.imageValue.spec.url = ''; this.imageValue.spec.url = '';
try { try {
// before uploading image, we need to create related image storage class first
await this.createImageStorageClass(fileName);
this.imageValue.spec.targetStorageClassName = fileName;
const res = await this.imageValue.save(); const res = await this.imageValue.save();
this.uploadImageId = res.id; this.uploadImageId = res.id;
@@ -296,20 +345,35 @@ export default {
} else { } else {
this.errors = exceptionToErrorsArray(e); this.errors = exceptionToErrorsArray(e);
} }
// if upload failed, delete the created image storage class
await this.deleteImageStorageClass(fileName);
this.file = {}; this.file = {};
this.uploadImageId = ''; this.uploadImageId = '';
} }
}, },
// replace _ to - to meet storage class name requirement
preprocessImageName(name) {
if (!name) {
return '';
}
return name.toLowerCase().replace(/[_]/g, '-');
},
handleImageDelete(imageId) { handleImageDelete(imageId) {
const image = this.allOSImages.find((I) => I.id === imageId); const image = this.allOSImages.find((I) => I.id === imageId);
const imageDisplayName = image?.spec?.displayName || '';
if (image) { if (image && imageDisplayName) {
this.$store.dispatch('harvester/promptModal', { this.$store.dispatch('harvester/promptModal', {
resources: [image], resources: [image],
component: 'ConfirmRelatedToRemoveDialog', component: 'ConfirmRelatedToRemoveDialog',
needConfirmation: false, needConfirmation: false,
warningMessage: this.$store.getters['i18n/t']('harvester.modal.osImage.message', { name: image.displayName }) warningMessage: this.$store.getters['i18n/t']('harvester.modal.osImage.message', { name: imageDisplayName }),
extraActionAfterRemove: async() => {
await this.deleteImageStorageClass(imageDisplayName);
}
}); });
this.deleteImageId = ''; this.deleteImageId = '';
} }
@@ -419,13 +483,13 @@ export default {
v-if="showUploadSuccessBanner" v-if="showUploadSuccessBanner"
color="success" color="success"
class="mt-0 mb-30" class="mt-0 mb-30"
:label="t('harvester.setting.upgrade.uploadSuccess', { name: file.name })" :label="t('harvester.setting.upgrade.uploadSuccess', { name: fileName })"
/> />
<Banner <Banner
v-if="showUploadingWarningBanner" v-if="showUploadingWarningBanner"
color="warning" color="warning"
class="mt-0 mb-30" class="mt-0 mb-30"
:label="t('harvester.image.warning.osUpgrade.uploading', { name: file.name })" :label="t('harvester.image.warning.osUpgrade.uploading', { name: fileName })"
/> />
<div <div
+1
View File
@@ -37,6 +37,7 @@ export const HCI = {
STORAGE: 'harvesterhci.io.storage', STORAGE: 'harvesterhci.io.storage',
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota', RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
KSTUNED: 'node.harvesterhci.io.ksmtuned', KSTUNED: 'node.harvesterhci.io.ksmtuned',
HUGEPAGES: 'node.harvesterhci.io.hugepage',
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice', PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim', PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice', SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
+903 -539
View File
File diff suppressed because it is too large Load Diff