Compare commits

..
Author SHA1 Message Date
mergify[bot]andAndy Lee 07f2b02a9d feat: add migration plan duration time column (#1005) (#1006)
* feat: add migration plan duration time column



* feat: allow duration column to be sorted



---------


(cherry picked from commit 44297ada16)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-15 19:20:06 +08:00
Harvester Bot 35b6949cdc chore: bump version to 1.9.0-rc2 (#1002)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-15 17:46:50 +08:00
mergify[bot]andAndy Lee c577f589e7 fix: allow input vlan 1 in host network (#999) (#1001)
(cherry picked from commit 9640c87947)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-15 15:25:42 +08:00
mergify[bot]andVolker Theile b1387820ed fix: A disk entry must be either disk or cdrom, never both (backport #998) (#1000)
Related to: https://github.com/harvester/harvester/issues/11154


(cherry picked from commit 2cefd7cb78)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-15 09:15:09 +02:00
mergify[bot]andAndy Lee 45f72749f5 refactor: add customized migration plan delete model (#996) (#997)
* refactor: add customized migration plan delete model



* refactor: copilot review



* refactor: copilot review



---------


(cherry picked from commit fb6ffa3c0f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 17:40:44 +08:00
mergify[bot]andAndy Lee a65b3f443e feat: add migration steps tooltip when mouse hover on progress bar (#994) (#995)
* feat: add progress bar tooltip



* refactor: copilot review



---------


(cherry picked from commit e0436bff28)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 14:39:59 +08:00
mergify[bot]andAndy Lee 21d415b5f3 refactor: rename pod network to management network (#992) (#993)
(cherry picked from commit 219f39ae52)

Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 14:27:26 +08:00
mergify[bot]andAndy Lee 7493fe2dfc refactor: forklift UI bug and enhancement (#989) (#991)
* fix: hide Virtul machine migration tabs if forklift addon is not enabled



* fix: rename advanced to configurations



* fix: fixed column width



* refactor: review comment



* revert: power on background color



---------


(cherry picked from commit 41cf44d231)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 12:07:06 +08:00
mergify[bot]andJack Yu e71d12f67f feat: prevent users from selecting mounted devices (#987) (#990)
(cherry picked from commit 7d09cb7142)

Signed-off-by: Jack Yu <jack.yu@suse.com>
Co-authored-by: Jack Yu <jack.yu@suse.com>
2026-07-14 11:58:48 +08:00
mergify[bot]andmarcelofukumoto d3b77e2e41 feat: forklift UI (#891) (#988)
* feat: forklift poc first commit



* feat: add migration and plan detail pages with progress tracking



* feat(forklift): UI based on the FIGMA



* feat(forklift): Fixed config based on new figma changes



* feat(forklift): New changes added based on new figma



* feat(forklift): Add the succeded state



* Merge the new APIs



* feat(forklift): Applied fix to select the proper data on network. Added condition to show critical



* feat(forklift): Added icons, critical state, fix grids, action button



* feat(forklift): Change to use the proper provider instead of a hardcoded



* feat(forklift): Added pagination UI



* feat(forklift): FIxed the routing for the extension



* feat(forklift): route and router doesnt work properly on extensions



* feat(forklift): fixed to fix the icon on extension



* feat(forklift): Added overflow hidden to mapping target list



* feat(forklift): Added better handling on the saving and testing



* feat(forklift): Remove test flags



* feat(forklift): Changed based on review



* feat(forklift): fixed title and button



* feat(forklift): Design changes



* feat(forklift): Changed to Wizard



* feat(forklift): added the provider wizard



* feat(forklift): Fixed some UI changes



* feat(forklift): Removed wrong configs used for testing



* feat(forklift): update size



* feat(forklift): small fixes



* feat(forklift): small fixes after Copilot



* feat(forklift): small fixes after Copilot



* feat(forklift): reverted settings.json changes



* feat(forklift): small fixes after Copilot



* feat(forklift): Fix the way it is registered



* feat(forklift): Fixed the type for VMware



* feat(forklift): small changes before rebase



* feat: Added new API for the vms



* feat: Fix some labels



* feat: Added some final fixes to structure



* feat: added loading to provider steps



* feat: added comments from copilot review



---------


(cherry picked from commit 560643d299)

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
Co-authored-by: marcelofukumoto <marceloyfukumoto@gmail.com>
2026-07-13 17:46:33 +08:00
mergify[bot]andRenuka Devi Rajendran 73126b953c feat: Display mgmt interface details under mgmt cluster network (#974) (#986)
(cherry picked from commit c3bb0ea4be)

Signed-off-by: Renuka Devi Rajendran <renuka.rajendran@suse.com>
Co-authored-by: Renuka Devi Rajendran <renuka.rajendran@suse.com>
2026-07-10 12:01:34 +08:00
mergify[bot]andAndy Lee 937afd9c9a chore: limit the rancher to 2.15.0+ for harvester v1.9.0 (#982) (#985)
(cherry picked from commit 3c37f04ef6)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-10 10:59:31 +08:00
mergify[bot]andVolker Theile 3658caf067 feat: Allow user to customize snapshot/backup deadline (#866) (#980)
* feat: Allow user to customize snapshot/backup deadline

Related to: https://github.com/harvester/harvester/issues/4098
Related to: https://github.com/harvester/harvester/pull/10545



* feat: Allow user to customize snapshot/backup deadline

Related to: https://github.com/harvester/harvester/issues/4098
Related to: https://github.com/harvester/harvester/pull/10545



---------


(cherry picked from commit d98db6ba49)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-09 18:34:52 +08:00
mergify[bot]andAndy Lee a067751cba feat: allow user specify static IP when VM creation (#973) (#979)
* feat: allow suer give static IP for VM



* feat: add feature flag



---------


(cherry picked from commit 531ed03796)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-09 13:20:22 +08:00
Harvester Bot 94566b2b54 chore: bump version to 1.9.0-rc1 (#978)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-08 17:47:16 +08:00
mergify[bot]andAndy Lee d47bf1d712 fix: vpc topology should consume NAD labels (#975) (#976)
(cherry picked from commit 59f260eab8)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-08 13:50:17 +08:00
41 changed files with 485 additions and 1177 deletions
+5 -10
View File
@@ -1,19 +1,14 @@
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
### PR Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
### PR Checklists
- Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
- [ ] Yes, the backend owner is:
### Related Issue #
<!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
<!-- Define findings related to the feature or bug issue. -->
### Test screenshot or video (Required)
### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
@@ -13,23 +13,13 @@ on:
- '*-dev'
types:
- merged
- labeled
jobs:
build-validation:
name: Build Test
# allow triggering when a PR is labeled with "build-artifact"
if: >-
github.event_name != 'pull_request' ||
github.event.action != 'labeled' ||
github.event.label.name == 'build-artifact'
uses: ./.github/workflows/run-lint.yaml
build:
name: Build and Upload Package
if: >-
github.event_name != 'pull_request' ||
github.event.action != 'labeled' ||
github.event.label.name == 'build-artifact'
uses: ./.github/workflows/build-and-publish-standalone.yaml
needs:
- build-validation
@@ -38,4 +28,4 @@ jobs:
packages: write
id-token: write
with:
CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
CI_BRANCH: ${{github.ref_name}}
+20 -20
View File
@@ -1,4 +1,4 @@
> 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.
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
# Project Overview
@@ -28,7 +28,9 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
- Run `yarn lint:fix` before commits.
- Use conventional commit format:
```
<type>: <description>
<type>:
<description>
```
- 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.
@@ -128,8 +130,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`:
- 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.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- The `<style>` tag should include `lang="scss" scoped`.
- Place source tag above template above style.
- style tag should contain `lang='scss' scoped`.
- `Linting`: Follow the ESLint configuration in the root.
- **File Structure:**
@@ -161,26 +163,24 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide
### 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/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
1. 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.
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps:
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:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Check: Use the following getter to verify if the feature is enabled for the current version:
3. **Render**: Use the result of the check to conditionally render the UI components.
```
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
## E2E Tests (Cypress)
@@ -228,7 +228,7 @@ For the Renovate config, see `.github/renovate.json`.
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>: <description>
<type>[optional scope]: <description>
[optional body]
-1
View File
@@ -1 +0,0 @@
./AGENTS.md
-1
View File
@@ -1 +0,0 @@
./AGENTS.md
+1 -1
View File
@@ -25,7 +25,7 @@ For Harvester UI extension installation instructions, please refer to the page *
## Development Setup
Ensure **Node.js v24 or later** is installed for development and debugging.
Ensure **Node.js v20 or later** is installed for development and debugging.
### Standalone Mode
-1
View File
@@ -32,7 +32,6 @@ module.exports = {
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'],
'header-max-length': [0, 'always', 100],
'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'],
+3 -1
View File
@@ -6,7 +6,9 @@
- Run `yarn lint:fix` before commits.
- Use conventional commit format:
```
<type>: <description>
<type>:
<description>
```
- 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.
+15 -17
View File
@@ -16,8 +16,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`:
- 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.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- The `<style>` tag should include `lang="scss" scoped`.
- Place source tag above template above style.
- style tag should contain `lang='scss' scoped`.
- `Linting`: Follow the ESLint configuration in the root.
- **File Structure:**
@@ -49,24 +49,22 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide
### 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/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
1. 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.
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps:
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:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Check: Use the following getter to verify if the feature is enabled for the current version:
3. **Render**: Use the result of the check to conditionally render the UI components.
```
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
@@ -26,7 +26,7 @@
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>: <description>
<type>[optional scope]: <description>
[optional body]
+1 -1
View File
@@ -1,4 +1,4 @@
> 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.
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
# Project Overview
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "harvester-ui-extension",
"version": "1.10.0-dev",
"version": "1.9.0-rc2",
"private": false,
"engines": {
"node": ">=24.0.0"
@@ -21,7 +21,7 @@
"yaml": "^2.5.1"
},
"resolutions": {
"@types/node": "25.9.5",
"@types/node": "25.9.4",
"cronstrue": "2.59.0",
"d3-color": "3.1.0",
"ejs": "3.1.10",
+8 -33
View File
@@ -55,12 +55,8 @@ export default {
!this.latestUpgrade.isUpgradeFailed;
},
canUpgrade() {
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
},
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);
},
@@ -158,23 +154,14 @@ export default {
:cluster="currentCluster.nameDisplay"
/>
</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
v-if="versionOptions.length && !isUpgradeInProgress"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<button
:disabled="!canUpgrade"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<t k="harvester.upgradePage.upgrade" />
</button>
</span>
<t k="harvester.upgradePage.upgrade" />
</button>
</header>
<ModalWithCard
@@ -309,18 +296,6 @@ export default {
line-height: 23px;
}
.upgrade-btn-wrap {
display: inline-flex;
button {
color: #000 !important;
}
button:hover {
color: #000 !important;
}
}
.currentVersion {
margin-top: 10px;
display: flex;
@@ -42,7 +42,6 @@ const {
username,
password,
skipTlsVerify,
vddkInitImage,
testPassed,
testResult,
testError,
@@ -98,7 +97,6 @@ watch(selectedProvider, (val) => {
username.value = '';
password.value = '';
skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null;
createdSecret.value = null;
} else {
@@ -109,7 +107,6 @@ watch(selectedProvider, (val) => {
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret;
@@ -242,18 +239,6 @@ const testConnection = async(buttonCb) => {
if (props.editMode && createdProvider.value) {
try {
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();
const secretRef = createdProvider.value.spec?.secret;
@@ -298,28 +283,20 @@ const testConnection = async(buttonCb) => {
const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const spec = {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
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,
spec: {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
}
});
await provider.save();
@@ -443,28 +420,17 @@ defineExpose({ testConnection, clickTestButton });
/>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</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>
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="row">
@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
@@ -17,10 +18,8 @@ const props = defineProps({
clearable: { type: Boolean, default: false },
});
// Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
const selectOptions = computed(() => {
if (!props.clearable) {
return props.options;
}
@@ -35,9 +34,14 @@ const optionsFor = (entry) => {
disabled: true,
kind: 'divider',
},
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options,
];
};
});
</script>
<template>
@@ -81,7 +85,7 @@ const optionsFor = (entry) => {
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="optionsFor(entry)"
:options="selectOptions"
:placeholder="placeholder+'...'"
:searchable="true"
/>
@@ -2,14 +2,11 @@
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import {
@@ -24,70 +21,29 @@ const props = defineProps({
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = computed(() => props.selectedVms);
const vms = ref([]);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE;
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 '';
});
const NAMESPACE = FORKLIFT_NAMESPACE;
const TARGET_NAMESPACE = 'default';
watch(planName, (val) => {
props.stepData.planName = val;
emit('ready', !!val);
}, { 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 totalMemoryGB = computed(() => {
@@ -108,7 +64,7 @@ const totalStorageGB = computed(() => {
return bytesToGB(totalBytes);
});
const vmRows = computed(() => {
const vmCards = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0;
@@ -122,93 +78,23 @@ const vmRows = computed(() => {
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
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 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 {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id,
name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os,
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass,
cpus,
memGB,
diskDisplay,
networkMappings: vmNetworkMappings,
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 inStore = store.getters['currentProduct'].inStore;
@@ -217,13 +103,13 @@ const startMigrationAction = async() => {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: props.providerName,
namespace: NS,
namespace: NAMESPACE,
},
destination: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: 'host',
namespace: NS,
namespace: NAMESPACE,
},
};
@@ -232,15 +118,15 @@ const startMigrationAction = async() => {
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NS },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
metadata: { name: networkMapName, namespace: NAMESPACE },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NS },
metadata: { name: storageMapName, namespace: NAMESPACE },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
});
@@ -248,7 +134,7 @@ const startMigrationAction = async() => {
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NS },
metadata: { name: planName.value, namespace: NAMESPACE },
spec: {
provider: providerRef,
map: {
@@ -256,16 +142,16 @@ const startMigrationAction = async() => {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: networkMapName,
namespace: NS,
namespace: NAMESPACE,
},
storage: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: storageMapName,
namespace: NS,
namespace: NAMESPACE,
},
},
targetNamespace: targetNamespace.value,
targetNamespace: TARGET_NAMESPACE,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
@@ -291,32 +177,8 @@ const startMigrationAction = async() => {
await plan.startMigration();
};
const init = async() => {
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);
const init = () => {
vms.value = props.selectedVms;
if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
@@ -349,178 +211,116 @@ defineExpose({ startMigration: startMigrationAction });
<div
class="review-migration-content"
>
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary -->
<div class="migration-details">
<div class="section-header">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<div class="span-9">
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
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 class="summary-row">
<div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</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 class="details-grid span-9">
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value detail-value-large">{{ vms.length }}</span>
</div>
</div>
<div class="stats-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-value">{{ providerName }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value">{{ vms.length }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</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-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ providerName }}</span>
</div>
</div>
<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-value">{{ totalVCpu }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</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-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</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>
<!-- Virtual Machines -->
<div class="vm-section">
<SortableTable
class="vm-sortable-table"
:rows="vmRows"
:headers="vmHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
>
<template #header-left>
<h4 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h4>
</template>
<template #header-right>
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
</a>
</template>
<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>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resources }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
: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"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h3>
<div class="vm-cards-grid">
<RcItemCard
v-for="vm in vmCards"
:id="vm.id"
:key="vm.id"
:variant="'small'"
:header="{ title: { text: vm.name } }"
>
<template #item-card-content>
<div class="vm-card-content">
<div class="vm-card-specs">
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
<span class="vm-resources">
<i
class="icon icon-disk"
aria-hidden="true"
/>
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
</span>
</div>
<MappingsCell
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
/>
</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>
</RcItemCard>
</div>
</div>
<!-- Cold Migration Warning -->
<Banner
color="warning"
class="m-0"
>
<div>
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br>
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
</div>
</Banner>
<!-- Error banner -->
<Banner
v-for="(err, i) in errors"
@@ -547,122 +347,74 @@ defineExpose({ startMigration: startMigrationAction });
font-weight: 600;
}
.name-row {
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
align-items: start;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px clamp(64px, 8vw, 128px);
line-height: 20px;
margin-top: 12px;
}
.section-header {
.detail-column {
display: flex;
flex-direction: column;
gap: 4px;
.section-description {
font-size: 14px;
&.grid-column-2 {
grid-column: span 2;
}
}
.migration-mode-column {
.detail-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-size: 14px;
flex-direction: row;
justify-content: space-between;
.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;
.detail-label {
font-size: 14px;
color: var(--muted);
}
.theme-light & {
background: rgba(168, 128, 26, 0.16);
.detail-value {
font-weight: 600;
font-size: 14px;
&.detail-value-large {
font-size: 24px;
}
}
}
.stats-column {
.vm-cards-grid {
display: grid;
grid-template-columns: auto auto;
justify-content: start;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
}
.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-name-cell {
.vm-card-content {
display: flex;
flex-direction: column;
.vm-name {
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
gap: 16px;
line-height: 20px;
}
.mapping-cell {
.mapping-target {
margin-left: 4px;
white-space: nowrap;
}
}
.vm-card-specs {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.mapping-cell + .mapping-cell {
margin-top: 8px;
.vm-os {
font-size: 14px;
}
.vm-resources {
font-size: 14px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.banner-title {
@@ -86,5 +86,4 @@ export const HCI = {
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
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',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'center',
align: 'left',
};
// Plan reference column in forklift.konveyor.io.migration list page
-7
View File
@@ -15,13 +15,6 @@ export const VOLUME_MODE = {
FILE_SYSTEM: 'Filesystem'
};
export const ACCESS_MODE = {
READ_WRITE_MANY: 'ReadWriteMany',
READ_WRITE_ONCE: 'ReadWriteOnce',
READ_ONLY_MANY: 'ReadOnlyMany',
READ_WRITE_ONCE_POD: 'ReadWriteOncePod',
};
export const NETWORK_PROTOCOL = {
IPv4: 'IPv4',
IPv6: 'IPv6',
@@ -1,133 +0,0 @@
<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>
@@ -1,5 +1,5 @@
<script>
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox';
@@ -10,10 +10,6 @@ import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params';
const {
READ_WRITE_ONCE, READ_ONLY_MANY, READ_WRITE_MANY, READ_WRITE_ONCE_POD
} = ACCESS_MODE;
export default {
name: 'CDISettings',
@@ -94,10 +90,10 @@ export default {
accessModeOptions() {
return [
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY },
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY },
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
];
},
@@ -26,11 +26,9 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { isInternalStorageClass } from '../utils/storage-class';
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
export default {
name: 'HarvesterVolume',
@@ -85,7 +83,7 @@ export default {
if (this.mode === _CREATE) {
// default volumeMode to Block
this.value.spec.volumeMode = VOLUME_MODE.BLOCK;
this.value.spec.accessModes = [READ_WRITE_MANY];
this.value.spec.accessModes = ['ReadWriteMany'];
}
const storage = this.value?.spec?.resources?.requests?.storage || null;
@@ -162,7 +160,7 @@ export default {
},
accessModeOptions() {
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
},
imageOption() {
@@ -357,7 +355,7 @@ export default {
getAccessMode() {
if (!this.longhornV2LVMSupport) {
return [READ_WRITE_MANY];
return ['ReadWriteMany'];
}
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@@ -373,7 +371,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
},
buildDataVolumeObj() {
const storage = {
@@ -16,12 +16,10 @@ import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm';
const { READ_WRITE_MANY } = ACCESS_MODE;
export default {
emits: ['update:value'],
@@ -71,7 +69,7 @@ export default {
customAccessMode: {
type: String,
default: READ_WRITE_MANY
default: 'ReadWriteMany'
},
resourceType: {
@@ -14,9 +14,7 @@ import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storagec
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../../../../utils/unit';
import { isInternalStorageClass } from '../../../../utils/storage-class';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
import { VOLUME_MODE } from '@pkg/harvester/config/types';
export default {
name: 'HarvesterEditVolume',
@@ -207,7 +205,7 @@ export default {
getAccessMode(storageClassName) {
if (!this.longhornV2LVMSupport) {
return READ_WRITE_MANY;
return 'ReadWriteMany';
}
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
@@ -218,7 +216,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
return readWriteOnce ? 'ReadWriteOnce' : 'ReadWriteMany';
},
update() {
@@ -93,9 +93,6 @@ export default {
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]);
return {
@@ -487,20 +484,6 @@ export default {
await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS);
} 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));
buttonCb(false);
}
+33 -68
View File
@@ -1,11 +1,11 @@
<script>
import PercentageBar from '@shell/components/PercentageBar';
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { METRIC, NODE } from '@shell/config/types';
import { parseSi } from '@shell/utils/units';
export default {
name: 'HarvesterCpuUsed',
components: { PercentageBar },
components: { ConsumptionGauge },
props: {
value: {
@@ -74,89 +74,54 @@ export default {
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>
<template>
<div>
<div class="consumption-gauge">
<div class="numbers">
<span class="mr-5">
<ConsumptionGauge
:capacity="cpuTotal"
:used="reserved"
:units="cpuUnits"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
</template>
</ConsumptionGauge>
<div
v-if="showUsed"
class="consumption-gauge mt-10"
class="mt-10"
>
<div class="numbers">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
<ConsumptionGauge
:capacity="cpuTotal"
:used="used"
:units="cpuUnits"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div>
</div>
</template>
<style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data {
white-space: nowrap;
}
@@ -1,11 +1,11 @@
<script>
import PercentageBar from '@shell/components/PercentageBar';
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { METRIC, NODE } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit';
export default {
name: 'HarvesterMemoryUsed',
components: { PercentageBar },
components: { ConsumptionGauge },
props: {
value: {
@@ -76,30 +76,6 @@ export default {
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: {
@@ -120,53 +96,50 @@ export default {
<template>
<div>
<div class="consumption-gauge">
<div class="numbers">
<span class="mr-10">
<ConsumptionGauge
:capacity="memoryTotal"
:used="reserved"
:units="memoryUnits"
:number-formatter="memoryFormatter"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
</template>
</ConsumptionGauge>
<div
v-if="showUsed"
class="consumption-gauge mt-10"
class="mt-10"
>
<div class="numbers">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
<ConsumptionGauge
:capacity="memoryTotal"
:used="used"
:units="memoryUnits"
:number-formatter="memoryFormatter"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div>
</div>
</template>
<style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data {
white-space: nowrap;
}
@@ -1,12 +1,12 @@
<script>
import PercentageBar from '@shell/components/PercentageBar';
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { LONGHORN } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit';
export default {
name: 'HarvesterStorageUsed',
components: { PercentageBar },
components: { ConsumptionGauge },
props: {
value: {
@@ -120,14 +120,6 @@ export default {
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: {
@@ -152,54 +144,53 @@ export default {
<div>
<div
v-if="showAllocated"
class="consumption-gauge"
>
<div class="numbers">
<span class="mr-10">
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="allocatedPercentage"
show-percentage
/>
</div>
<ConsumptionGauge
:capacity="storageStats.total"
:used="storageStats.scheduled"
:units="allocatedUnits"
:number-formatter="formatter"
:resource-name="resourceName"
>
<template #title="{formattedPercentage}">
<span>
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div>
<div
class="consumption-gauge"
:class="{ 'mt-10': showAllocated }"
<ConsumptionGauge
:capacity="storageStats.maximum"
:used="storageStats.used"
:units="usedUnits"
:number-formatter="formatter"
:resource-name="showAllocated ? '' : resourceName"
:class="{
'mt-10': showAllocated,
}"
>
<div class="numbers">
<template #title="{formattedPercentage}">
<span>
{{ t('node.detail.glance.consumptionGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div>
</template>
</ConsumptionGauge>
</div>
</template>
<style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data {
white-space: nowrap;
}
+10 -45
View File
@@ -331,8 +331,7 @@ harvester:
message: Message
phase: Phase
attachedVM: Attached Virtual Machine
cpuManager: CPU<br>Manager
diskState: Disk
cpuManager: CPU Manager
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
fingerprint: Fingerprint
value: Value
@@ -488,6 +487,7 @@ harvester:
volume:
upperType: Volume name
lowerType: volume name
needAtLeastOneBootable: 'At least one bootable volume is required!'
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.'
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,8 +1099,6 @@ harvester:
downloadLog: Download Log
logStatus: Log Download Status
dismissMessage: Dismiss it
upgradeAvailable: Upgrade Available
upgradeUnavailable: Upgrade Unavailable
upgradeInfo:
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.
@@ -1838,7 +1836,6 @@ harvester:
unknown: Unknown
identifier: Identifier
podNetwork: Pod Network
ignored: Ignored
usedBy: "Used by:"
vlan: "VLAN {id}"
vCpu: "{count} vCPU"
@@ -1893,14 +1890,11 @@ harvester:
providerSelect: Provider
createNew: Create new provider
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 Harvester Node."
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."
name: Name
urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk"
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
skipSslHint: Not recommended for production environments
testConnection:
@@ -1960,15 +1954,14 @@ harvester:
initializingMigration: Initializing migration
finishedSuccessfully: Finished Successfully
step: "Step {index}"
vmCount: "{count, plural, one {# VM} other {# VMs}}"
vmCount: "{count} VMs"
vmId: "id: {id}"
checkMigratedVm: Click to check migrated VM details
configureMappings:
title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue
noTemplate: Start from scratch
removeMap: Remove mapping
removeMap: Remove Map
networkMapping:
title: Network Mapping
description: Map VMware port groups to Harvester networks
@@ -1976,49 +1969,27 @@ harvester:
template: Use existing network mapping as template
options:
podNetworking: Management Network
ignored: Ignored (No NIC will be created)
ignored: Ignored
storageMapping:
title: Storage Mapping
description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Harvester Storage Class
placeholder: Choose a Longhorn Storage Volume
template: Use existing storage mapping as template
reviewMigration:
title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name
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
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs
vcpu: vCPU
memory: Memory
storage: Storage
source: Source
provider: Provider
targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace"
targetNamespace: Target Namespace
migrationMode: Migration Mode
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
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"
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
@@ -2283,12 +2254,6 @@ harvester:
prefix: The nvidia-driver-toolkit add-on is not enabled, click
middle: here
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:
label: vGPU MIG Configurations
@@ -2448,8 +2413,8 @@ advancedSettings:
typeLabel:
kubevirt.io.virtualmachine: |-
{count, plural,
one { Andy Virtual Machine }
other { Andy Virtual Machines }
one { Virtual Machine }
other { Virtual Machines }
}
harvesterhci.io.virtualmachineimage: |-
{count, plural,
@@ -86,13 +86,6 @@ export default {
value: 'spec.address',
sort: ['spec.address']
},
{
name: 'timesliced',
labelKey: 'harvester.sriovgpu.timesliced',
value: 'isTimesliced',
formatter: 'Checked',
align: 'center',
},
{
name: 'vfAddresses',
label: 'VF Addresses',
+4 -13
View File
@@ -84,7 +84,7 @@ export default {
},
{
...NAME,
width: 150,
width: 130,
},
{
name: 'host-ip',
@@ -136,14 +136,14 @@ export default {
value: 'id',
formatter: 'HarvesterCPUPinning',
formatterOpts: { rows: this.rows },
width: 75,
width: 150,
align: 'center',
});
}
if (this.hasLonghornSchema) {
out.push({
name: 'diskState',
labelKey: 'harvester.tableHeaders.diskState',
labelKey: 'tableHeaders.diskState',
value: 'diskState',
formatter: 'HarvesterDiskState',
width: 130,
@@ -156,6 +156,7 @@ export default {
name: 'console',
label: ' ',
align: 'right',
width: 80,
});
return out;
@@ -238,8 +239,6 @@ export default {
v-if="!row.consoleUrl"
:href="consoleDocLink"
target="_blank"
rel="noopener noreferrer"
class="console-info"
><i class="icon icon-info" /></a>
</div>
</template>
@@ -250,13 +249,5 @@ export default {
<style lang="scss" scoped>
.console-button {
display: flex;
position: relative;
justify-content: flex-end;
}
.console-info {
position: absolute;
top: -8px;
right: -3px;
}
</style>
+8 -28
View File
@@ -25,9 +25,7 @@ import { HCI } from '../../types';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE, ACCESS_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY } = ACCESS_MODE;
import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
@@ -164,12 +162,6 @@ export default {
machineType: '',
machineTypes: [],
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,
showAdvanced: false,
deleteAgent: true,
@@ -278,7 +270,7 @@ export default {
},
customAccessMode() {
return this.storageClassSetting.accessModes || READ_WRITE_MANY;
return this.storageClassSetting.accessModes || 'ReadWriteMany';
},
isWindows() {
@@ -506,7 +498,7 @@ export default {
id: randomStr(5),
source: SOURCE_TYPE.IMAGE,
name: 'disk-0',
accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX
accessMode: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX
bus,
volumeName: '',
size,
@@ -572,7 +564,7 @@ export default {
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
source = SOURCE_TYPE.ATTACH_VOLUME;
accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY;
accessMode = pvcResource?.spec?.accessModes?.[0] || 'ReadWriteMany';
size = pvcResource?.spec?.resources?.requests?.storage || '10Gi';
storageClassName = pvcResource?.spec?.storageClassName;
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
@@ -874,30 +866,18 @@ export default {
}
});
// 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)) {
if (this.needNewSecret || !this.secretName) {
this.secretName = this.generateSecretName(this.secretNamePrefix);
this.secretNamePrefixUsed = this.secretNamePrefix;
}
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
// If we have content but no secret name, it's a new secret that needs a name. Also
// 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) {
// If we have content but no secret name, it's a new secret that needs a name.
if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) {
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
this.sysprepSecretNamePrefixUsed = this.secretNamePrefix;
}
// Preserve/attach sysprep whenever a secret is selected/known.
@@ -916,7 +896,7 @@ export default {
}
}
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
if (!this.isWindows) {
disks.push({
name: 'cloudinitdisk',
@@ -69,15 +69,16 @@ export default class SRIOVDevice extends SteveModel {
return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0;
}
get isTimesliced() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true';
}
enableDevice() {
this.$dispatch('promptModal', {
resources: [this],
component: 'EnableSriovGpuDevice'
});
async enableDevice() {
try {
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 });
}
}
async disableDevice() {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "harvester",
"description": "Rancher UI Extension for Harvester",
"version": "1.10.0-dev",
"version": "1.9.0-rc2",
"private": false,
"rancher": {
"annotations": {
@@ -37,7 +37,6 @@ const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
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 allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
const escapeHtml = (value = '') => String(value)
.replaceAll('&', '&amp;')
@@ -99,12 +98,12 @@ const formatPipelineTooltip = (pipeline = []) => {
};
const formatDuration = (started, completed) => {
if (!started) {
if (!started || !completed) {
return { display: null, seconds: -1 };
}
const startMs = new Date(started).getTime();
const endMs = completed ? new Date(completed).getTime() : Date.now();
const endMs = new Date(completed).getTime();
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
return { display: null, seconds: -1 };
@@ -130,8 +129,8 @@ const rows = computed(() => {
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);
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 || 'Ignored' } → ${ e.destination?.storageClass || 'Ignored' }`);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
@@ -179,35 +178,11 @@ const rows = computed(() => {
overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it.
// 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) {
// If no step-level error but the plan itself is failed, surface it
if (!errorMsg && plan.planFailed) {
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 {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
@@ -216,7 +191,6 @@ const rows = computed(() => {
pipelineTooltip: formatPipelineTooltip(pipeline),
errorMsg,
canceled: plan.planCanceled,
vmDetailLocation,
};
});
@@ -274,7 +248,7 @@ const headers = [
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
value: 'duration',
sort: 'durationSeconds',
width: 90,
width: 120,
},
{ ...AGE },
];
@@ -309,17 +283,6 @@ const init = async() => {
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_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) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally {
@@ -403,17 +366,7 @@ init();
>
<div class="vm-progress-header">
<div class="vm-name-block">
<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="vm-name">{{ vm.vmName }}</span>
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
</div>
</div>
@@ -504,14 +457,6 @@ init();
.vm-name {
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-detail-link {
display: inline-flex;
align-items: center;
}
.vm-name-block {
@@ -46,7 +46,6 @@ const stepData = reactive({
username: '',
password: '',
skipTlsVerify: false,
vddkInitImage: '',
testPassed: false,
testResult: null,
testError: null,
@@ -194,7 +193,6 @@ const init = async() => {
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret;
@@ -41,7 +41,6 @@ const stepData = reactive({
username: '',
password: '',
skipTlsVerify: false,
vddkInitImage: '',
testPassed: false,
testResult: null,
testError: null,
@@ -57,7 +56,7 @@ const stepData = reactive({
networkEntries: [],
storageEntries: [],
},
review: { planName: '', targetNamespace: '' },
review: { planName: '' },
});
const steps = reactive([
@@ -174,24 +173,6 @@ const onReviewReady = (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
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
@@ -210,13 +191,13 @@ watch(() => stepData.provider.providerName, (newVal, oldVal) => {
}
});
// 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.
// Clear mappings and review when VM selection changes
watch(selectedVMs, (newVal, oldVal) => {
const newIds = new Set(newVal.map((v) => v.id));
const oldIds = new Set(oldVal.map((v) => v.id));
const added = newVal.some((v) => !oldIds.has(v.id));
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id));
if (oldVal.length > 0 && added) {
if (oldVal.length > 0 && changed) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
@@ -309,9 +290,6 @@ const onCancel = () => {
:mapping-entries="stepData.mappings"
:step-data="stepData.review"
@ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/>
</template>
</CruResource>
@@ -68,28 +68,40 @@ export default defineComponent({
return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => ({
name: resource?.metadata?.name,
namespace: resource?.metadata?.namespace || namespace,
}));
const migrationsMap = new Map();
const matchedNetworkMap = existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => this.ownedByPlan(resource, plan));
const matchedStorageMap = existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => this.ownedByPlan(resource, plan));
history
.map((entry) => ({
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 {
planName,
namespace,
migrations,
migrations: [...migrationsMap.values()],
networkMap: matchedNetworkMap ? {
namespace: matchedNetworkMap?.metadata?.namespace || namespace,
name: matchedNetworkMap?.metadata?.name,
namespace: networkMap.namespace || namespace,
name: networkMap.name,
} : null,
storageMap: matchedStorageMap ? {
namespace: matchedStorageMap?.metadata?.namespace || namespace,
name: matchedStorageMap?.metadata?.name,
namespace: storageMap.namespace || namespace,
name: storageMap.name,
} : null,
};
});
@@ -113,57 +125,53 @@ export default defineComponent({
methods: {
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() {
const existing = this.existingRelatedResources;
const targets = new Map();
const addTarget = (type, resource) => {
const name = resource?.metadata?.name;
const ns = resource?.metadata?.namespace;
for (const plan of this.value || []) {
const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
if (!name) {
return;
if (networkMap?.name) {
const ns = networkMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_NETWORK_MAP }|${ ns }|${ networkMap.name }`;
targets.set(key, {
type: HCI.FORKLIFT_NETWORK_MAP,
name: networkMap.name,
namespace: ns,
});
}
const key = `${ type }|${ ns }|${ name }`;
if (storageMap?.name) {
const ns = storageMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_STORAGE_MAP }|${ ns }|${ storageMap.name }`;
targets.set(key, {
type, name, namespace: ns
});
};
targets.set(key, {
type: HCI.FORKLIFT_STORAGE_MAP,
name: storageMap.name,
namespace: ns,
});
}
for (const plan of this.value || []) {
existing[HCI.FORKLIFT_NETWORK_MAP]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_NETWORK_MAP, resource));
for (const entry of history) {
const migrationName = entry?.migration?.name;
existing[HCI.FORKLIFT_STORAGE_MAP]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_STORAGE_MAP, resource));
if (!migrationName) {
continue;
}
existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource));
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()];
+13
View File
@@ -69,11 +69,17 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType);
});
let hasBootableVolume = false;
_volumes.forEach((V, idx) => {
const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value);
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 (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) {
const key = getters['i18n/t']('harvester.fields.size');
@@ -130,6 +136,13 @@ 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;
}
+1 -5
View File
@@ -7,11 +7,7 @@ if (process.argv.length < 3) {
}
const prTitle = process.argv[2];
// 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 prLabel = prTitle.split(':')[0].trim();
const config = await load({ extends: ["./commitlint.config.js"] });
const commitPrefix = config?.rules?.['type-enum']?.[2] || [];
+4 -4
View File
@@ -2846,10 +2846,10 @@
dependencies:
"@types/node" "*"
"@types/node@*", "@types/node@25.3.3", "@types/node@25.9.5":
version "25.9.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.5.tgz#0fefc09e6e82e94cde291bacf43522e989eb01a4"
integrity sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==
"@types/node@*", "@types/node@25.3.3", "@types/node@25.9.4":
version "25.9.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.4.tgz#18b63c47f88c1fbbed9d55ea2b66ffd494a47001"
integrity sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==
dependencies:
undici-types ">=7.24.0 <7.24.7"