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') }}
</p>
<p class="mb-5">
<p>
{{ t('harvester.upgradePage.upgradeInfo.moreNotes') }} <a
:href="releaseVersion"
target="_blank"

View File

@ -42,8 +42,25 @@ export default {
computed: {
...mapState('action-menu', ['modalData']),
warningMessageKey() {
return this.modalData.warningMessageKey;
title() {
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() {
@ -70,6 +87,12 @@ export default {
return this.resources[0].nameDisplay;
},
needConfirmation() {
const { needConfirmation = true } = this.modalData ;
return needConfirmation === true;
},
plusMore() {
const remaining = this.resources.length - this.names.length;
@ -97,6 +120,10 @@ export default {
},
deleteDisabled() {
if (!this.needConfirmation) {
return false;
}
return this.confirmName !== this.nameToMatch;
},
@ -147,21 +174,29 @@ export default {
v-clean-html="t(warningMessageKey, { type, names: resourceNames }, true)"
></span>
<div class="mt-10 mb-10">
<span
v-clean-html="t('promptRemove.confirmName', { nameToMatch: escapeHtml(nameToMatch) }, true)"
></span>
</div>
<div class="mb-10">
<CopyToClipboardText :text="nameToMatch" />
</div>
<input
id="confirm"
v-model="confirmName"
type="text"
/>
<div class="text-info mt-20">
{{ protip }}
<div
v-if="needConfirmation"
class="mt-20"
>
<div class="mt-10 mb-10">
<span
v-clean-html="t('dialog.promptRemove.confirmName', {
type: formattedType,
nameToMatch: escapeHtml(nameToMatch)
}, true)"
></span>
</div>
<div class="mb-10">
<CopyToClipboardText :text="nameToMatch" />
</div>
<input
id="confirm"
v-model="confirmName"
type="text"
/>
<div class="text-info mt-20">
{{ protip }}
</div>
</div>
<Banner
v-for="(error, i) in errors"

View File

@ -290,7 +290,7 @@ export default {
const res = await this.value.save();
res.uploadImage(file);
await res.uploadImage(file);
buttonCb(true);
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'
productLabel: 'Harvester'
modal:
backup:
success: 'Backup { backUpName } has been initiated.'
addBackup: Add Backup
@ -99,6 +100,9 @@ harvester:
tip: Please enter a virtual machine name!
success: 'Virtual machine { name } cloned successfully.'
failed: 'Failed clone virtual machine!'
osImage:
title: Delete Image
message: Are you sure you want to delete the image { name } ?
downloadImage:
title: Download Image
banner: 'This action takes a while depending on the image size ({ size }). Please be patient.'
@ -872,14 +876,18 @@ harvester:
upgradeNode: Upgrading Node
upgradeSysService: Upgrading System Service
upgradeImage: Download Upgrade Image
osUpgrade: OS Upgrade
osUpgrade: Cluster Upgrade
uploadNew: Upload New Image
deleteHeader: Please select an image to delete.
deleteExisting: Delete Existing Image
selectExisting: Select Existing Image
createRepository: Creating Upgrade Repository
succeeded: Succeeded
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.
pending: Pending
upload:
duplicatedFile: The file you are trying to upload already exists.
repoInfo:
upgradeStatus: Upgrade Status
os: OS
@ -1086,6 +1094,11 @@ harvester:
imageUrl: Please input a valid image URL.
chooseFile: Please select to upload an image.
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:
label: Harvester Monitoring
section:

View File

@ -313,7 +313,7 @@ export default class HciVmImage extends HarvesterResource {
try {
this.$ctx.commit('harvester-common/uploadStart', this.metadata.name, { root: true });
await this.doAction('upload', formData, {
const result = await this.doAction('upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'File-Size': file.size,
@ -321,15 +321,15 @@ export default class HciVmImage extends HarvesterResource {
params: { size: file.size },
signal: opt.signal,
});
return result;
} catch (err) {
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 });
return Promise.reject(err);
throw 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 ImagePercentageBar from '@shell/components/formatter/ImagePercentageBar';
import { Banner } from '@components/Banner';
import isEmpty from 'lodash/isEmpty';
const IMAGE_METHOD = {
NEW: 'new',
EXIST: 'exist'
NEW: 'new',
EXIST: 'exist',
DELETE: 'delete'
};
const DOWNLOAD = 'download';
@ -40,23 +42,8 @@ export default {
spec: { image: '' }
});
const imageValue = await this.$store.dispatch('harvester/create', {
type: HCI.IMAGE,
metadata: {
name: '',
namespace: 'harvester-system',
generateName: 'image-',
annotations: {}
},
spec: {
sourceType: UPLOAD,
displayName: '',
checksum: ''
},
});
await this.initImageValue();
this.value = value;
this.imageValue = imageValue;
},
beforeUnmount() {
@ -67,17 +54,20 @@ export default {
data() {
return {
value: null,
file: {},
uploadImageId: '',
imageId: '',
imageSource: IMAGE_METHOD.NEW,
sourceType: UPLOAD,
uploadController: null,
imageValue: null,
errors: [],
enableLogging: true,
IMAGE_METHOD
value: null,
file: {},
uploadImageId: '',
imageId: '',
deleteImageId: '',
imageSource: IMAGE_METHOD.NEW,
sourceType: UPLOAD,
uploadController: null,
uploadResult: null,
imageValue: null,
enableLogging: true,
IMAGE_METHOD,
skipSingleReplicaDetachedVol: false,
errors: [],
};
},
@ -86,22 +76,45 @@ export default {
return `${ HARVESTER_PRODUCT }-c-cluster-resource`;
},
osImageOptions() {
return this.$store.getters['harvester/all'](HCI.IMAGE)
.filter((I) => I.isOSImage)
.map((I) => {
return {
label: I.spec.displayName,
value: I.id,
disabled: !I.isReady
};
});
skipSingleReplicaDetachedVolFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('skipSingleReplicaDetachedVol');
},
uploadImage() {
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() {
return this.allOSImages.map((I) => {
return {
label: I.spec.displayName,
value: I.id,
disabled: !I.isReady
};
});
},
createNewImage() {
return this.imageSource === IMAGE_METHOD.NEW;
},
selectExistImage() {
return this.imageSource === IMAGE_METHOD.EXIST;
},
deleteExistImage() {
return this.imageSource === IMAGE_METHOD.DELETE;
},
fileName() {
return this.file?.name || '';
},
@ -116,7 +129,11 @@ export default {
return image?.status?.progress;
},
enableSave() {
enableUpgrade() {
if (this.deleteExistImage) {
return false;
}
if (this.sourceType === DOWNLOAD) {
return true;
}
@ -128,16 +145,28 @@ export default {
return true;
},
showProgressBar() {
return this.sourceType === UPLOAD && this.fileName !== '';
},
showUploadingWarningBanner() {
isUploading() {
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() {
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) {
let res = null;
this.file = {};
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') }));
buttonCb(false);
@ -164,6 +212,28 @@ export default {
}
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) {
this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True';
@ -194,12 +264,15 @@ export default {
if (this.canEnableLogging) {
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();
this.done();
buttonCb(true);
} catch (e) {
this.errors = exceptionToErrorsArray(e);
this.errors = [e?.message] || exceptionToErrorsArray(e);
buttonCb(false);
}
},
@ -207,36 +280,71 @@ export default {
async uploadFile(file) {
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) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.unknownImageName'));
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.spec.url = '';
try {
const res = await this.imageValue.save();
this.uploadImageId = res.id;
this.uploadController = new AbortController();
const signal = this.uploadController.signal;
await res.uploadImage(file, { signal });
this.uploadResult = await res.uploadImage(file, { signal });
} 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() {
this.file = this.$refs.file.files[0];
this.uploadImageId = '';
this.errors = [];
await this.uploadFile(this.file);
this.file = this.$refs.file?.files[0];
if (this.file) {
await this.initImageValue();
await this.uploadFile(this.file);
}
},
selectFile() {
@ -246,6 +354,12 @@ export default {
},
watch: {
imageSource(neu) {
if (neu !== IMAGE_METHOD.DELETE) {
this.deleteImageId = '';
}
},
'imageValue.spec.url': {
handler(neu) {
const suffixName = neu?.split('/')?.pop();
@ -283,10 +397,11 @@ export default {
:errors="errors"
:can-yaml="false"
finish-button-mode="upgrade"
:validation-passed="enableSave"
:validation-passed="enableUpgrade"
:cancel-event="true"
@finish="save"
@cancel="done"
@error="e=>errors = e"
>
<RadioGroup
v-model:value="imageSource"
@ -295,20 +410,55 @@ export default {
:options="[
IMAGE_METHOD.NEW,
IMAGE_METHOD.EXIST,
IMAGE_METHOD.DELETE,
]"
:labels="[
t('harvester.upgradePage.uploadNew'),
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
v-if="showUploadingWarningBanner"
color="warning"
class="mt-0 mb-30"
: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
v-model:value.trim="imageValue.spec.displayName"
class="mb-20"
@ -322,14 +472,6 @@ export default {
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
v-model:value="sourceType"
class="mb-20 image-group"
@ -386,15 +528,33 @@ export default {
:value="uploadProgress"
/>
</div>
<LabeledSelect
v-else
v-if="selectExistImage"
v-model:value="imageId"
:options="osImageOptions"
required
class="mb-20"
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>
</div>
</template>