Repository: harvester/harvester-ui-extension
Pull request #306: Fix OS upgrade page
Author: login: a110605
Labels: name: require backport/v1.5
Created: 2025-05-20T07:30:20Z | Merged: 2025-05-21T08:51:17Z

<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary

Two changes : 
1. Make upgrade button visible even not enable "View in API".
2. When user chooses an OS image, upload it immediately with progress bar.
 
### PR Checklists
- Do we need to backport this PR change to the [Harvester Dashboard](https://github.com/harvester/dashboard)?
    - [ ] Yes, the relevant PR is at:
- Are backend engineers aware of UI changes?
    - [x] Yes, the backend owner is: @brandboat 

### Related Issue #
- https://github.com/harvester/harvester/issues/7690
- https://github.com/harvester/harvester/issues/8079

### Test screenshot/video

https://github.com/user-attachments/assets/d0640e6b-18f5-44cd-916b-c066b2b860e0



### Extra technical notes summary
<!-- Outline technical changes which may pass unobserved or may help to understand the process of solving the issue -->





Diff:
diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml
index d748aa1e0..ebe6031a5 100644
--- a/pkg/harvester/l10n/en-us.yaml
+++ b/pkg/harvester/l10n/en-us.yaml
@@ -848,6 +848,8 @@ harvester:
         =1 {1 image is uploading, please do not refresh or close the page.}
         other {{count} images are uploading, please do not refresh or close the page.}
         }
+      osUpgrade:
+        uploading: "{name} is uploading, please do not refresh or close the page."
     checksum: Checksum
     checksumTip: Validate the image using the SHA512 checksum, if specified.
 
@@ -1086,7 +1088,7 @@ harvester:
         addConfig: Add Configuration
 
     upgrade:
-      selectExitImage: Please select the OS image to upgrade.
+      unknownImageName: Image name is not found.
       imageUrl: Please input a valid image URL.
       chooseFile: Please select to upload an image.
       checksum: Checksum
diff --git a/pkg/harvester/list/harvesterhci.io.setting.vue b/pkg/harvester/list/harvesterhci.io.setting.vue
index 4567d10da..c7fe57971 100644
--- a/pkg/harvester/list/harvesterhci.io.setting.vue
+++ b/pkg/harvester/list/harvesterhci.io.setting.vue
@@ -2,13 +2,12 @@
 import { mapGetters } from 'vuex';
 import { Banner } from '@components/Banner';
 import Loading from '@shell/components/Loading';
-import { VIEW_IN_API, DEV } from '@shell/store/prefs';
 import { MANAGEMENT } from '@shell/config/types';
 import { allHash } from '@shell/utils/promise';
 import Tabbed from '@shell/components/Tabbed/index.vue';
 import Tab from '@shell/components/Tabbed/Tab.vue';
 import Settings from '@pkg/harvester/components/SettingList.vue';
-import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING } from '../config/settings';
+import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING, HCI_SETTING } from '../config/settings';
 import { HCI } from '../types';
 
 export default {
@@ -21,14 +20,6 @@ export default {
   },
 
   async fetch() {
-    let isDev;
-
-    try {
-      isDev = this.$store.getters['prefs/get'](VIEW_IN_API);
-    } catch {
-      isDev = this.$store.getters['prefs/get'](DEV);
-    }
-
     const isSingleProduct = !!this.$store.getters['isSingleProduct'];
     const inStore = this.$store.getters['currentProduct'].inStore;
 
@@ -77,7 +68,7 @@ export default {
       };
 
       s.hide = s.canHide = (s.kind === 'json' || s.kind === 'multiline' || s.customFormatter === 'json' || s.data.customFormatter === 'json');
-      s.hasActions = !s.readOnly || isDev;
+      s.hasActions = s.id === HCI_SETTING.SERVER_VERSION ? true : !s.readOnly;
       initSettings.push(s);
     });
 
diff --git a/pkg/harvester/models/harvesterhci.io.setting.js b/pkg/harvester/models/harvesterhci.io.setting.js
index fe0d4de33..46c016bab 100644
--- a/pkg/harvester/models/harvesterhci.io.setting.js
+++ b/pkg/harvester/models/harvesterhci.io.setting.js
@@ -28,6 +28,7 @@ export default class HciSetting extends HarvesterResource {
     }
 
     const schema = this.$getters['schemaFor'](HCI.UPGRADE);
