Add OS upgrade features (backport #311) (#320)

* Add OS upgrade features (#311)

* Add failed and success banner after image uploaded

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

* add delete image feature

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

* add skip checking single-replica detached volumes checkbox

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

* change delete image flow

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

* Reuse ConfirmRelatedToRemoveDialog

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
(cherry picked from commit dbbad01b0f1f491bc64a54ae0d23ffe1774b357a)

# Conflicts:
#	pkg/harvester/dialog/ConfirmRelatedToRemoveDialog.vue

* resolve conflict

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
mergify[bot] 2025-05-28 12:58:46 +08:00 committed by GitHub
parent 01e3867da1
commit 965c7d9b72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 305 additions and 97 deletions

View File

@ -43,7 +43,7 @@ export default {
{{ t('harvester.upgradePage.upgradeInfo.tip') }} {{ t('harvester.upgradePage.upgradeInfo.tip') }}
</p> </p>
<p class="mb-5"> <p>
{{ t('harvester.upgradePage.upgradeInfo.moreNotes') }} <a {{ t('harvester.upgradePage.upgradeInfo.moreNotes') }} <a
:href="releaseVersion" :href="releaseVersion"
target="_blank" target="_blank"

View File

@ -42,8 +42,25 @@ export default {
computed: { computed: {
...mapState('action-menu', ['modalData']), ...mapState('action-menu', ['modalData']),
warningMessageKey() { title() {
return this.modalData.warningMessageKey; return this.modalData.title || 'dialog.promptRemove.title';
},
formattedType() {
return this.type.toLowerCase();
},
warningMessage() {
if (this.modalData.warningMessage) return this.modalData.warningMessage;
const isPlural = this.type.endsWith('s');
const thisOrThese = isPlural ? 'these' : 'this';
const defaultMessage = this.t('dialog.promptRemove.warningMessage', {
type: this.formattedType,
thisOrThese,
});
return defaultMessage;
}, },
names() { names() {
@ -70,6 +87,12 @@ export default {
return this.resources[0].nameDisplay; return this.resources[0].nameDisplay;
}, },
needConfirmation() {
const { needConfirmation = true } = this.modalData ;
return needConfirmation === true;
},
plusMore() { plusMore() {
const remaining = this.resources.length - this.names.length; const remaining = this.resources.length - this.names.length;
@ -97,6 +120,10 @@ export default {
}, },
deleteDisabled() { deleteDisabled() {
if (!this.needConfirmation) {
return false;
}
return this.confirmName !== this.nameToMatch; return this.confirmName !== this.nameToMatch;
}, },
@ -147,9 +174,16 @@ export default {
v-clean-html="t(warningMessageKey, { type, names: resourceNames }, true)" v-clean-html="t(warningMessageKey, { type, names: resourceNames }, true)"
></span> ></span>
<div
v-if="needConfirmation"
class="mt-20"
>
<div class="mt-10 mb-10"> <div class="mt-10 mb-10">
<span <span
v-clean-html="t('promptRemove.confirmName', { nameToMatch: escapeHtml(nameToMatch) }, true)" v-clean-html="t('dialog.promptRemove.confirmName', {
type: formattedType,
nameToMatch: escapeHtml(nameToMatch)
}, true)"
></span> ></span>
</div> </div>
<div class="mb-10"> <div class="mb-10">
@ -163,6 +197,7 @@ export default {
<div class="text-info mt-20"> <div class="text-info mt-20">
{{ protip }} {{ protip }}
</div> </div>
</div>
<Banner <Banner
v-for="(error, i) in errors" v-for="(error, i) in errors"
:key="i" :key="i"

View File

@ -290,7 +290,7 @@ export default {
const res = await this.value.save(); const res = await this.value.save();
res.uploadImage(file); await res.uploadImage(file);
buttonCb(true); buttonCb(true);
this.done(); this.done();

View File

@ -61,6 +61,7 @@ harvester:
tip: 'Upload an icon to replace the Harvester favicon in the browser tab. Max file size is 20KB' tip: 'Upload an icon to replace the Harvester favicon in the browser tab. Max file size is 20KB'
productLabel: 'Harvester' productLabel: 'Harvester'
modal: modal:
backup: backup:
success: 'Backup { backUpName } has been initiated.' success: 'Backup { backUpName } has been initiated.'
addBackup: Add Backup addBackup: Add Backup
@ -99,6 +100,9 @@ harvester:
tip: Please enter a virtual machine name! tip: Please enter a virtual machine name!
success: 'Virtual machine { name } cloned successfully.' success: 'Virtual machine { name } cloned successfully.'
failed: 'Failed clone virtual machine!' failed: 'Failed clone virtual machine!'
osImage:
title: Delete Image
message: Are you sure you want to delete the image { name } ?
downloadImage: downloadImage:
title: Download Image title: Download Image
banner: 'This action takes a while depending on the image size ({ size }). Please be patient.' banner: 'This action takes a while depending on the image size ({ size }). Please be patient.'
@ -872,14 +876,18 @@ harvester:
upgradeNode: Upgrading Node upgradeNode: Upgrading Node
upgradeSysService: Upgrading System Service upgradeSysService: Upgrading System Service
upgradeImage: Download Upgrade Image upgradeImage: Download Upgrade Image
osUpgrade: OS Upgrade osUpgrade: Cluster Upgrade
uploadNew: Upload New Image uploadNew: Upload New Image
deleteHeader: Please select an image to delete.
deleteExisting: Delete Existing Image
selectExisting: Select Existing Image selectExisting: Select Existing Image
createRepository: Creating Upgrade Repository createRepository: Creating Upgrade Repository
succeeded: Succeeded succeeded: Succeeded
releaseTip: Please read the upgrade documentation carefully. You can view details on the <a href="{url}" target="_blank">Harvester Release Notes</a>. releaseTip: Please read the upgrade documentation carefully. You can view details on the <a href="{url}" target="_blank">Harvester Release Notes</a>.
checkReady: I have read and understood the upgrade instructions related to this Harvester version. checkReady: I have read and understood the upgrade instructions related to this Harvester version.
pending: Pending pending: Pending
upload:
duplicatedFile: The file you are trying to upload already exists.
repoInfo: repoInfo:
upgradeStatus: Upgrade Status upgradeStatus: Upgrade Status
os: OS os: OS
@ -1086,6 +1094,11 @@ harvester:
imageUrl: Please input a valid image URL. imageUrl: Please input a valid image URL.
chooseFile: Please select to upload an image. chooseFile: Please select to upload an image.
checksum: Checksum checksum: Checksum
networkError: Unable to upload the image. Resolve network issues that may have occurred and try again.
cancelUpload: Cancelled the image upload.
uploadSuccess: "{name} uploaded successfully. Press Upgrade button to start the cluster upgrade process."
deleteImage: Please select an image to delete.
deleteSuccess: "{name} deleted successfully."
harvesterMonitoring: harvesterMonitoring:
label: Harvester Monitoring label: Harvester Monitoring
section: section:

View File

@ -313,7 +313,7 @@ export default class HciVmImage extends HarvesterResource {
try { try {
this.$ctx.commit('harvester-common/uploadStart', this.metadata.name, { root: true }); this.$ctx.commit('harvester-common/uploadStart', this.metadata.name, { root: true });
await this.doAction('upload', formData, { const result = await this.doAction('upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
'File-Size': file.size, 'File-Size': file.size,
@ -321,15 +321,15 @@ export default class HciVmImage extends HarvesterResource {
params: { size: file.size }, params: { size: file.size },
signal: opt.signal, signal: opt.signal,
}); });
return result;
} catch (err) { } catch (err) {
this.$ctx.commit('harvester-common/uploadError', { name: this.name, message: err.message }, { root: true }); this.$ctx.commit('harvester-common/uploadError', { name: this.name, message: err.message }, { root: true });
this.$ctx.commit('harvester-common/uploadEnd', this.metadata.name, { root: true }); this.$ctx.commit('harvester-common/uploadEnd', this.metadata.name, { root: true });
throw err;
return Promise.reject(err); } finally {
this.$ctx.commit('harvester-common/uploadEnd', this.metadata.name, { root: true });
} }
this.$ctx.commit('harvester-common/uploadEnd', this.metadata.name, { root: true });
}; };
} }

View File

@ -11,10 +11,12 @@ import { HCI } from '../../../../types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../../config/harvester'; 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';
const IMAGE_METHOD = { const IMAGE_METHOD = {
NEW: 'new', NEW: 'new',
EXIST: 'exist' EXIST: 'exist',
DELETE: 'delete'
}; };
const DOWNLOAD = 'download'; const DOWNLOAD = 'download';
@ -40,23 +42,8 @@ export default {
spec: { image: '' } spec: { image: '' }
}); });
const imageValue = await this.$store.dispatch('harvester/create', { await this.initImageValue();
type: HCI.IMAGE,
metadata: {
name: '',
namespace: 'harvester-system',
generateName: 'image-',
annotations: {}
},
spec: {
sourceType: UPLOAD,
displayName: '',
checksum: ''
},
});
this.value = value; this.value = value;
this.imageValue = imageValue;
}, },
beforeUnmount() { beforeUnmount() {
@ -71,13 +58,16 @@ export default {
file: {}, file: {},
uploadImageId: '', uploadImageId: '',
imageId: '', imageId: '',
deleteImageId: '',
imageSource: IMAGE_METHOD.NEW, imageSource: IMAGE_METHOD.NEW,
sourceType: UPLOAD, sourceType: UPLOAD,
uploadController: null, uploadController: null,
uploadResult: null,
imageValue: null, imageValue: null,
errors: [],
enableLogging: true, enableLogging: true,
IMAGE_METHOD IMAGE_METHOD,
skipSingleReplicaDetachedVol: false,
errors: [],
}; };
}, },
@ -86,10 +76,25 @@ export default {
return `${ HARVESTER_PRODUCT }-c-cluster-resource`; return `${ HARVESTER_PRODUCT }-c-cluster-resource`;
}, },
skipSingleReplicaDetachedVolFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('skipSingleReplicaDetachedVol');
},
allOSImages() {
return this.$store.getters['harvester/all'](HCI.IMAGE).filter((I) => I.isOSImage) || [];
},
deleteOSImageOptions() {
return this.allOSImages.map((I) => {
return {
label: I.spec.displayName,
value: I.id,
};
});
},
osImageOptions() { osImageOptions() {
return this.$store.getters['harvester/all'](HCI.IMAGE) return this.allOSImages.map((I) => {
.filter((I) => I.isOSImage)
.map((I) => {
return { return {
label: I.spec.displayName, label: I.spec.displayName,
value: I.id, value: I.id,
@ -98,10 +103,18 @@ export default {
}); });
}, },
uploadImage() { createNewImage() {
return this.imageSource === IMAGE_METHOD.NEW; return this.imageSource === IMAGE_METHOD.NEW;
}, },
selectExistImage() {
return this.imageSource === IMAGE_METHOD.EXIST;
},
deleteExistImage() {
return this.imageSource === IMAGE_METHOD.DELETE;
},
fileName() { fileName() {
return this.file?.name || ''; return this.file?.name || '';
}, },
@ -116,7 +129,11 @@ export default {
return image?.status?.progress; return image?.status?.progress;
}, },
enableSave() { enableUpgrade() {
if (this.deleteExistImage) {
return false;
}
if (this.sourceType === DOWNLOAD) { if (this.sourceType === DOWNLOAD) {
return true; return true;
} }
@ -128,16 +145,28 @@ export default {
return true; return true;
}, },
showProgressBar() { isUploading() {
return this.sourceType === UPLOAD && this.fileName !== '';
},
showUploadingWarningBanner() {
return this.fileName !== '' && this.uploadProgress !== 100; return this.fileName !== '' && this.uploadProgress !== 100;
}, },
showProgressBar() {
return this.createNewImage && this.sourceType === UPLOAD && this.isUploading;
},
showUploadSuccessBanner() {
return this.createNewImage && this.fileName !== '' && isEmpty(this.errors) && !this.showUploadingWarningBanner && this.uploadResult?._status === 200;
},
showUploadingWarningBanner() {
return this.createNewImage && this.isUploading;
},
showUpgradeOptions() {
return this.createNewImage || this.selectExistImage;
},
disableUploadButton() { disableUploadButton() {
return this.sourceType === UPLOAD && this.fileName !== '' && this.uploadProgress !== 100; return this.sourceType === UPLOAD && this.isUploading;
}, },
}, },
@ -152,11 +181,30 @@ export default {
}); });
}, },
async initImageValue() {
this.imageValue = await this.$store.dispatch('harvester/create', {
type: HCI.IMAGE,
metadata: {
name: '',
namespace: 'harvester-system',
generateName: 'image-',
annotations: {}
},
spec: {
sourceType: UPLOAD,
displayName: '',
checksum: ''
},
});
},
async save(buttonCb) { async save(buttonCb) {
let res = null; let res = null;
this.file = {};
this.errors = []; this.errors = [];
if (!this.imageValue.spec.displayName && this.uploadImage) {
if (!this.imageValue.spec.displayName && 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);
@ -164,6 +212,28 @@ export default {
} }
try { try {
if (this.deleteExistImage) {
// if not select image, show error
if (!this.deleteImageId) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.deleteImage'));
buttonCb(false);
return;
}
// if select image, delete image
const image = this.$store.getters['harvester/byId'](HCI.IMAGE, this.deleteImageId);
if (image) {
this.handleImageDelete(image);
buttonCb(true);
return;
}
return;
}
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';
@ -194,12 +264,15 @@ export default {
if (this.canEnableLogging) { if (this.canEnableLogging) {
this.value.spec.logEnabled = this.enableLogging; this.value.spec.logEnabled = this.enableLogging;
} }
if (this.skipSingleReplicaDetachedVolFeatureEnabled) {
this.value.metadata.annotations = { [HCI_ANNOTATIONS.SKIP_SINGLE_REPLICA_DETACHED_VOL]: JSON.stringify(this.skipSingleReplicaDetachedVol) };
}
await this.value.save(); await this.value.save();
this.done(); this.done();
buttonCb(true); buttonCb(true);
} catch (e) { } catch (e) {
this.errors = exceptionToErrorsArray(e); this.errors = [e?.message] || exceptionToErrorsArray(e);
buttonCb(false); buttonCb(false);
} }
}, },
@ -207,36 +280,71 @@ export default {
async uploadFile(file) { async uploadFile(file) {
const fileName = file.name; const fileName = file.name;
this.imageValue.spec.sourceType = UPLOAD;
this.imageValue.spec.displayName = fileName;
this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True';
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'));
return; return;
} }
const isDuplicatedFile = this.allOSImages.some((I) => I.spec.displayName === fileName);
this.imageValue.spec.url = ''; if (isDuplicatedFile) {
this.errors.push(this.$store.getters['i18n/t']('harvester.upgradePage.upload.duplicatedFile'));
this.file = {};
return;
}
this.errors = [];
this.imageValue.spec.sourceType = UPLOAD;
this.imageValue.spec.displayName = fileName;
this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True';
this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = fileName; this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = fileName;
this.imageValue.spec.url = '';
try { try {
const res = await this.imageValue.save(); const res = await this.imageValue.save();
this.uploadImageId = res.id; this.uploadImageId = res.id;
this.uploadController = new AbortController(); this.uploadController = new AbortController();
const signal = this.uploadController.signal; const signal = this.uploadController.signal;
await res.uploadImage(file, { signal }); this.uploadResult = await res.uploadImage(file, { signal });
} catch (e) { } catch (e) {
this.errors = exceptionToErrorsArray(e); if (e?.code === 'ERR_NETWORK') {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.networkError'));
} else if (e?.code === 'ERR_CANCELED') {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.cancelUpload'));
} else {
this.errors = [e?.message] || exceptionToErrorsArray(e);
}
this.file = {};
this.uploadImageId = '';
}
},
handleImageDelete(imageId) {
const image = this.allOSImages.find((I) => I.id === imageId);
if (image) {
this.$store.dispatch('harvester/promptModal', {
resources: [image],
component: 'ConfirmRelatedToRemoveDialog',
needConfirmation: false,
warningMessage: this.$store.getters['i18n/t']('harvester.modal.osImage.message', { name: image.displayName })
});
this.deleteImageId = '';
} }
}, },
async handleFileUpload() { async handleFileUpload() {
this.file = this.$refs.file.files[0]; this.uploadImageId = '';
this.errors = []; this.errors = [];
this.file = this.$refs.file?.files[0];
if (this.file) {
await this.initImageValue();
await this.uploadFile(this.file); await this.uploadFile(this.file);
}
}, },
selectFile() { selectFile() {
@ -246,6 +354,12 @@ export default {
}, },
watch: { watch: {
imageSource(neu) {
if (neu !== IMAGE_METHOD.DELETE) {
this.deleteImageId = '';
}
},
'imageValue.spec.url': { 'imageValue.spec.url': {
handler(neu) { handler(neu) {
const suffixName = neu?.split('/')?.pop(); const suffixName = neu?.split('/')?.pop();
@ -283,10 +397,11 @@ export default {
:errors="errors" :errors="errors"
:can-yaml="false" :can-yaml="false"
finish-button-mode="upgrade" finish-button-mode="upgrade"
:validation-passed="enableSave" :validation-passed="enableUpgrade"
:cancel-event="true" :cancel-event="true"
@finish="save" @finish="save"
@cancel="done" @cancel="done"
@error="e=>errors = e"
> >
<RadioGroup <RadioGroup
v-model:value="imageSource" v-model:value="imageSource"
@ -295,20 +410,55 @@ export default {
:options="[ :options="[
IMAGE_METHOD.NEW, IMAGE_METHOD.NEW,
IMAGE_METHOD.EXIST, IMAGE_METHOD.EXIST,
IMAGE_METHOD.DELETE,
]" ]"
:labels="[ :labels="[
t('harvester.upgradePage.uploadNew'), t('harvester.upgradePage.uploadNew'),
t('harvester.upgradePage.selectExisting'), t('harvester.upgradePage.selectExisting'),
t('harvester.upgradePage.deleteExisting'),
]" ]"
/> />
<UpgradeInfo v-if="createNewImage || selectExistImage" />
<Banner
v-if="showUploadSuccessBanner"
color="success"
class="mt-0 mb-30"
:label="t('harvester.setting.upgrade.uploadSuccess', { name: file.name })"
/>
<Banner <Banner
v-if="showUploadingWarningBanner" v-if="showUploadingWarningBanner"
color="warning" color="warning"
class="mt-0 mb-30"
:label="t('harvester.image.warning.osUpgrade.uploading', { name: file.name })" :label="t('harvester.image.warning.osUpgrade.uploading', { name: file.name })"
/> />
<UpgradeInfo />
<div v-if="uploadImage"> <div
v-if="showUpgradeOptions"
class="mt-10 mb-10"
>
<Checkbox
v-if="canEnableLogging"
v-model:value="enableLogging"
class="check mb-20"
type="checkbox"
:label="t('harvester.upgradePage.enableLogging')"
/>
<div
v-if="skipSingleReplicaDetachedVolFeatureEnabled"
class="mb-20"
>
<Checkbox
v-model:value="skipSingleReplicaDetachedVol"
class="check"
type="checkbox"
:label="t('harvester.upgradePage.skipSingleReplicaDetachedVol')"
/>
</div>
</div>
<div v-if="createNewImage">
<LabeledInput <LabeledInput
v-model:value.trim="imageValue.spec.displayName" v-model:value.trim="imageValue.spec.displayName"
class="mb-20" class="mb-20"
@ -322,14 +472,6 @@ export default {
label-key="harvester.setting.upgrade.checksum" label-key="harvester.setting.upgrade.checksum"
/> />
<Checkbox
v-if="canEnableLogging"
v-model:value="enableLogging"
class="check mb-20"
type="checkbox"
:label="t('harvester.upgradePage.enableLogging')"
/>
<RadioGroup <RadioGroup
v-model:value="sourceType" v-model:value="sourceType"
class="mb-20 image-group" class="mb-20 image-group"
@ -386,15 +528,33 @@ export default {
:value="uploadProgress" :value="uploadProgress"
/> />
</div> </div>
<LabeledSelect <LabeledSelect
v-else v-if="selectExistImage"
v-model:value="imageId" v-model:value="imageId"
:options="osImageOptions" :options="osImageOptions"
required required
class="mb-20" class="mb-20"
label-key="harvester.fields.image" label-key="harvester.fields.image"
/> />
<div
v-if="deleteExistImage"
class="mt-20"
>
<Banner
color="info"
class="mt-10 mb-30"
:label="t('harvester.upgradePage.deleteHeader')"
/>
<LabeledSelect
v-model:value="deleteImageId"
:options="deleteOSImageOptions"
required
class="mb-20"
label-key="harvester.fields.image"
@update:value="handleImageDelete"
/>
</div>
</CruResource> </CruResource>
</div> </div>
</template> </template>