Compare commits

..
Author SHA1 Message Date
Andy Lee c8a0885fe6 ci: fix CI_BRANCH ternary so PR builds skip upload gate (#1062)
* ci: fix CI_BRANCH ternary so PR builds skip upload gate

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

* ci: simplify CI_BRANCH expression comment

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

---------

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

# Conflicts:
#	.github/workflows/build-standalone-on-merge.yaml
2026-07-28 06:34:57 +00:00
mergify[bot]andAndy Lee a0f21d5902 feat: add VMware VDDK image input field to VM migration provider (#1057) (#1060)
* feat: add VMware VDDK image option to VM migration provider

Add an optional VMware VDDK Image field to the Configure Provider step of the VM migration wizard, bound to spec.settings.vddkInitImage when set and left unset when empty. Supports create and edit flows and populates from the existing provider when editing.



* refactor: copilot review



---------


(cherry picked from commit a6bd7ec00e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:33:31 +08:00
mergify[bot]andAndy Lee f0929100f0 feat: validate migration plan name against existing providers and plans (#1053) (#1054)
Show an error banner and disable the Create and Start action when the migration plan name matches an existing provider or migration plan name.


(cherry picked from commit 790cade04d)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-24 16:46:52 +08:00
mergify[bot]andAndy Lee 78ca4bb663 refactor: new design for review page (#1051) (#1052)
* refactor: new design for review page



* refactor: customize empty table rows message



* refactor: fixed the table column



* style: update header tag and align fixed header actions in review migration step



* style: remove unneeded overflow css



---------


(cherry picked from commit 2903e86c02)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-24 10:30:00 +08:00
mergify[bot]andAndy Lee eb917c9d18 fix: forklift mapping UX and migration progress fixes (#1049) (#1050)
* refactor: modify relatedResources using ownerReferences uid



* refactor: wordings



* fix(vm-migration): keep completed VMs green and linkable in failed plans

When a plan fails overall, VMs that already reached 100% were marked with an error: their progress bar turned red while the label still read "Finished Successfully", and the migrated-VM detail link was hidden.

Skip the plan-level failure fallback for VMs at 100% and drop the !planFailed guard from the detail-link condition so successfully migrated VMs stay green and stay navigable.



---------


(cherry picked from commit 2d747e435a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-23 14:41:01 +08:00
Harvester Bot 8815c70361 chore: bump version to 1.9.0-rc3 (#1048)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-22 20:01:38 +08:00
mergify[bot]andVolker Theile eb4c0504c8 fix: Creating a VM with no cloud init user data but network data only fails (#1044) (#1045)
Related to: https://github.com/harvester/harvester/issues/11213


(cherry picked from commit a178302f73)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-21 11:09:55 +08:00
mergify[bot]andAndy Lee 82e7b0da19 docs: require scope-less conventional commit format in guides (#1042) (#1043)
(cherry picked from commit d376bfb3a5)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-21 11:06:37 +08:00
mergify[bot]andAndy Lee 92f34fa64f fix(vm-migration): show elapsed duration for in-progress plans (#1037) (#1038)
formatDuration returned an empty value when a migration was in progress (started set but completed missing), causing the duration column to show an em-dash for all running plans. It now computes elapsed time up to Date.now() when completed is absent, only returning the placeholder when started is unset or timestamps are invalid.

Related issue: harvester/harvester#10417


(cherry picked from commit 7a84e08f99)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 17:07:09 +08:00
mergify[bot]andAndy Lee 6039f21320 docs(agents): clarify agent guide and share it across AI assistants (#1035) (#1036)
* ci: disable header-max-length commit rule



* docs(agents): clarify agent guide and share it across AI assistants

- Symlink CLAUDE.md and GEMINI.md to AGENTS.md so Claude Code, Copilot
  and Gemini read the same guidance
- Use the full conventional-commit format with scope in the guide
- Clarify Vue SFC block order and the feature-flag development steps
- Bump the documented Node.js requirement to v24



---------


(cherry picked from commit 3d39626b93)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:55:10 +08:00
mergify[bot]andAndy Lee 3cbaf97399 refactor(host): update resource usage gauges and table column headers to fit small screen (#1031) (#1034)
* refactor(host): update resource usage gauges and table column headers

- Replace ConsumptionGauge with PercentageBar in CPU/Memory/Storage
  formatters to show the percentage next to the progress bar
- Rename the Disk State column header to Disk (via i18n)
- Display the CPU Manager column header on two lines



* refactor: add noopener noreferrer rel



---------


(cherry picked from commit 1c3b40b631)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:52:16 +08:00
mergify[bot]andAndy Lee bcde1744e1 fix(ci): support scoped commit prefixes in PR label extraction (#1032) (#1033)
Strip the scope in parentheses so titles like 'refactor(host): ...'
resolve to the 'refactor' type instead of falling back to 'other'.


(cherry picked from commit 87baf75301)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:29:24 +08:00
mergify[bot]andAndy Lee e991c9bcc8 feat: improve VM migration dashboard mapping labels and VM detail link (#1029) (#1030)
* feat: improve VM migration dashboard mapping labels and VM detail link

- Display Management Network / Ignored labels via i18n in mappings column and review card
- Add clickable external-link icon to migrated VM detail page after successful migration
- Add tooltip to the VM detail external-link icon



* refactor: modify duration column width



* refactor: copilot review comment



---------


(cherry picked from commit a10d651827)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 14:42:53 +08:00
mergify[bot]andrenovate[bot] 6e709bc00f deps: update dependency @types/node to v25.9.5 (#1021) (#1028)
(cherry picked from commit 11ff7c8a7c)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 12:28:04 +08:00
mergify[bot]andJack Yu 20d40860ce feat: support skip MIG mode configuration in srivogpu (#1009) (#1027)
(cherry picked from commit f6dffe9816)

Signed-off-by: Jack Yu <jack.yu@suse.com>
Co-authored-by: Jack Yu <jack.yu@suse.com>
2026-07-20 12:20:09 +08:00
mergify[bot]andAndy Lee 1496c17f3d docs: add XSS safety comment for VM display name annotation (#1019) (#1020)
(cherry picked from commit b813bc578e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-17 17:32:24 +08:00
mergify[bot]andAndy Lee e99e4ef34f fix: remove bootable volume validation in VM disk validator (#1016) (#1017)
(cherry picked from commit 7c3b2730ab)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-17 17:00:59 +08:00
73e022172b fix: stale cloud-init secret name reused after VM name change (#1012) (#1015)
* fix: regenerate stale cloud-init secret name after VM name change

When creating a VM, the cloud-init/sysprep secret name was generated
once and cached for the component's lifetime (needNewSecret is always
false during create). If a first create attempt was rejected by the
API (e.g. duplicate name) and the user corrected the name and retried,
the VM was created successfully but reused the secret name derived
from the rejected attempt, and any subsequent secret-creation failure
surfaced as a stale error after the VM already existed.

Now the secret name (and Windows sysprep secret name) is regenerated
whenever the VM name prefix used to generate it no longer matches the
current name, and is proactively reset on a failed create attempt so
retries always start fresh.

Fixes harvester/harvester#11174




* refactor: copilot review



* refactor: remove console



---------



(cherry picked from commit 429d9e18ad)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-17 15:50:51 +08:00
mergify[bot]andAndy Lee 7e86c9fae0 ci: modify PR template (#1013) (#1014)
* ci: modify PR template



* refactor: review comment



---------


(cherry picked from commit dc166d8fc9)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-17 15:50:41 +08:00
mergify[bot]andAndy Lee b4b49222c3 feat: allow user specify namespace for migrated VM (#1007) (#1011)
* feat: allow user specify namespace for migrated VM



* refactor: not pre-select target namespace



* refactor: ignore option



* refactor: copilot review



---------


(cherry picked from commit 3ff4534294)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-16 16:16:19 +08:00
mergify[bot]andAndy Lee b937a2147b feat: keep upgrade button appear (#1003) (#1008)
* feat: keep upgrade button appear



* refactor: copilot review



---------


(cherry picked from commit d4223ad2ae)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-16 11:46:21 +08:00
36 changed files with 1133 additions and 466 deletions
+10 -5
View File
@@ -1,14 +1,19 @@
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary ### Summary
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
### PR Checklists
### PR Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
- Are backend engineers aware of UI changes ? - Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is: - [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
### Related Issue # ### Related Issue #
<!-- Define findings related to the feature or bug issue. --> <!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
### Test screenshot or video
### Test screenshot or video (Required)
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary --> <!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
@@ -28,4 +28,8 @@ jobs:
packages: write packages: write
id-token: write id-token: write
with: with:
<<<<<<< HEAD
CI_BRANCH: ${{github.ref_name}} CI_BRANCH: ${{github.ref_name}}
=======
CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
>>>>>>> 719c33f (ci: fix CI_BRANCH ternary so PR builds skip upload gate (#1062))
+15 -15
View File
@@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. > This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
# Project Overview # Project Overview
@@ -28,9 +28,7 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
- Run `yarn lint:fix` before commits. - Run `yarn lint:fix` before commits.
- Use conventional commit format: - Use conventional commit format:
``` ```
<type>: <type>: <description>
<description>
``` ```
- Follow existing naming conventions (PascalCase for components, camelCase for functions). - Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint. - After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
@@ -130,8 +128,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`: - `Vue.js`:
- Composition API components are preferred over Options API. - Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components. - Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Place source tag above template above style. - Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- style tag should contain `lang='scss' scoped`. - The `<style>` tag should include `lang="scss" scoped`.
- `Linting`: Follow the ESLint configuration in the root. - `Linting`: Follow the ESLint configuration in the root.
- **File Structure:** - **File Structure:**
@@ -163,24 +161,26 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide ## Harvester UI Extension Development Guide
1. Backward Compatibility ### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps: To add a feature in a new release, follow these steps:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration. 1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
Check: Use the following getter to verify if the feature is enabled for the current version: ```js
```
computed: { computed: {
newFeatureEnabled() { newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]'); return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
}, },
}, },
``` ```
Render: Use the result of the check to conditionally render the UI components.
3. **Render**: Use the result of the check to conditionally render the UI components.
## E2E Tests (Cypress) ## E2E Tests (Cypress)
@@ -228,7 +228,7 @@ For the Renovate config, see `.github/renovate.json`.
### Commit Message Format ### Commit Message Format
All commit messages must follow the conventional commit format: All commit messages must follow the conventional commit format:
``` ```
<type>[optional scope]: <description> <type>: <description>
[optional body] [optional body]
Symlink
+1
View File
@@ -0,0 +1 @@
./AGENTS.md
Symlink
+1
View File
@@ -0,0 +1 @@
./AGENTS.md
+1 -1
View File
@@ -25,7 +25,7 @@ For Harvester UI extension installation instructions, please refer to the page *
## Development Setup ## Development Setup
Ensure **Node.js v20 or later** is installed for development and debugging. Ensure **Node.js v24 or later** is installed for development and debugging.
### Standalone Mode ### Standalone Mode
+1
View File
@@ -32,6 +32,7 @@ module.exports = {
'subject-empty': [2, 'never'], 'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'], 'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'], 'subject-max-length': [0, 'never'],
'header-max-length': [0, 'always', 100],
'body-leading-blank': [2, 'always'], 'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100], 'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'], 'footer-leading-blank': [2, 'always'],
+1 -3
View File
@@ -6,9 +6,7 @@
- Run `yarn lint:fix` before commits. - Run `yarn lint:fix` before commits.
- Use conventional commit format: - Use conventional commit format:
``` ```
<type>: <type>: <description>
<description>
``` ```
- Follow existing naming conventions (PascalCase for components, camelCase for functions). - Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint. - After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
+12 -10
View File
@@ -16,8 +16,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`: - `Vue.js`:
- Composition API components are preferred over Options API. - Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components. - Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Place source tag above template above style. - Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- style tag should contain `lang='scss' scoped`. - The `<style>` tag should include `lang="scss" scoped`.
- `Linting`: Follow the ESLint configuration in the root. - `Linting`: Follow the ESLint configuration in the root.
- **File Structure:** - **File Structure:**
@@ -49,22 +49,24 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide ## Harvester UI Extension Development Guide
1. Backward Compatibility ### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps: To add a feature in a new release, follow these steps:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration. 1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
Check: Use the following getter to verify if the feature is enabled for the current version: ```js
```
computed: { computed: {
newFeatureEnabled() { newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]'); return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
}, },
}, },
``` ```
Render: Use the result of the check to conditionally render the UI components.
3. **Render**: Use the result of the check to conditionally render the UI components.
@@ -26,7 +26,7 @@
### Commit Message Format ### Commit Message Format
All commit messages must follow the conventional commit format: All commit messages must follow the conventional commit format:
``` ```
<type>[optional scope]: <description> <type>: <description>
[optional body] [optional body]
+1 -1
View File
@@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. > This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
# Project Overview # Project Overview
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.9.0-rc2", "version": "1.9.0-rc3",
"private": false, "private": false,
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
@@ -21,7 +21,7 @@
"yaml": "^2.5.1" "yaml": "^2.5.1"
}, },
"resolutions": { "resolutions": {
"@types/node": "25.9.4", "@types/node": "25.9.5",
"cronstrue": "2.59.0", "cronstrue": "2.59.0",
"d3-color": "3.1.0", "d3-color": "3.1.0",
"ejs": "3.1.10", "ejs": "3.1.10",
+27 -2
View File
@@ -55,8 +55,12 @@ export default {
!this.latestUpgrade.isUpgradeFailed; !this.latestUpgrade.isUpgradeFailed;
}, },
canUpgrade() {
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
},
versionOptions() { versionOptions() {
const versions = this.$store.getters['harvester/all'](HCI.VERSION); const versions = this.$store.getters['harvester/all'](HCI.VERSION) || [];
return versions.map((V) => V.metadata.name); return versions.map((V) => V.metadata.name);
}, },
@@ -154,14 +158,23 @@ export default {
:cluster="currentCluster.nameDisplay" :cluster="currentCluster.nameDisplay"
/> />
</h1> </h1>
<span
v-clean-tooltip="{
content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'),
triggers: ['hover', 'focus', 'touch'],
}"
class="upgrade-btn-wrap"
:tabindex="canUpgrade ? -1 : 0"
>
<button <button
v-if="versionOptions.length && !isUpgradeInProgress" :disabled="!canUpgrade"
type="button" type="button"
class="btn bg-warning btn-sm" class="btn bg-warning btn-sm"
@click="open" @click="open"
> >
<t k="harvester.upgradePage.upgrade" /> <t k="harvester.upgradePage.upgrade" />
</button> </button>
</span>
</header> </header>
<ModalWithCard <ModalWithCard
@@ -296,6 +309,18 @@ export default {
line-height: 23px; line-height: 23px;
} }
.upgrade-btn-wrap {
display: inline-flex;
button {
color: #000 !important;
}
button:hover {
color: #000 !important;
}
}
.currentVersion { .currentVersion {
margin-top: 10px; margin-top: 10px;
display: flex; display: flex;
@@ -42,6 +42,7 @@ const {
username, username,
password, password,
skipTlsVerify, skipTlsVerify,
vddkInitImage,
testPassed, testPassed,
testResult, testResult,
testError, testError,
@@ -97,6 +98,7 @@ watch(selectedProvider, (val) => {
username.value = ''; username.value = '';
password.value = ''; password.value = '';
skipTlsVerify.value = false; skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null; createdProvider.value = null;
createdSecret.value = null; createdSecret.value = null;
} else { } else {
@@ -107,6 +109,7 @@ watch(selectedProvider, (val) => {
if (provider) { if (provider) {
providerName.value = provider.metadata.name; providerName.value = provider.metadata.name;
url.value = provider.spec?.url || ''; url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret; const secretRef = provider.spec?.secret;
@@ -239,6 +242,18 @@ const testConnection = async(buttonCb) => {
if (props.editMode && createdProvider.value) { if (props.editMode && createdProvider.value) {
try { try {
createdProvider.value.spec.url = url.value; createdProvider.value.spec.url = url.value;
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
createdProvider.value.spec.settings = {
...(createdProvider.value.spec.settings || {}),
vddkInitImage: vddkImage,
};
} else if (createdProvider.value.spec.settings) {
delete createdProvider.value.spec.settings.vddkInitImage;
}
await createdProvider.value.save(); await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret; const secretRef = createdProvider.value.spec?.secret;
@@ -283,20 +298,28 @@ const testConnection = async(buttonCb) => {
const namespace = FORKLIFT_NAMESPACE; const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`; const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const provider = await store.dispatch(`${ inStore }/create`, { const spec = {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec: {
type: 'vsphere', type: 'vsphere',
url: url.value, url: url.value,
secret: { secret: {
name: secretName, name: secretName,
namespace, namespace,
}, },
};
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
spec.settings = { vddkInitImage: vddkImage };
} }
const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec,
}); });
await provider.save(); await provider.save();
@@ -420,7 +443,8 @@ defineExpose({ testConnection, clickTestButton });
/> />
</div> </div>
<div> <div class="row">
<div class="col span-6">
<LabeledInput <LabeledInput
v-model:value="url" v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')" :label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
@@ -432,6 +456,16 @@ defineExpose({ testConnection, clickTestButton });
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }} {{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p> </p>
</div> </div>
<div class="col span-6">
<LabeledInput
v-model:value="vddkInitImage"
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
:disabled="isExistingProvider && !editMode"
/>
</div>
</div>
<div class="row"> <div class="row">
<div class="col span-6"> <div class="col span-6">
@@ -1,5 +1,4 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard'; import { RcItemCard } from '@components/RcItemCard';
@@ -18,8 +17,10 @@ const props = defineProps({
clearable: { type: Boolean, default: false }, clearable: { type: Boolean, default: false },
}); });
const selectOptions = computed(() => { // Only offer "Remove Map" for entries that already have a target selected;
if (!props.clearable) { // entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
return props.options; return props.options;
} }
@@ -34,14 +35,9 @@ const selectOptions = computed(() => {
disabled: true, disabled: true,
kind: 'divider', kind: 'divider',
}, },
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options, ...props.options,
]; ];
}); };
</script> </script>
<template> <template>
@@ -85,7 +81,7 @@ const selectOptions = computed(() => {
<div class="mapping-target"> <div class="mapping-target">
<LabeledSelect <LabeledSelect
v-model:value="entry.target" v-model:value="entry.target"
:options="selectOptions" :options="optionsFor(entry)"
:placeholder="placeholder+'...'" :placeholder="placeholder+'...'"
:searchable="true" :searchable="true"
/> />
@@ -2,11 +2,14 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard'; import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import MappingsCell from '../MappingsCell'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { import {
@@ -21,29 +24,70 @@ const props = defineProps({
stepData: { type: Object, required: true }, stepData: { type: Object, required: true },
}); });
const emit = defineEmits(['ready']); const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const vms = ref([]); const vms = computed(() => props.selectedVms);
const networkMappings = ref([]); const networkMappings = ref([]);
const storageMappings = ref([]); const storageMappings = ref([]);
const planName = ref(''); const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
// Restore persisted state // Restore persisted state
planName.value = props.stepData.planName; planName.value = props.stepData.planName;
targetNamespace.value = props.stepData.targetNamespace || '';
const NAMESPACE = FORKLIFT_NAMESPACE; const NS = FORKLIFT_NAMESPACE;
const TARGET_NAMESPACE = 'default';
const nameConflictError = computed(() => {
const name = (planName.value || '').trim();
if (!name) {
return '';
}
if (existingProviderNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
}
if (existingPlanNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
}
return '';
});
watch(planName, (val) => { watch(planName, (val) => {
props.stepData.planName = val; props.stepData.planName = val;
emit('ready', !!val);
}, { immediate: true }); }, { immediate: true });
watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val;
});
watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true });
const formatNetworkTarget = (target = '') => {
if (target === 'pod') {
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
}
if (target === 'ignored') {
return t('harvester.addons.vmMigration.generic.ignored');
}
return target;
};
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0)); const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => { const totalMemoryGB = computed(() => {
@@ -64,7 +108,7 @@ const totalStorageGB = computed(() => {
return bytesToGB(totalBytes); return bytesToGB(totalBytes);
}); });
const vmCards = computed(() => { const vmRows = computed(() => {
return vms.value.map((vm) => { return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0; const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
@@ -78,23 +122,93 @@ const vmCards = computed(() => {
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-'; const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-'; const os = vm.guestName || vm.guestOS || vm.os || '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id)); const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id)); const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
return { return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id, id: vm.id,
name: vm.name || vm.id, name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os, os,
cpus, resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
memGB, resourcesSub: `${ memGB } • ${ diskDisplay }`,
diskDisplay, powerState,
powerStateClass,
networkMappings: vmNetworkMappings, networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings, storageMappings: vmStorageMappings,
networkMapping: networkMappingSort,
storageMapping: storageMappingSort,
}; };
}); });
}); });
const vmHeaders = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
value: 'name',
sort: ['name'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
value: 'os',
sort: ['os'],
width: 140,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
value: 'resources',
sort: false,
width: 120,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 100,
},
{
name: 'networkMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
value: 'networkMapping',
sort: ['networkMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'storageMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
value: 'storageMapping',
sort: ['storageMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'actions',
label: ' ',
align: 'right',
sort: false,
width: 40,
},
];
const onEditSelection = () => emit('edit-selection');
const onEditMappings = () => emit('edit-mappings');
const onRemoveVm = (id) => emit('remove-vm', id);
const startMigrationAction = async() => { const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
@@ -103,13 +217,13 @@ const startMigrationAction = async() => {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider', kind: 'Provider',
name: props.providerName, name: props.providerName,
namespace: NAMESPACE, namespace: NS,
}, },
destination: { destination: {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider', kind: 'Provider',
name: 'host', name: 'host',
namespace: NAMESPACE, namespace: NS,
}, },
}; };
@@ -118,15 +232,15 @@ const startMigrationAction = async() => {
const networkMap = await store.dispatch(`${ inStore }/create`, { const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP, type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NAMESPACE }, metadata: { name: networkMapName, namespace: NS },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef }, spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
}); });
await networkMap.save(); await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, { const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP, type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NAMESPACE }, metadata: { name: storageMapName, namespace: NS },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef }, spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
}); });
@@ -134,7 +248,7 @@ const startMigrationAction = async() => {
const plan = await store.dispatch(`${ inStore }/create`, { const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN, type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NAMESPACE }, metadata: { name: planName.value, namespace: NS },
spec: { spec: {
provider: providerRef, provider: providerRef,
map: { map: {
@@ -142,16 +256,16 @@ const startMigrationAction = async() => {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap', kind: 'NetworkMap',
name: networkMapName, name: networkMapName,
namespace: NAMESPACE, namespace: NS,
}, },
storage: { storage: {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap', kind: 'StorageMap',
name: storageMapName, name: storageMapName,
namespace: NAMESPACE, namespace: NS,
}, },
}, },
targetNamespace: TARGET_NAMESPACE, targetNamespace: targetNamespace.value,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })), vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false, warm: false,
}, },
@@ -177,8 +291,32 @@ const startMigrationAction = async() => {
await plan.startMigration(); await plan.startMigration();
}; };
const init = () => { const init = async() => {
vms.value = props.selectedVms; const inStore = store.getters['currentProduct'].inStore;
try {
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
} catch (e) {
namespaceOptions.value = [];
}
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {}
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
} catch (e) {}
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
if (props.mappingEntries) { if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({ networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
@@ -211,115 +349,177 @@ defineExpose({ startMigration: startMigrationAction });
<div <div
class="review-migration-content" class="review-migration-content"
> >
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary --> <!-- Migration Details Summary -->
<div class="migration-details"> <div class="migration-details">
<div class="section-header">
<h3 class="section-title m-0"> <h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }} {{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3> </h3>
<div class="span-9"> <span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row">
<LabeledInput <LabeledInput
v-model:value="planName" v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')" :label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')" :placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required required
/> />
<LabeledSelect
v-model:value="targetNamespace"
class="namespace-select"
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
:options="namespaceOptions"
required
/>
</div> </div>
<div class="details-grid span-9">
<div class="detail-column"> <div class="summary-row">
<div class="detail-item"> <div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="detail-value detail-value-large">{{ vms.length }}</span> <span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div> </div>
</div>
<div class="detail-column"> <div class="stats-column">
<div class="detail-item"> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ providerName }}</span> <span class="detail-value">{{ providerName }}</span>
</div> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
</div> <span class="detail-value">{{ vms.length }}</span>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ TARGET_NAMESPACE }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span> <span class="detail-value">{{ totalVCpu }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span> <span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span> <span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div> </div>
</div> </div>
<div class="detail-column grid-column-2">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
</span>
</div>
</div>
</div>
</div> </div>
<!-- Virtual Machines --> <!-- Virtual Machines -->
<div class="vm-section"> <div class="vm-section">
<h3 class="section-title m-0"> <SortableTable
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }}) class="vm-sortable-table"
</h3> :rows="vmRows"
:headers="vmHeaders"
<div class="vm-cards-grid"> :search="false"
<RcItemCard :table-actions="false"
v-for="vm in vmCards" :row-actions="false"
:id="vm.id" :groupable="false"
:key="vm.id" :paging="true"
:variant="'small'" key-field="_key"
:header="{ title: { text: vm.name } }"
> >
<template #item-card-content> <template #header-left>
<div class="vm-card-content"> <h4 class="section-title m-0">
<div class="vm-card-specs"> {{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
<span class="vm-os text-deemphasized">{{ vm.os }}</span> </h4>
<span class="vm-resources"> </template>
<i <template #header-right>
class="icon icon-disk" <a
aria-hidden="true" href="#"
/> class="edit-selection-link"
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }} @click.prevent="onEditSelection"
</span> >
</div> {{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
<MappingsCell </a>
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)" </template>
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)" <template #cell:vmName="{ row }">
/> <div class="vm-name-cell">
<span class="vm-name">{{ row.name }}</span>
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
</div> </div>
</template> </template>
</RcItemCard> <template #cell:resources="{ row }">
</div> <span>{{ row.resources }}</span><br>
</div> <span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<!-- Cold Migration Warning --> <template #cell:powerState="{ row }">
<Banner <BadgeState
color="warning" :label="row.powerState"
class="m-0" :color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:networkMapping="{ row }">
<template v-if="row.networkMappings.length">
<div
v-for="(m, i) in row.networkMappings"
:key="'net-' + i"
class="mapping-cell"
> >
<div> <span class="mapping-source">{{ m.source }}</span>
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br> <span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }} </div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:storageMapping="{ row }">
<template v-if="row.storageMappings.length">
<div
v-for="(m, i) in row.storageMappings"
:key="'stor-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ m.target }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:actions="{ row }">
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
<rc-dropdown-trigger
variant="ghost"
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<rc-dropdown-item @click="onEditMappings">
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
</rc-dropdown-item>
<rc-dropdown-item
class="text-error"
@click="onRemoveVm(row.id)"
>
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>
<template #no-rows>
<tr class="no-rows">
<td
:colspan="vmHeaders.length"
class="text-center"
>
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
</td>
</tr>
</template>
</SortableTable>
</div> </div>
</Banner>
<!-- Error banner --> <!-- Error banner -->
<Banner <Banner
@@ -347,28 +547,63 @@ defineExpose({ startMigration: startMigrationAction });
font-weight: 600; font-weight: 600;
} }
.details-grid { .name-row {
display: grid; display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: 12px clamp(12px, 1vw, 96px);
gap: 12px clamp(64px, 8vw, 128px); align-items: start;
line-height: 20px;
} }
.detail-column { .summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
line-height: 20px;
margin-top: 12px;
}
.section-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
&.grid-column-2 { .section-description {
grid-column: span 2; font-size: 14px;
} }
} }
.detail-item { .migration-mode-column {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between; align-items: flex-start;
gap: 8px;
font-size: 14px;
.cold-migration-badge {
padding: 2px 10px;
border-radius: 4px;
opacity: 0.8;
font-weight: 600;
background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600;
.theme-light & {
background: rgba(168, 128, 26, 0.16);
}
}
}
.stats-column {
display: grid;
grid-template-columns: auto auto;
justify-content: start;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
}
.detail-label { .detail-label {
font-size: 14px; font-size: 14px;
@@ -378,45 +613,58 @@ defineExpose({ startMigration: startMigrationAction });
.detail-value { .detail-value {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
&.detail-value-large {
font-size: 24px;
} }
.edit-selection-link {
cursor: pointer;
color: var(--link);
font-size: 14px;
}
.vm-sortable-table {
:deep(.fixed-header-actions) {
align-items: end;
}
:deep(table) {
table-layout: fixed;
}
:deep(td) {
vertical-align: middle;
overflow: hidden;
}
:deep(th:first-child),
:deep(td:first-child) {
padding-left: 16px;
} }
} }
.vm-cards-grid { .vm-name-cell {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.vm-card-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
line-height: 20px; .vm-name {
font-weight: 600;
} }
.vm-card-specs { .vm-id {
display: flex; font-size: 12px;
flex-direction: row; }
gap: 16px;
align-items: center;
.vm-os {
font-size: 14px;
} }
.vm-resources { .mapping-cell {
font-size: 14px; .mapping-target {
color: var(--muted); margin-left: 4px;
display: inline-flex; white-space: nowrap;
align-items: center;
gap: 8px;
} }
} }
.mapping-cell + .mapping-cell {
margin-top: 8px;
}
.banner-title { .banner-title {
font-weight: 600; font-weight: 600;
} }
@@ -86,4 +86,5 @@ export const HCI = {
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind', CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks', CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep', WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
}; };
+1 -1
View File
@@ -286,7 +286,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount', labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length', value: 'spec.vms.length',
sort: 'spec.vms.length', sort: 'spec.vms.length',
align: 'left', align: 'center',
}; };
// Plan reference column in forklift.konveyor.io.migration list page // Plan reference column in forklift.konveyor.io.migration list page
@@ -0,0 +1,133 @@
<script>
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { Checkbox } from '@components/Form/Checkbox';
import { escapeHtml } from '@shell/utils/string';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterEnableSriovGpuDevice',
emits: ['close'],
components: {
AsyncButton,
Card,
Checkbox,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return { skipMigConfiguration: this.resources[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true' };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
this.$emit('close');
},
async save(buttonCb) {
const actionResource = this.resources[0];
const prevEnabled = actionResource.spec.enabled;
const prevAnnotation = actionResource.metadata.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
try {
if (this.skipMigConfiguration) {
if (!actionResource.metadata.annotations) {
actionResource.metadata.annotations = {};
}
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = 'true';
} else {
if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
}
actionResource.spec.enabled = true;
await actionResource.save();
buttonCb(true);
this.close();
} catch (err) {
actionResource.spec.enabled = prevEnabled;
if (prevAnnotation !== undefined) {
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = prevAnnotation;
} else if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(actionResource.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.sriovgpu.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<Checkbox
v-model:value="skipMigConfiguration"
:label="t('harvester.sriovgpu.enable.skipMigConfiguration')"
/>
<p class="note mt-10">
{{ t('harvester.sriovgpu.enable.note') }}
</p>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
@click="save"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
.note {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
</style>
@@ -93,6 +93,9 @@ export default {
const hostname = this.value.spec.template.spec.hostname || ''; const hostname = this.value.spec.template.spec.hostname || '';
// Display name can contain arbitrary strings. There is no XSS risk because the value is
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
// this field. See harvester/harvester#10423 for details.
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]); const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return { return {
@@ -484,6 +487,20 @@ export default {
await value.save(); await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS); await this.applyHooks(AFTER_SAVE_HOOKS);
} catch (e) { } catch (e) {
// Reset the generated secret name(s) so a retried create (e.g. after correcting an
// invalid/duplicate VM name) always regenerates them from the current name instead of
// reusing names derived from this failed attempt. Only do this for create: in edit mode
// secretName/sysprep.secretName may reference an existing secret loaded from the VM,
// which must not be discarded. See harvester/harvester#11174.
if (this.isCreate) {
this.secretName = '';
this.secretNamePrefixUsed = '';
if (this.sysprepSecretNamePrefixUsed !== '') {
this.sysprep.secretName = '';
this.sysprepSecretNamePrefixUsed = '';
}
}
this.errors.push(...exceptionToErrorsArray(e)); this.errors.push(...exceptionToErrorsArray(e));
buttonCb(false); buttonCb(false);
} }
+63 -28
View File
@@ -1,11 +1,11 @@
<script> <script>
import ConsumptionGauge from '@shell/components/ConsumptionGauge'; import PercentageBar from '@shell/components/PercentageBar';
import { METRIC, NODE } from '@shell/config/types'; import { METRIC, NODE } from '@shell/config/types';
import { parseSi } from '@shell/utils/units'; import { parseSi } from '@shell/utils/units';
export default { export default {
name: 'HarvesterCpuUsed', name: 'HarvesterCpuUsed',
components: { ConsumptionGauge }, components: { PercentageBar },
props: { props: {
value: { value: {
@@ -74,54 +74,89 @@ export default {
return 0; return 0;
} }
}, },
}
reservedPercentage() {
return this.cpuTotal ? (this.reserved * 100) / this.cpuTotal : 0;
},
usedPercentage() {
return this.cpuTotal ? (this.used * 100) / this.cpuTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.numberFormatter(this.reserved || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.numberFormatter(this.used || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
},
methods: {
numberFormatter(value) {
return Number.isInteger(value) ? value : value.toFixed(2);
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<ConsumptionGauge <div class="consumption-gauge">
:capacity="cpuTotal" <div class="numbers">
:used="reserved" <span class="mr-5">
:units="cpuUnits"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }} {{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span> </span>
</span> </div>
</template> <div class="mt-10">
</ConsumptionGauge> <PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
<div <div
v-if="showUsed" v-if="showUsed"
class="mt-10" class="consumption-gauge mt-10"
> >
<ConsumptionGauge <div class="numbers">
:capacity="cpuTotal"
:used="used"
:units="cpuUnits"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span> <span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }} {{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span> </span>
</span> </div>
</template> <div class="mt-10">
</ConsumptionGauge> <PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }
@@ -1,11 +1,11 @@
<script> <script>
import ConsumptionGauge from '@shell/components/ConsumptionGauge'; import PercentageBar from '@shell/components/PercentageBar';
import { METRIC, NODE } from '@shell/config/types'; import { METRIC, NODE } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units'; import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit'; import { UNIT_SUFFIX } from '../utils/unit';
export default { export default {
name: 'HarvesterMemoryUsed', name: 'HarvesterMemoryUsed',
components: { ConsumptionGauge }, components: { PercentageBar },
props: { props: {
value: { value: {
@@ -76,6 +76,30 @@ export default {
return 0; return 0;
} }
}, },
reservedPercentage() {
return this.memoryTotal ? (this.reserved * 100) / this.memoryTotal : 0;
},
usedPercentage() {
return this.memoryTotal ? (this.used * 100) / this.memoryTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.reserved || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.used || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
}, },
methods: { methods: {
@@ -96,50 +120,53 @@ export default {
<template> <template>
<div> <div>
<ConsumptionGauge <div class="consumption-gauge">
:capacity="memoryTotal" <div class="numbers">
:used="reserved" <span class="mr-10">
:units="memoryUnits"
:number-formatter="memoryFormatter"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }} {{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span> </span>
</span> </div>
</template> <div class="mt-10">
</ConsumptionGauge> <PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
<div <div
v-if="showUsed" v-if="showUsed"
class="mt-10" class="consumption-gauge mt-10"
> >
<ConsumptionGauge <div class="numbers">
:capacity="memoryTotal"
:used="used"
:units="memoryUnits"
:number-formatter="memoryFormatter"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span> <span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }} {{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span> </span>
</span> </div>
</template> <div class="mt-10">
</ConsumptionGauge> <PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }
@@ -1,12 +1,12 @@
<script> <script>
import ConsumptionGauge from '@shell/components/ConsumptionGauge'; import PercentageBar from '@shell/components/PercentageBar';
import { LONGHORN } from '@shell/config/types'; import { LONGHORN } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units'; import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit'; import { UNIT_SUFFIX } from '../utils/unit';
export default { export default {
name: 'HarvesterStorageUsed', name: 'HarvesterStorageUsed',
components: { ConsumptionGauge }, components: { PercentageBar },
props: { props: {
value: { value: {
@@ -120,6 +120,14 @@ export default {
unit: this.allocatedUnits, unit: this.allocatedUnits,
}; };
}, },
usedPercentage() {
return this.storageStats.maximum ? (this.storageStats.used * 100) / this.storageStats.maximum : 0;
},
allocatedPercentage() {
return this.storageStats.total ? (this.storageStats.scheduled * 100) / this.storageStats.total : 0;
},
}, },
methods: { methods: {
@@ -144,53 +152,54 @@ export default {
<div> <div>
<div <div
v-if="showAllocated" v-if="showAllocated"
class="consumption-gauge"
> >
<ConsumptionGauge <div class="numbers">
:capacity="storageStats.total" <span class="mr-10">
:used="storageStats.scheduled"
:units="allocatedUnits"
:number-formatter="formatter"
:resource-name="resourceName"
>
<template #title="{formattedPercentage}">
<span>
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }} {{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span> </span>
</span>
</template>
</ConsumptionGauge>
</div> </div>
<ConsumptionGauge <div class="mt-10">
:capacity="storageStats.maximum" <PercentageBar
:used="storageStats.used" :model-value="allocatedPercentage"
:units="usedUnits" show-percentage
:number-formatter="formatter" />
:resource-name="showAllocated ? '' : resourceName" </div>
:class="{ </div>
'mt-10': showAllocated, <div
}" class="consumption-gauge"
:class="{ 'mt-10': showAllocated }"
> >
<template #title="{formattedPercentage}"> <div class="numbers">
<span> <span>
{{ t('node.detail.glance.consumptionGauge.used') }} {{ t('node.detail.glance.consumptionGauge.used') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span> </span>
</span> </div>
</template> <div class="mt-10">
</ConsumptionGauge> <PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }
+43 -8
View File
@@ -331,7 +331,8 @@ harvester:
message: Message message: Message
phase: Phase phase: Phase
attachedVM: Attached Virtual Machine attachedVM: Attached Virtual Machine
cpuManager: CPU Manager cpuManager: CPU<br>Manager
diskState: Disk
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to. routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
fingerprint: Fingerprint fingerprint: Fingerprint
value: Value value: Value
@@ -487,7 +488,6 @@ harvester:
volume: volume:
upperType: Volume name upperType: Volume name
lowerType: volume name lowerType: volume name
needAtLeastOneBootable: 'At least one bootable volume is required!'
image: image:
ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.' ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.' ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
@@ -1099,6 +1099,8 @@ harvester:
downloadLog: Download Log downloadLog: Download Log
logStatus: Log Download Status logStatus: Log Download Status
dismissMessage: Dismiss it dismissMessage: Dismiss it
upgradeAvailable: Upgrade Available
upgradeUnavailable: Upgrade Unavailable
upgradeInfo: upgradeInfo:
warning: WARNING warning: WARNING
doc: Read the <a href="{url}" target="_blank">documentation</a> before starting the upgrade process. Ensure that you complete procedures that are relevant to your environment and the version you are upgrading to. doc: Read the <a href="{url}" target="_blank">documentation</a> before starting the upgrade process. Ensure that you complete procedures that are relevant to your environment and the version you are upgrading to.
@@ -1836,6 +1838,7 @@ harvester:
unknown: Unknown unknown: Unknown
identifier: Identifier identifier: Identifier
podNetwork: Pod Network podNetwork: Pod Network
ignored: Ignored
usedBy: "Used by:" usedBy: "Used by:"
vlan: "VLAN {id}" vlan: "VLAN {id}"
vCpu: "{count} vCPU" vCpu: "{count} vCPU"
@@ -1890,11 +1893,14 @@ harvester:
providerSelect: Provider providerSelect: Provider
createNew: Create new provider createNew: Create new provider
requirementsTitle: Requirements requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher." requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Harvester Node."
name: Name name: Name
urlLabel: vCenter/ESXi URL urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk" urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https:// urlHint: Enter the full URL including https://
vddkImageLabel: VMware VDDK Image
vddkImagePlaceholder: "[your-private-registry]/vddk-image:latest"
vddkImageTooltip: Specify the VMware VDDK (Virtual Disk Development Kit) Image to accelerate and optimize VMware virtual machine migration.
skipSsl: Skip SSL certificate verification skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments skipSslHint: Not recommended for production environments
testConnection: testConnection:
@@ -1954,14 +1960,15 @@ harvester:
initializingMigration: Initializing migration initializingMigration: Initializing migration
finishedSuccessfully: Finished Successfully finishedSuccessfully: Finished Successfully
step: "Step {index}" step: "Step {index}"
vmCount: "{count} VMs" vmCount: "{count, plural, one {# VM} other {# VMs}}"
vmId: "id: {id}" vmId: "id: {id}"
checkMigratedVm: Click to check migrated VM details
configureMappings: configureMappings:
title: Set Mappings title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue save: Save Mappings and Continue
noTemplate: Start from scratch noTemplate: Start from scratch
removeMap: Remove Map removeMap: Remove mapping
networkMapping: networkMapping:
title: Network Mapping title: Network Mapping
description: Map VMware port groups to Harvester networks description: Map VMware port groups to Harvester networks
@@ -1969,27 +1976,49 @@ harvester:
template: Use existing network mapping as template template: Use existing network mapping as template
options: options:
podNetworking: Management Network podNetworking: Management Network
ignored: Ignored ignored: Ignored (No NIC will be created)
storageMapping: storageMapping:
title: Storage Mapping title: Storage Mapping
description: Map VMware datastores to Harvester storage classes description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Longhorn Storage Volume placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template template: Use existing storage mapping as template
reviewMigration: reviewMigration:
title: Review Migration Plan title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster. description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name planName: Name
planNamePlaceholder: "e.g. my-migration-plan" planNamePlaceholder: "e.g. my-migration-plan"
planNameProviderConflict: 'The name “{name}” is already used by an existing provider. Please input a different plan name.'
planNamePlanConflict: 'The name “{name}” is already used by an existing migration plan. Please input a different plan name.'
migrationDetails: Migration Details migrationDetails: Migration Details
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs totalVms: Total VMs
vcpu: vCPU vcpu: vCPU
memory: Memory memory: Memory
storage: Storage storage: Storage
source: Source source: Source
targetNamespace: Target Namespace provider: Provider
targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace"
migrationMode: Migration Mode migrationMode: Migration Mode
coldMigration: Cold Migration coldMigration: Cold Migration
coldMigrationDescription: All source VMs will be powered off during migration.
coldMigrationHint: Plan a maintenance window and notify relevant stakeholders before starting.
virtualMachines: Virtual Machines virtualMachines: Virtual Machines
editSelection: Edit selection
noVmSelected: "No VMs selected. Return to "
noVmSelectedLink: "Step 2 to select virtual machines."
columns:
vmName: VM Name
os: Operating System
resources: Resources
powerState: Power State
networkMapping: Network Mapping
storageMapping: Storage Mapping
sourceTarget: "Source \u2192 Target"
actions:
menuLabel: Actions
editMappings: Edit Mappings
removeFromPlan: Remove from Plan
warningTitle: "Cold Migration Warning" warningTitle: "Cold Migration Warning"
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders" warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
startMigration: Start Migration Plan startMigration: Start Migration Plan
@@ -2254,6 +2283,12 @@ harvester:
prefix: The nvidia-driver-toolkit add-on is not enabled, click prefix: The nvidia-driver-toolkit add-on is not enabled, click
middle: here middle: here
suffix: to enable it to manage your SR-IOV GPU devices. suffix: to enable it to manage your SR-IOV GPU devices.
enable:
title: Enable SR-IOV GPU Device
skipMigConfiguration: Use timesliced mode (skip MIG configuration)
note: If unchecked, MIG mode will be attempted by default. If MIG mode is not supported or fails to initialize, it will fall back to timesliced mode. The actual mode depends on whether the GPU supports MIG.
timeslicedBadge: timesliced mode
timesliced: Timesliced
migconfiguration: migconfiguration:
label: vGPU MIG Configurations label: vGPU MIG Configurations
@@ -86,6 +86,13 @@ export default {
value: 'spec.address', value: 'spec.address',
sort: ['spec.address'] sort: ['spec.address']
}, },
{
name: 'timesliced',
labelKey: 'harvester.sriovgpu.timesliced',
value: 'isTimesliced',
formatter: 'Checked',
align: 'center',
},
{ {
name: 'vfAddresses', name: 'vfAddresses',
label: 'VF Addresses', label: 'VF Addresses',
+13 -4
View File
@@ -84,7 +84,7 @@ export default {
}, },
{ {
...NAME, ...NAME,
width: 130, width: 150,
}, },
{ {
name: 'host-ip', name: 'host-ip',
@@ -136,14 +136,14 @@ export default {
value: 'id', value: 'id',
formatter: 'HarvesterCPUPinning', formatter: 'HarvesterCPUPinning',
formatterOpts: { rows: this.rows }, formatterOpts: { rows: this.rows },
width: 150, width: 75,
align: 'center', align: 'center',
}); });
} }
if (this.hasLonghornSchema) { if (this.hasLonghornSchema) {
out.push({ out.push({
name: 'diskState', name: 'diskState',
labelKey: 'tableHeaders.diskState', labelKey: 'harvester.tableHeaders.diskState',
value: 'diskState', value: 'diskState',
formatter: 'HarvesterDiskState', formatter: 'HarvesterDiskState',
width: 130, width: 130,
@@ -156,7 +156,6 @@ export default {
name: 'console', name: 'console',
label: ' ', label: ' ',
align: 'right', align: 'right',
width: 80,
}); });
return out; return out;
@@ -239,6 +238,8 @@ export default {
v-if="!row.consoleUrl" v-if="!row.consoleUrl"
:href="consoleDocLink" :href="consoleDocLink"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="console-info"
><i class="icon icon-info" /></a> ><i class="icon icon-info" /></a>
</div> </div>
</template> </template>
@@ -249,5 +250,13 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.console-button { .console-button {
display: flex; display: flex;
position: relative;
justify-content: flex-end;
}
.console-info {
position: absolute;
top: -8px;
right: -3px;
} }
</style> </style>
+22 -4
View File
@@ -162,6 +162,12 @@ export default {
machineType: '', machineType: '',
machineTypes: [], machineTypes: [],
secretName: '', secretName: '',
// Tracks the name prefix that `secretName` was generated from, so it can be
// regenerated if the VM name changes (e.g. after a failed create attempt is retried
// with a different name). See harvester/harvester#11174.
secretNamePrefixUsed: '',
// Same purpose as secretNamePrefixUsed, but for the Windows sysprep secret name.
sysprepSecretNamePrefixUsed: '',
secretRef: null, secretRef: null,
showAdvanced: false, showAdvanced: false,
deleteAgent: true, deleteAgent: true,
@@ -866,18 +872,30 @@ export default {
} }
}); });
if (this.needNewSecret || !this.secretName) { // Regenerate the cloud-init secret name whenever it hasn't been generated yet, or the VM
// name has changed since it was last generated (e.g. a previous create attempt failed with
// an invalid/duplicate name and the user corrected it). Without this, a stale secret name
// derived from a rejected attempt gets reused for the VM that's actually created.
// See harvester/harvester#11174.
if (this.needNewSecret || !this.secretName || (this.isCreate && this.secretNamePrefixUsed !== this.secretNamePrefix)) {
this.secretName = this.generateSecretName(this.secretNamePrefix); this.secretName = this.generateSecretName(this.secretNamePrefix);
this.secretNamePrefixUsed = this.secretNamePrefix;
} }
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) { if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.(); const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
// If we have content but no secret name, it's a new secret that needs a name. // If we have content but no secret name, it's a new secret that needs a name. Also
if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) { // regenerate when creating a VM if the name prefix has changed since it was last
// generated.
const sysprepNeedsNewSecretName = !this.sysprep.secretName?.trim?.() ||
(this.isCreate && this.sysprepSecretNamePrefixUsed !== '' && this.sysprepSecretNamePrefixUsed !== this.secretNamePrefix);
if (hasSysprepContent && sysprepNeedsNewSecretName) {
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep'; const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`; this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
this.sysprepSecretNamePrefixUsed = this.secretNamePrefix;
} }
// Preserve/attach sysprep whenever a secret is selected/known. // Preserve/attach sysprep whenever a secret is selected/known.
@@ -896,7 +914,7 @@ export default {
} }
} }
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) { if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
if (!this.isWindows) { if (!this.isWindows) {
disks.push({ disks.push({
name: 'cloudinitdisk', name: 'cloudinitdisk',
@@ -69,16 +69,15 @@ export default class SRIOVDevice extends SteveModel {
return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0; return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0;
} }
async enableDevice() { get isTimesliced() {
try { return this.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true';
this.spec.enabled = true;
await this.save();
} catch (err) {
this.$dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }),
err,
}, { root: true });
} }
enableDevice() {
this.$dispatch('promptModal', {
resources: [this],
component: 'EnableSriovGpuDevice'
});
} }
async disableDevice() { async disableDevice() {
+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.9.0-rc2", "version": "1.9.0-rc3",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
@@ -37,6 +37,7 @@ const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN)); const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP)); const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP)); const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
const allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
const escapeHtml = (value = '') => String(value) const escapeHtml = (value = '') => String(value)
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -98,12 +99,12 @@ const formatPipelineTooltip = (pipeline = []) => {
}; };
const formatDuration = (started, completed) => { const formatDuration = (started, completed) => {
if (!started || !completed) { if (!started) {
return { display: null, seconds: -1 }; return { display: null, seconds: -1 };
} }
const startMs = new Date(started).getTime(); const startMs = new Date(started).getTime();
const endMs = new Date(completed).getTime(); const endMs = completed ? new Date(completed).getTime() : Date.now();
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) { if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
return { display: null, seconds: -1 }; return { display: null, seconds: -1 };
@@ -129,8 +130,8 @@ const rows = computed(() => {
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs); const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs); const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`); plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking') : (e.destination?.name || 'Ignored') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`); plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.storageClass || 'Ignored' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-'; plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
@@ -178,11 +179,35 @@ const rows = computed(() => {
overallProgress = Math.round(overallProgress * 10) / 10; overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it // If no step-level error but the plan itself is failed, surface it.
if (!errorMsg && plan.planFailed) { // Skip VMs that already reached 100% (they finished successfully before
// the plan failed) so their bar stays green instead of turning red.
if (!errorMsg && plan.planFailed && overallProgress < 100) {
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`; errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
} }
const vmNamespace = vm.namespace || plan.spec?.targetNamespace || plan.metadata?.namespace;
const routeParams = currentRouter().currentRoute?.value?.params || {};
const product = routeParams.product || store.getters['productId'];
const cluster = routeParams.cluster || store.getters['clusterId'];
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
// A VM that individually reached 100% without error should be navigable
// even when the overall plan failed (other VMs in the plan may have
// failed); `overallProgress >= 100 && !errorMsg && targetVm` already
// guarantees this VM migrated successfully.
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planCanceled && !!targetVm && !!product && !!cluster;
const vmDetailLocation = canNavigateToVm ? {
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
params: {
product,
cluster,
resource: HCI.VM,
namespace: targetVm.metadata.namespace,
id: targetVm.metadata.name,
}
} : null;
return { return {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'), vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '', vmId: vm.id || '',
@@ -191,6 +216,7 @@ const rows = computed(() => {
pipelineTooltip: formatPipelineTooltip(pipeline), pipelineTooltip: formatPipelineTooltip(pipeline),
errorMsg, errorMsg,
canceled: plan.planCanceled, canceled: plan.planCanceled,
vmDetailLocation,
}; };
}); });
@@ -248,7 +274,7 @@ const headers = [
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration', labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
value: 'duration', value: 'duration',
sort: 'durationSeconds', sort: 'durationSeconds',
width: 120, width: 90,
}, },
{ ...AGE }, { ...AGE },
]; ];
@@ -283,6 +309,17 @@ const init = async() => {
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]); ]);
// VMs are only needed to conditionally show the post-migration VM-detail
// link, so load them separately and non-fatally: a failure here (or the
// cost of findAll on large clusters) should not block plans/maps from
// rendering.
try {
await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.VM });
} catch (e) {
// Intentionally ignored: the dashboard still works without the VM list,
// the migrated VM-detail link simply won't be shown.
}
} catch (e) { } catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')]; errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally { } finally {
@@ -366,7 +403,17 @@ init();
> >
<div class="vm-progress-header"> <div class="vm-progress-header">
<div class="vm-name-block"> <div class="vm-name-block">
<span class="vm-name">{{ vm.vmName }}</span> <span class="vm-name">
{{ vm.vmName }}
<router-link
v-if="vm.vmDetailLocation"
v-clean-tooltip="t('harvester.addons.vmMigration.dashboard.progress.checkMigratedVm')"
:to="vm.vmDetailLocation"
class="vm-detail-link"
>
<i class="icon icon-external-link" />
</router-link>
</span>
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span> <span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
</div> </div>
</div> </div>
@@ -457,6 +504,14 @@ init();
.vm-name { .vm-name {
font-size: 14px; font-size: 14px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-detail-link {
display: inline-flex;
align-items: center;
} }
.vm-name-block { .vm-name-block {
@@ -46,6 +46,7 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -193,6 +194,7 @@ const init = async() => {
stepData.provider.selectedProvider = fetchedProvider.metadata.name; stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name; stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || ''; stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider; stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret; const secretRef = fetchedProvider.spec?.secret;
@@ -41,6 +41,7 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -56,7 +57,7 @@ const stepData = reactive({
networkEntries: [], networkEntries: [],
storageEntries: [], storageEntries: [],
}, },
review: { planName: '' }, review: { planName: '', targetNamespace: '' },
}); });
const steps = reactive([ const steps = reactive([
@@ -173,6 +174,24 @@ const onReviewReady = (ready) => {
reviewReady.value = ready; reviewReady.value = ready;
}; };
const onEditSelection = () => {
wizardComponent.value?.goToStep(2);
};
const onEditMappings = () => {
wizardComponent.value?.goToStep(3);
};
const onRemoveVm = (id) => {
selectedVMs.value = selectedVMs.value.filter((vm) => vm.id !== id);
stepData.vms.selectedVMIds.delete(id);
vmsReady.value = selectedVMs.value.length > 0;
if (!vmsReady.value) {
reviewReady.value = false;
}
};
// Clear downstream data when provider changes // Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => { watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
@@ -191,13 +210,13 @@ watch(() => stepData.provider.providerName, (newVal, oldVal) => {
} }
}); });
// Clear mappings and review when VM selection changes // Clear mappings and review when VMs are added to the selection. Pure removals
// (e.g. "Remove from Plan" on the review step) keep the existing mappings and plan name.
watch(selectedVMs, (newVal, oldVal) => { watch(selectedVMs, (newVal, oldVal) => {
const newIds = new Set(newVal.map((v) => v.id));
const oldIds = new Set(oldVal.map((v) => v.id)); const oldIds = new Set(oldVal.map((v) => v.id));
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id)); const added = newVal.some((v) => !oldIds.has(v.id));
if (oldVal.length > 0 && changed) { if (oldVal.length > 0 && added) {
stepData.mappings.networkEntries = []; stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = []; stepData.mappings.storageEntries = [];
mappingsReady.value = false; mappingsReady.value = false;
@@ -290,6 +309,9 @@ const onCancel = () => {
:mapping-entries="stepData.mappings" :mapping-entries="stepData.mappings"
:step-data="stepData.review" :step-data="stepData.review"
@ready="onReviewReady" @ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/> />
</template> </template>
</CruResource> </CruResource>
@@ -68,40 +68,28 @@ export default defineComponent({
return (this.value || []).map((plan) => { return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-'; const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || ''; const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
const migrationsMap = new Map(); const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => ({
name: resource?.metadata?.name,
namespace: resource?.metadata?.namespace || namespace,
}));
history const matchedNetworkMap = existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => this.ownedByPlan(resource, plan));
.map((entry) => ({ const matchedStorageMap = existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => this.ownedByPlan(resource, plan));
name: entry?.migration?.name,
namespace: entry?.migration?.namespace || namespace,
}))
.filter((entry) => !!entry.name)
.forEach((entry) => {
const matched = existing[HCI.FORKLIFT_MIGRATION].find((resource) => resource?.metadata?.name === entry.name && resource?.metadata?.namespace === entry.namespace);
if (matched) {
migrationsMap.set(`${ entry.namespace }/${ entry.name }`, entry);
}
});
const matchedNetworkMap = networkMap?.name && existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMap.name && resource?.metadata?.namespace === (networkMap.namespace || namespace));
const matchedStorageMap = storageMap?.name && existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMap.name && resource?.metadata?.namespace === (storageMap.namespace || namespace));
return { return {
planName, planName,
namespace, namespace,
migrations: [...migrationsMap.values()], migrations,
networkMap: matchedNetworkMap ? { networkMap: matchedNetworkMap ? {
namespace: networkMap.namespace || namespace, namespace: matchedNetworkMap?.metadata?.namespace || namespace,
name: networkMap.name, name: matchedNetworkMap?.metadata?.name,
} : null, } : null,
storageMap: matchedStorageMap ? { storageMap: matchedStorageMap ? {
namespace: storageMap.namespace || namespace, namespace: matchedStorageMap?.metadata?.namespace || namespace,
name: storageMap.name, name: matchedStorageMap?.metadata?.name,
} : null, } : null,
}; };
}); });
@@ -125,53 +113,57 @@ export default defineComponent({
methods: { methods: {
resourceNames, resourceNames,
/**
* Determine whether a related resource (migration / network map / storage map)
* is owned by the given plan by inspecting its `metadata.ownerReferences`.
* Matches on `kind: Plan` and the plan name; when both sides expose a uid it
* must match too, to disambiguate same-named plans.
*/
ownedByPlan(resource, plan) {
const planName = plan?.metadata?.name;
const planUid = plan?.metadata?.uid;
const owners = resource?.metadata?.ownerReferences || [];
if (!planName) {
return false;
}
return owners.some((owner) => owner?.kind === 'Plan' &&
owner?.name === planName &&
(!planUid || !owner?.uid || owner.uid === planUid));
},
buildDeleteTargets() { buildDeleteTargets() {
const existing = this.existingRelatedResources;
const targets = new Map(); const targets = new Map();
const addTarget = (type, resource) => {
const name = resource?.metadata?.name;
const ns = resource?.metadata?.namespace;
if (!name) {
return;
}
const key = `${ type }|${ ns }|${ name }`;
targets.set(key, {
type, name, namespace: ns
});
};
for (const plan of this.value || []) { for (const plan of this.value || []) {
const namespace = plan?.metadata?.namespace || ''; existing[HCI.FORKLIFT_NETWORK_MAP]
const networkMap = plan?.spec?.map?.network; .filter((resource) => this.ownedByPlan(resource, plan))
const storageMap = plan?.spec?.map?.storage; .forEach((resource) => addTarget(HCI.FORKLIFT_NETWORK_MAP, resource));
const history = plan?.status?.migration?.history || [];
if (networkMap?.name) { existing[HCI.FORKLIFT_STORAGE_MAP]
const ns = networkMap.namespace || namespace; .filter((resource) => this.ownedByPlan(resource, plan))
const key = `${ HCI.FORKLIFT_NETWORK_MAP }|${ ns }|${ networkMap.name }`; .forEach((resource) => addTarget(HCI.FORKLIFT_STORAGE_MAP, resource));
targets.set(key, { existing[HCI.FORKLIFT_MIGRATION]
type: HCI.FORKLIFT_NETWORK_MAP, .filter((resource) => this.ownedByPlan(resource, plan))
name: networkMap.name, .forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource));
namespace: ns,
});
}
if (storageMap?.name) {
const ns = storageMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_STORAGE_MAP }|${ ns }|${ storageMap.name }`;
targets.set(key, {
type: HCI.FORKLIFT_STORAGE_MAP,
name: storageMap.name,
namespace: ns,
});
}
for (const entry of history) {
const migrationName = entry?.migration?.name;
if (!migrationName) {
continue;
}
const migrationNs = entry?.migration?.namespace || namespace;
const key = `${ HCI.FORKLIFT_MIGRATION }|${ migrationNs }|${ migrationName }`;
targets.set(key, {
type: HCI.FORKLIFT_MIGRATION,
name: migrationName,
namespace: migrationNs,
});
}
} }
return [...targets.values()]; return [...targets.values()];
-13
View File
@@ -69,17 +69,11 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType); validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType);
}); });
let hasBootableVolume = false;
_volumes.forEach((V, idx) => { _volumes.forEach((V, idx) => {
const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value); const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value);
const prefix = V.name || idx + 1; const prefix = V.name || idx + 1;
if ([SOURCE_TYPE.IMAGE, SOURCE_TYPE.ATTACH_VOLUME, SOURCE_TYPE.CONTAINER].includes(type)) {
hasBootableVolume = true;
}
if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) { if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) {
if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) { if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) {
const key = getters['i18n/t']('harvester.fields.size'); const key = getters['i18n/t']('harvester.fields.size');
@@ -136,13 +130,6 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
} }
}); });
/**
* At least one bootable volume must be provided. (Verify only when create.)
*/
if (!hasBootableVolume && !value.links) {
errors.push(getters['i18n/t']('harvester.validation.vm.volume.needAtLeastOneBootable'));
}
return errors; return errors;
} }
+5 -1
View File
@@ -7,7 +7,11 @@ if (process.argv.length < 3) {
} }
const prTitle = process.argv[2]; const prTitle = process.argv[2];
const prLabel = prTitle.split(':')[0].trim();
// Parse the conventional-commit prefix `type(scope)!:`, keeping only the type
// and dropping an optional scope and breaking-change marker.
const prefixMatch = prTitle.match(/^\s*([a-zA-Z]+)(?:\([^)]*\))?!?:/);
const prLabel = prefixMatch ? prefixMatch[1].toLowerCase() : '';
const config = await load({ extends: ["./commitlint.config.js"] }); const config = await load({ extends: ["./commitlint.config.js"] });
const commitPrefix = config?.rules?.['type-enum']?.[2] || []; const commitPrefix = config?.rules?.['type-enum']?.[2] || [];
+4 -4
View File
@@ -2846,10 +2846,10 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/node@*", "@types/node@25.3.3", "@types/node@25.9.4": "@types/node@*", "@types/node@25.3.3", "@types/node@25.9.5":
version "25.9.4" version "25.9.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.4.tgz#18b63c47f88c1fbbed9d55ea2b66ffd494a47001" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.5.tgz#0fefc09e6e82e94cde291bacf43522e989eb01a4"
integrity sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g== integrity sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==
dependencies: dependencies:
undici-types ">=7.24.0 <7.24.7" undici-types ">=7.24.0 <7.24.7"