+
     const hasUpgradeAccess = !!schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase()));
 
     if (this.id === HCI_SETTING.SERVER_VERSION && hasUpgradeAccess) {
@@ -38,7 +39,7 @@ export default class HciSetting extends HarvesterResource {
         enabled:  true,
         icon:     'icon icon-refresh',
         label:    this.t('harvester.upgradePage.upgrade'),
-        disabled: !!latestUpgrade && !latestUpgrade?.isUpgradeSucceeded
+        disabled: !!latestUpgrade && latestUpgrade?.isUpgradeSucceeded
       });
     }
 
diff --git a/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js b/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js
index e101b6ceb..f3107a031 100644
--- a/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js
+++ b/pkg/harvester/models/harvesterhci.io.virtualmachineimage.js
@@ -305,7 +305,7 @@ export default class HciVmImage extends HarvesterResource {
   }
 
   get uploadImage() {
-    return async(file) => {
+    return async(file, opt) => {
       const formData = new FormData();
 
       formData.append('chunk', file);
@@ -319,6 +319,7 @@ export default class HciVmImage extends HarvesterResource {
             'File-Size':    file.size,
           },
           params: { size: file.size },
+          signal: opt.signal,
         });
       } catch (err) {
         this.$ctx.commit('harvester-common/uploadError', { name: this.name, message: err.message }, { root: true });
diff --git a/pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue b/pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue
index b54255c40..2a1e4e5f1 100644
--- a/pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue
+++ b/pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue
@@ -7,9 +7,10 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
 import { exceptionToErrorsArray } from '@shell/utils/error';
 import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
 import UpgradeInfo from '../../../../components/UpgradeInfo';
-
 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';
 
 const IMAGE_METHOD = {
   NEW:   'new',
@@ -22,7 +23,7 @@ const UPLOAD = 'upload';
 export default {
   name:       'HarvesterAirgapUpgrade',
   components: {
-    Checkbox, CruResource, LabeledSelect, LabeledInput, RadioGroup, UpgradeInfo
+    Checkbox, CruResource, LabeledSelect, LabeledInput, RadioGroup, UpgradeInfo, ImagePercentageBar, Banner
   },
 
   inheritAttrs: false,
@@ -58,16 +59,24 @@ export default {
     this.imageValue = imageValue;
   },
 
+  beforeUnmount() {
+    if (this.uploadController) {
+      this.uploadController.abort();
+    }
+  },
+
   data() {
     return {
-      value:         null,
-      file:          {},
-      imageId:       '',
-      imageSource:   IMAGE_METHOD.NEW,
-      sourceType:    UPLOAD,
-      imageValue:    null,
-      errors:        [],
-      enableLogging: true,
+      value:            null,
+      file:             {},
+      uploadImageId:    '',
+      imageId:          '',
+      imageSource:      IMAGE_METHOD.NEW,
+      sourceType:       UPLOAD,
+      uploadController: null,
+      imageValue:       null,
+      errors:           [],
+      enableLogging:    true,
       IMAGE_METHOD
     };
   },
@@ -100,10 +109,43 @@ export default {
     canEnableLogging() {
       return this.$store.getters['harvester/schemaFor'](HCI.UPGRADE_LOG);
     },
+
+    uploadProgress() {
+      const image = this.$store.getters['harvester/byId'](HCI.IMAGE, this.imageValue.id);
+
+      return image?.status?.progress;
+    },
+
+    enableSave() {
+      if (this.sourceType === DOWNLOAD) {
+        return true;
+      }
+
+      if (this.sourceType === UPLOAD) {
+        return this.fileName === '' ? true : this.uploadProgress === 100;
+      }
+
+      return true;
+    },
+
+    showProgressBar() {
+      return this.sourceType === UPLOAD && this.fileName !== '';
+    },
+
+    showUploadingWarningBanner() {
+      return this.fileName !== '' && this.uploadProgress !== 100;
+    },
+
+    disableUploadButton() {
+      return this.sourceType === UPLOAD && this.fileName !== '' && this.uploadProgress !== 100;
+    },
   },
 
   methods: {
     done() {
+      if (this.uploadController) {
+        this.uploadController.abort();
+      }
       this.$router.push({
         name:   this.doneRoute,
         params: { resource: HCI.SETTING, product: 'harvester' }
@@ -125,24 +167,8 @@ export default {
         if (this.imageSource === IMAGE_METHOD.NEW) {
           this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True';
 
-          if (this.sourceType === UPLOAD) {
-            this.imageValue.spec.sourceType = UPLOAD;
-            const file = this.file;
-
-            if (!file.name) {
-              this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.selectExitImage'));
-              buttonCb(false);
-
-              return;
-            }
-
-            this.imageValue.spec.url = '';
-
-            this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = file.name;
-
-            res = await this.imageValue.save();
-
-            res.uploadImage(file);
+          if (this.sourceType === UPLOAD && this.uploadImageId !== '') {
+            this.value.spec.image = this.uploadImageId;
           } else if (this.sourceType === DOWNLOAD) {
             this.imageValue.spec.sourceType = DOWNLOAD;
             if (!this.imageValue.spec.url) {
@@ -153,9 +179,8 @@ export default {
             }
 
             res = await this.imageValue.save();
+            this.value.spec.image = res.id;
           }
-
-          this.value.spec.image = res.id;
         } else if (this.imageSource === IMAGE_METHOD.EXIST) {
           if (!this.imageId) {
             this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.chooseFile'));
@@ -179,8 +204,39 @@ export default {
       }
     },
 
-    handleFileUpload() {
+    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;
+      }
+
+      this.imageValue.spec.url = '';
+      this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = fileName;
+
+      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 });
+      } catch (e) {
+        this.errors = exceptionToErrorsArray(e);
+      }
+    },
+
+    async handleFileUpload() {
       this.file = this.$refs.file.files[0];
+      this.errors = [];
+      await this.uploadFile(this.file);
     },
 
     selectFile() {
@@ -196,15 +252,15 @@ export default {
         const splitName = suffixName?.split('.') || [];
         const fileSuffix = splitName?.pop()?.toLowerCase();
 
-        if (splitName.length > 1 && fileSuffix === 'iso' && !this.imageValue.spec.displayName) {
+        if (splitName.length > 1 && fileSuffix === 'iso' && suffixName !== this.imageValue.spec.displayName) {
           this.imageValue.spec.displayName = suffixName;
         }
       },
       deep: true
     },
-
     file(neu) {
-      if (!this.imageValue.spec.displayName && neu.name) {
+      // update name input if select new image
+      if (neu.name && neu.name !== this.imageValue.spec.displayName) {
         this.imageValue.spec.displayName = neu.name;
       }
     }
@@ -227,6 +283,7 @@ export default {
       :errors="errors"
       :can-yaml="false"
       finish-button-mode="upgrade"
+      :validation-passed="enableSave"
       :cancel-event="true"
       @finish="save"
       @cancel="done"
@@ -244,12 +301,16 @@ export default {
           t('harvester.upgradePage.selectExisting'),
         ]"
       />
-
+      <Banner
+        v-if="showUploadingWarningBanner"
+        color="warning"
+        :label="t('harvester.image.warning.osUpgrade.uploading', { name: file.name })"
+      />
       <UpgradeInfo />
 
       <div v-if="uploadImage">
         <LabeledInput
-          v-model.trim="imageValue.spec.displayName"
+          v-model:value.trim="imageValue.spec.displayName"
           class="mb-20"
           label-key="harvester.fields.name"
           required
@@ -285,7 +346,7 @@ export default {
 
         <LabeledInput
           v-if="sourceType === 'download'"
-          v-model.trim="imageValue.spec.url"
+          v-model:value.trim="imageValue.spec.url"
           class="labeled-input--tooltip"
           required
           label-key="harvester.image.url"
@@ -298,6 +359,7 @@ export default {
           <button
             type="button"
             class="btn role-primary"
+            :disabled="disableUploadButton"
             @click="selectFile"
           >
             {{ t('harvester.image.uploadFile') }}
@@ -318,6 +380,11 @@ export default {
             {{ fileName ? fileName : t('harvester.generic.noFileChosen') }}
           </span>
         </div>
+        <ImagePercentageBar
+          v-if="showProgressBar"
+          class="mt-20"
+          :value="uploadProgress"
+        />
       </div>
 
       <LabeledSelect
@@ -334,12 +401,17 @@ export default {
 
 <style lang="scss" scoped>
 #air-gap {
+  padding: 20px;
+
   :deep() .image-group .radio-group {
     display: flex;
     .radio-container {
       margin-right: 30px;
     }
   }
+  .parent {
+    grid-template-columns:auto 40px;
+  }
   .chooseFile {
     display: flex;
     align-items: center;


Reviews:
user: login: a110605
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T07:35:36Z
user: login: brandboat
body: LGTM, thanks!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T10:57:45Z
user: login: houhoucoop
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T03:27:06Z
user: login: a110605
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T06:27:03Z
user: login: a110605
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T06:27:30Z
user: login: a110605
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T06:57:26Z
user: login: houhoucoop
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-21T08:21:43Z

Review comments:
diff_hunk: @@ -38,7 +39,7 @@ export default class HciSetting extends HarvesterResource {
         enabled:  true,
         icon:     'icon icon-refresh',
         label:    this.t('harvester.upgradePage.upgrade'),
-        disabled: !!latestUpgrade && !latestUpgrade?.isUpgradeSucceeded
+        disabled: !!latestUpgrade && latestUpgrade?.isUpgradeSucceeded
path: pkg/harvester/models/harvesterhci.io.setting.js
user: login: a110605
body: When last upgrade is succeeded, we should disable this action.

On the contrary, if last upgrade failed, we should enable action to let user upgrade again.

<img width="1483" alt="Screenshot 2025-05-20 at 3 27 22 PM" src="https://github.com/user-attachments/assets/e30603c7-d573-499b-af1a-ed4199a97321" />

created_at: 2025-05-20T07:35:36Z
author_association: COLLABORATOR
diff_hunk: @@ -21,14 +20,6 @@ export default {
   },
 
   async fetch() {
-    let isDev;
-
-    try {
-      isDev = this.$store.getters['prefs/get'](VIEW_IN_API);
path: pkg/harvester/list/harvesterhci.io.setting.vue
user: login: houhoucoop
body: Does `VIEW_IN_API` only affect `server-version`?
created_at: 2025-05-21T01:56:43Z
author_association: COLLABORATOR
diff_hunk: @@ -77,7 +68,7 @@ export default {
       };
 
       s.hide = s.canHide = (s.kind === 'json' || s.kind === 'multiline' || s.customFormatter === 'json' || s.data.customFormatter === 'json');
-      s.hasActions = !s.readOnly || isDev;
+      s.hasActions = true;
path: pkg/harvester/list/harvesterhci.io.setting.vue
user: login: houhoucoop
body: This change will affect all settings, do we need to match only the `server-version`?
created_at: 2025-05-21T02:00:32Z
author_association: COLLABORATOR
diff_hunk: @@ -38,7 +39,7 @@ export default class HciSetting extends HarvesterResource {
         enabled:  true,
         icon:     'icon icon-refresh',
         label:    this.t('harvester.upgradePage.upgrade'),
-        disabled: !!latestUpgrade && !latestUpgrade?.isUpgradeSucceeded
+        disabled: !!latestUpgrade && latestUpgrade?.isUpgradeSucceeded
path: pkg/harvester/models/harvesterhci.io.setting.js
user: login: houhoucoop
body: Should we handle this on the `airgapupgrade` page? Although the upgrade button is disabled here, users can still access the page directly through the URL, then the button is still enabled.
created_at: 2025-05-21T02:07:23Z
author_association: COLLABORATOR
diff_hunk: @@ -179,8 +194,32 @@ export default {
       }
     },
 
-    handleFileUpload() {
+    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;
+      }
+
+      this.imageValue.spec.url = '';
+      this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = fileName;
+
+      const res = await this.imageValue.save();
+
+      this.uploadImageId = res.id;
+      res.uploadImage(file);
path: pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue
user: login: houhoucoop
body: we should `await` the call and catch the timeout exception here, e.g.
```js
try {
  const res = await this.imageValue.save();
  this.uploadImageId = res.id;
  await res.uploadImage(file);
 } catch (err) {
  // handle 408 status or any errors
  // enable the upload file button again?
  this.errors.push(err.message);
}
```
created_at: 2025-05-21T03:02:57Z
author_association: COLLABORATOR
diff_hunk: @@ -179,8 +194,32 @@ export default {
       }
     },
 
-    handleFileUpload() {
+    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;
+      }
+
+      this.imageValue.spec.url = '';
+      this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = fileName;
+
+      const res = await this.imageValue.save();
+
+      this.uploadImageId = res.id;
+      res.uploadImage(file);
+    },
path: pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue
user: login: houhoucoop
body: Ideally we should cancel the request when the user leaves the page or clicks `Cancel`, to avoid unexpected timeout errors. we can do something like this:
1. in `data()`
```
uploadController: null,
```
2. in `uploadFile()`, the `uploadImage` requires to support passing options
Ref: https://axios-http.com/docs/cancellation
```
this.uploadController = new AbortController();
const signal = this.uploadController.signal;
...
await res.uploadImage(file, { signal });
```
3. in `done()`:
```
if (this.uploadController) {
  this.uploadController.abort();
}
```
created_at: 2025-05-21T03:26:36Z
author_association: COLLABORATOR
diff_hunk: @@ -21,14 +20,6 @@ export default {
   },
 
   async fetch() {
-    let isDev;
-
-    try {
-      isDev = this.$store.getters['prefs/get'](VIEW_IN_API);
path: pkg/harvester/list/harvesterhci.io.setting.vue
user: login: a110605
body: yes
created_at: 2025-05-21T06:27:03Z
author_association: COLLABORATOR
diff_hunk: @@ -77,7 +68,7 @@ export default {
       };
 
       s.hide = s.canHide = (s.kind === 'json' || s.kind === 'multiline' || s.customFormatter === 'json' || s.data.customFormatter === 'json');
-      s.hasActions = !s.readOnly || isDev;
+      s.hasActions = true;
path: pkg/harvester/list/harvesterhci.io.setting.vue
user: login: a110605
body: Currently only server-version has readOnly flag.

Do you think we should keep readOnly logic here ?

```
s.hasActions = s.id === 'server-version' ? true : !s.readOnly 
```
created_at: 2025-05-21T06:27:30Z
author_association: COLLABORATOR
diff_hunk: @@ -179,8 +194,32 @@ export default {
       }
     },
 
-    handleFileUpload() {
+    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;
+      }
+
+      this.imageValue.spec.url = '';
+      this.imageValue.metadata.annotations[HCI_ANNOTATIONS.IMAGE_NAME] = fileName;
+
+      const res = await this.imageValue.save();
+
+      this.uploadImageId = res.id;
+      res.uploadImage(file);
+    },
path: pkg/harvester/pages/c/_cluster/airgapupgrade/index.vue
user: login: a110605
body: yeah, [makeRequest](https://github.com/a110605/harvester-dashboard/blob/f11ae4d846c1cc94709f1a2c02a5ee5bed9f8de8/shell/plugins/steve/actions.js#L119) is using axios under the hood.  Let me add signal to uploadImage().

created_at: 2025-05-21T06:57:26Z
author_association: COLLABORATOR

PR comments:
user: login: w13915984028
created_at: 2025-05-21T10:37:39Z
body: BTW, Could we rename `OS upgrde` to `Upgrade` or `Cluster upgrade`? `OS upgrade` sounds like a new concept comparing to existing `Upgrade`. e.g. https://docs.harvesterhci.io/v1.6/upgrade/index
author_association: MEMBER
user: login: w13915984028
created_at: 2025-05-21T11:10:51Z
body: @a110605 The `checksum` for upgrade image seems should be a `must` option, in some slow networks, the upload might take long time, need the help of `checksum` to verfiy the uploaded image first.
author_association: MEMBER
user: login: a110605
created_at: 2025-05-28T04:41:36Z
body: @mergify backport release-harvester-v1.5
author_association: COLLABORATOR
user: login: mergify[bot]
created_at: 2025-05-28T04:41:42Z
body: > backport release-harvester-v1.5

#### ✅ Backports have been created

<details>

* [#318 Fix OS upgrade page (backport #306)](https://github.com/harvester/harvester-ui-extension/pull/318) has been created for branch `release-harvester-v1.5`

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "backport release-harvester-v1.5", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: CONTRIBUTOR