Compare commits

..
55 Commits
Author SHA1 Message Date
Harvester Bot 2e996d95da chore: bump version to 1.8.2-rc1 (#971)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-06 12:47:23 +08:00
renovate[bot] bdfa44186d deps: update dependency qs to v6.15.3 (#970)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 11:24:53 +08:00
renovate[bot] ab2aa79037 deps: update patch dependencies (#949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 12:20:11 +08:00
Harvester Bot cd86909b73 chore: bump version to 1.8.1 (#952)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-06-29 16:56:15 +08:00
Harvester Bot c73aca870f chore: bump version to 1.8.1-rc2 (#943)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-06-26 06:15:00 +08:00
renovate[bot] 447be1f75d deps: update patch dependencies (#936)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 16:12:04 +08:00
renovate[bot] d958f443bf deps: update patch dependencies (#925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 15:17:46 +08:00
renovate[bot] c43c3915b7 deps: update minor dependencies to v7.29.7 (#917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 16:02:28 +08:00
Harvester Bot 2e3547d0a2 chore: bump version to 1.8.1-rc1 (#913)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-06-05 18:01:10 +08:00
renovate[bot] 1cbb6a6d54 deps: update dependency @types/node to v25.9.1 (#897)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 17:16:07 +08:00
renovate[bot] 3a3f8f60dc deps: update dependency semver to v7.8.1 (#896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 17:15:47 +08:00
renovate[bot] f7d2f1c586 deps: update patch dependencies (#886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 18:56:44 +08:00
renovate[bot] 4d4e3699a8 deps: update dependency @types/node to v25.6.2 (#874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:52:13 +08:00
renovate[bot] 9423cf4c11 deps: update dependency semver to v7.8.0 (#875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:38:50 +08:00
renovate[bot] 73b6193bf7 deps: update dependency eslint-plugin-promise to v7.3.0 (#862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:17:30 +08:00
renovate[bot] 21ff6b0307 deps: update dependency yaml to v2.8.4 (#861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-13 15:58:06 +08:00
mergify[bot]andAndy Lee b887e5ee01 ci: replace pull_request_target with two-step workflows (backport #841) (#854)
* ci: replace pull_request_target with two-step workflows (#841)

* ci: update PR auto assign workflows

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

* ci: update backport label workflow

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

* ci: update backport PR via mergify workflow

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

* ci: update add PR label workflow

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

* refactor: file name

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

* refactor: limit auto-assign-check for target branches

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

---------

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

* ci: remove brackets in PR Management

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-05-04 15:23:15 +08:00
mergify[bot]andAndy Lee bf9b5866a7 docs: add AGENTS.md for AI agent guidance (#837) (#840)
* feat: add AGENNTS.md



* refactor: update based on copilot feedback



* refactor: update AGENTS.md



* refactor: update based on AI suggestion



* refactor: based on comments



* refactor: some files



* refactor: boundaries.md



---------


(cherry picked from commit 67bb6dfbd5)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-29 16:10:19 +08:00
Andy Lee b97017e076 chore: bump to v1.8.1-dev (#836)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-28 15:49:20 +08:00
renovate[bot] 3740c9c424 deps: update minor dependencies (#834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 16:00:49 +08:00
renovate[bot] af8e8b2eeb deps: update patch dependencies (#833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 15:56:55 +08:00
mergify[bot]andAndy Lee 7906033287 chore: bump @rancher/shell to 3.0.12-rc.1 (backport #810) (#821)
* chore: bump @rancher/shell to 3.0.12-rc.1 (#810)

* chore: bump @rancher/shell to 3.0.12-rc.1

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

* ci: update node version to 24

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

* ci: update build catalog yaml

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

* fix: nav items order

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

* refactor: remove unneeded weightType

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

---------

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

* fix: failed build-extension-charts CI (#822)

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-27 13:58:36 +08:00
5584c86789 fix: provisioner not update when deleting disk (#791) (#820)
* fix: provisioner not update when deleting disk



* refactor: update pkg/harvester/edit/harvesterhci.io.host/HarvesterDisk.vue




* refactor: based on feedback



---------



(cherry picked from commit c4d1018388)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 13:51:11 +08:00
mergify[bot]andTim Liou 1dcb528717 chore: bump sha for upstream action pinning (#827) (#828)
(cherry picked from commit b7119d5c4c)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-04-24 16:38:12 +08:00
Harvester Bot 36fc21ccbc chore: bump version to 1.8.0 (#826)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-04-24 15:52:17 +08:00
mergify[bot]andAndy Lee 8803b79524 fix: change auth/V3user to auth/user (#824) (#825)
* fix: change auth/V3user to auth/user



* refactor: extract to utils/auth.js



---------


(cherry picked from commit 6fdd1e3954)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-22 15:42:30 +08:00
Harvester Bot 3f80a71303 chore: bump version to 1.8.0-rc6 (#812)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-04-16 13:43:38 +08:00
Andy Lee 13e4154335 Revert "deps: update patch digest dependencies (#796)" (#809)
This reverts commit 7788161d94.
2026-04-13 16:34:30 +08:00
renovate[bot] a80b87ce4f deps: update fossas/fossa-action action to v1.9.0 (#797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 16:24:18 +08:00
mergify[bot]andAndy Lee 809b9925fa ci: add add_minrelaseday to delay dep update (#798) (#800)
(cherry picked from commit 6dd9b33336)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-07 15:30:12 +08:00
renovate[bot] 7788161d94 deps: update patch digest dependencies (#796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 08:34:47 +00:00
mergify[bot]andTim Liou 90b8a821a1 chore: bump version to 1.8.0-rc5 (#789) (#790)
(cherry picked from commit 1f9e9b336b)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-04-01 09:17:31 +08:00
mergify[bot]andAndy Lee 87ff014bd8 refactor: add banner in PCI Devices page (#785) (#788)
* refactor: add banner in PCI Devices page



* refactor: based on copilot review



---------


(cherry picked from commit c5b4f6cd1e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-01 09:05:00 +08:00
mergify[bot]andAndy Lee 20328b06e6 fix: the rancher-eio/read-vault-secret sha (#786) (#787)
(cherry picked from commit 4ce35ce075)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-31 14:09:22 +08:00
mergify[bot]andTim Liou bbc415a2b4 chore: bump version to 1.8.0-rc4 (#778) (#779)
(cherry picked from commit 9d698b1230)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-03-27 16:42:53 +08:00
c12d4b5fba feat: support advance option and creation from DataVolume (#776) (#777)
* feat: support advance option and creation from DataVolume

    - advance option would let user change the accessMode/volumeMode
    - creation from DataVolume could supports various scenario with
      3rd-party storage



* feat: add data migration action on volume page



* refactor: use show advanced options link instead of checkbox



* feat: add feature flag



* feat: add feature flag for dataMigration action



---------




(cherry picked from commit 566e79eda5)

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-26 17:33:01 +08:00
mergify[bot]andAndy Lee dc91be11f9 chore: bump version to 1.8.0-rc3 (#774) (#775)
(cherry picked from commit 42ddcfc1fe)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-26 12:35:12 +08:00
mergify[bot]andAndy Lee e5b72499b5 fix: use default value got undefined value in payload (#772) (#773)
(cherry picked from commit ad3decf71f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-25 17:12:51 +08:00
mergify[bot]andAndy Lee e66037b007 ci: remove the single quota for use commit hash (#767) (#771)
(cherry picked from commit 8083a41df0)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-25 14:13:08 +08:00
mergify[bot]andfreeze c676cad057 chore: pin GH Actions to commit sha (#765) (#766)
(cherry picked from commit 62801b3b13)

Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-03-25 14:08:49 +08:00
mergify[bot]andAndy Lee 254abd4648 ci: add CODEOWNERS file (#768) (#769)
(cherry picked from commit 46b860260a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-25 13:42:42 +08:00
renovate[bot] 1df1d3534a deps: update dependency yaml to v2.8.3 (#757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-22 13:06:18 +00:00
mergify[bot]andAndy Lee ff1e6e6e7f docs: update install instruction doc link (#753) (#754)
(cherry picked from commit 97e93dba0b)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-20 16:53:54 +08:00
mergify[bot]andAndy Lee 622242e4a5 refactor: change rwxNetwork setting to kind json (#751) (#752)
(cherry picked from commit 9a8a709e56)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-20 14:48:49 +08:00
mergify[bot]andAndy Lee d8ea9be174 feat: introduce rwxNetwork setting (#746) (#750)
* feat: add rwxNetwork setting



* fix: network payload



---------


(cherry picked from commit d1949641a7)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-19 16:19:44 +08:00
mergify[bot]andfreeze f8a479cfcf feat: add storage migration operation (#724) (#749)
- add storage migration
    - add cancel storage migration


(cherry picked from commit 9c9f59c939)

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-03-19 11:53:24 +08:00
mergify[bot]andAndy Lee 6d5e584113 chore: bump to v1.8.0-rc2 (#747) (#748)
(cherry picked from commit ccc14c7fb9)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-19 11:31:07 +08:00
mergify[bot]andAndy Lee a9eb04195e feat: introduce instance-manager-resources setting (#744) (#745)
(cherry picked from commit 2ba471907e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-18 17:21:28 +08:00
mergify[bot]andAndy Lee 0441c1f6e3 refactor: remove using resourceName to determine is vGPU device (#741) (#743)
(cherry picked from commit 5aea476f64)

Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-18 17:16:28 +08:00
mergify[bot]andAndy Lee dde58269d5 fix: typo (#740) (#742)
(cherry picked from commit 519c7d9f1f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-17 16:33:37 +08:00
mergify[bot]andTim Liou b8111f0ad7 fix: reword the error message to focus on bootable volume (#736) (#738)
(cherry picked from commit a9c392c13f)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-03-12 17:46:08 +08:00
mergify[bot]andTim Liou 6d627f82e9 fix: container disks don't need volumeClaimTemplates but volumes (#735) (#737)
(cherry picked from commit 888ec7a50f)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-03-12 17:45:40 +08:00
mergify[bot]andAndy Lee cfc7a76fe7 feat: add vGPU filter button and hide the enable/disable passthrough in PCIDevice page (#729) (#732)
* feat: add another filter button



* feat: add feature falg to hide vGPU enable/disable actions in PCIDevices page



* refactor: update with conditionally rendering



---------


(cherry picked from commit 23344e0c07)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-11 15:39:41 +08:00
mergify[bot]andPo Han Huang 71d3067354 feat: ensure the state is pending when perform cloning the efi (#730) (#734)
* feat: ensure the state is pending when perform cloning the efi



* feat: define harvesterhci.io/clone-backend-storage-status in labels-annotations.js



---------


(cherry picked from commit a2486a7d38)

Signed-off-by: pohanhuang <pohan.huang@suse.com>
Co-authored-by: Po Han Huang <hhcs9527@gmail.com>
2026-03-11 15:39:01 +08:00
mergify[bot]andAndy Lee 9ecc372009 chore: bump to v1.8.0-rc1 (#731) (#733)
(cherry picked from commit df3d249923)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-11 15:34:55 +08:00
111 changed files with 2639 additions and 10714 deletions
+4 -12
View File
@@ -14,17 +14,10 @@
"semanticCommits": "enabled",
"semanticCommitType": "deps",
"prHourlyLimit": 12,
"digest": {
"enabled": false
},
"timezone": "Asia/Taipei",
"schedule": ["after 10am on sunday"],
"postUpdateOptions": ["yarnDedupeFewer"],
"packageRules": [
{
"matchUpdateTypes": ["digest"],
"enabled": false
},
{
"matchUpdateTypes": ["major"],
"enabled": false
@@ -46,12 +39,11 @@
"reviewers": ["a110605", "houhoucoop"]
},
{
"matchUpdateTypes": ["patch"],
"automerge": false,
"matchUpdateTypes": ["patch", "digest"],
"automerge": true,
"minimumReleaseAge": "7 days",
"groupName": "patch dependencies",
"labels": ["patch-update"],
"reviewers": ["a110605", "houhoucoop"]
"groupName": "patch digest dependencies",
"labels": ["patch-update", "automerge"]
}
]
}
@@ -43,10 +43,12 @@ jobs:
needs:
- setup-release-tag
- check-version
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@3b26a36bad555e5e2b8634b24823be29732f287c # master
permissions:
actions: write
contents: write
pull-requests: write
deployments: write
pages: write
with:
target_branch: gh-pages
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
-20
View File
@@ -5,19 +5,6 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
> **Note:**
> This extension is available starting from **Rancher 2.10.0**. Ensure your Rancher version is **2.10.0 or later** to access Harvester integration.
## Table of Contents
- [Installation](#installation)
- [Development Setup](#development-setup)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Branch Structure](#branch-structure)
- [Testing Guidelines](#testing-guidelines)
- [Release](#release)
- [Contributing](#contributing)
- [License](#license)
## Installation
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
@@ -170,13 +157,6 @@ To test the standalone UI, configure Harvester to load the UI from an external s
2. Set **ui-source** to `External`
3. Set **ui-index** to the desired URL
## Release
The Harvester UI Extension follows the [Harvester](https://github.com/harvester/harvester) release cycle. After RC1 is cut for a new Harvester version, we usually create and work from the corresponding release branch (for example, `release-harvester-v1.8`). The remaining RC builds and the final official release are published from that branch.
After Harvester releases a new version, update the Harvester entry in rancher/ui-plugin-charts [manifest.json](https://github.com/rancher/ui-plugin-charts/blob/aafd215debbc6cb3100e7ba4b0a542c932397acd/manifest.json#L133-L151). This ensures air-gapped users can pull the new Harvester UI Extension image.
## Contributing
If you want to contribute, start by reading this document, then visit our [Getting Started guide](https://extensions.rancher.io/extensions/next/extensions-getting-started) to learn how to develop and submit changes.
+1 -1
View File
@@ -33,7 +33,7 @@ module.exports = {
'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'],
'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [2, 'always', 100],
},
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "harvester-ui-extension",
"version": "1.9.0-rc2",
"version": "1.8.2-rc1",
"private": false,
"engines": {
"node": ">=24.0.0"
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.29.7",
"@rancher/shell": "3.0.12-rc.3",
"@rancher/shell": "3.0.12-rc.1",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
@@ -1,186 +0,0 @@
<script>
import { Banner } from '@components/Banner';
import MatchExpressions from '@shell/components/form/MatchExpressions';
import ResourceTable from '@shell/components/ResourceTable';
import { _EDIT } from '@shell/config/query-params';
import { convert, simplify, matching as selectorMatching } from '@shell/utils/selector';
import throttle from 'lodash/throttle';
import { NODE } from '@shell/config/types';
import { NAME, AGE } from '@shell/config/table-headers';
export default {
name: 'HarvesterNodeSelector',
components: {
Banner,
MatchExpressions,
ResourceTable,
},
props: {
mode: {
type: String,
default: _EDIT,
},
value: {
type: Object,
required: true,
},
},
async fetch() {
this.updateMatchingResources();
},
data() {
const inStore = this.$store.getters['currentProduct'].inStore;
return {
matchingResources: {
matched: 0,
matches: [],
none: true,
sample: null,
total: 0,
},
tableHeaders: [
NAME,
{
name: 'host-ip',
labelKey: 'tableHeaders.hostIp',
search: ['internalIp'],
value: 'internalIp',
sort: ['internalIp'],
align: 'center',
},
{
name: 'cpuManager',
labelKey: 'harvester.tableHeaders.cpuManager',
value: 'id',
formatter: 'HarvesterCPUPinning',
width: 150,
align: 'center',
},
{
name: 'diskState',
labelKey: 'tableHeaders.diskState',
value: 'diskState',
formatter: 'HarvesterDiskState',
width: 130,
},
AGE,
],
inStore,
};
},
watch: {
value: {
handler: 'updateMatchingResources',
deep: true,
},
allResourcesInScope() {
this.updateMatchingResources();
},
},
computed: {
schema() {
return this.$store.getters[`${ this.inStore }/schemaFor`](NODE);
},
selectorExpressions: {
get() {
return convert(
this.value.matchLabels || {},
this.value.matchExpressions || []
);
},
set(selectorExpressions) {
const { matchLabels, matchExpressions } = simplify(selectorExpressions);
this.value['matchLabels'] = matchLabels;
this.value['matchExpressions'] = matchExpressions;
this.updateMatchingResources();
},
},
allNodes() {
return this.$store.getters[`${ this.inStore }/all`](NODE) || [];
},
allResourcesInScope() {
return this.allNodes.length;
},
},
methods: {
updateMatchingResources: throttle(function() {
const expressions = this.selectorExpressions;
const allNodes = this.allNodes;
// Empty expressions with no key = no match
const hasValidExpression = expressions.length > 0 && expressions.every((e) => !!e.key);
if (!hasValidExpression) {
this.matchingResources = {
matched: 0,
matches: [],
none: true,
sample: null,
total: allNodes.length,
};
return;
}
const matches = selectorMatching(allNodes, expressions, 'metadata.labels');
this.matchingResources = {
matched: matches.length,
matches,
none: matches.length === 0,
sample: matches[0]?.nameDisplay || null,
total: allNodes.length,
};
}, 100, { trailing: true })
},
};
</script>
<template>
<div>
<div class="row">
<div class="col span-12">
<MatchExpressions
v-model:value="selectorExpressions"
:mode="mode"
:show-remove="false"
:type="'node'"
:target-resources="allResourcesInScope"
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<Banner :color="(matchingResources.none ? 'warning' : 'success')">
<span v-clean-html="t('generic.selectors.matchingResources.matchesSome', matchingResources)" />
</Banner>
</div>
</div>
<div class="row">
<div class="col span-12">
<ResourceTable
:rows="matchingResources.matches"
:headers="tableHeaders"
key-field="id"
:table-actions="false"
:row-actions="false"
:schema="schema"
:groupable="false"
:search="false"
/>
</div>
</div>
</div>
</template>
-59
View File
@@ -1,59 +0,0 @@
<script setup>
defineProps({
networkEntries: {
type: Array,
default: () => [],
},
storageEntries: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div class="mappings-cell">
<div
v-for="(entry, idx) in networkEntries"
:key="'net-' + idx"
class="mapping-entry"
>
<i
class="icon icon-network"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
<div
v-for="(entry, idx) in storageEntries"
:key="'stor-' + idx"
class="mapping-entry"
>
<i
class="icon icon-datastore"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-cell {
display: flex;
flex-direction: column;
gap: 8px;
}
.mapping-entry {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
.icon {
font-size: 16px;
}
}
</style>
+1 -1
View File
@@ -122,7 +122,7 @@ export default {
return this.$store.getters['currentCluster'].isLocal;
},
canEditClusterMembers() {
return this.schema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
return this.normanClusterRTBSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
},
},
};
+5 -8
View File
@@ -58,14 +58,11 @@ export default {
const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
// Defer so v-select can finish closing the dropdown before the popup steals focus
this.$nextTick(() => {
window.open(
url,
'_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
});
window.open(
url,
'_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
},
isEmpty(o) {
@@ -1,196 +0,0 @@
<script>
import { RadioGroup } from '@components/Form/Radio';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { NAMESPACE } from '@shell/config/types';
import CreateEditView from '@shell/mixins/create-edit-view';
export default {
name: 'HarvesterClusterPodSecurityStandard',
components: {
RadioGroup,
LabeledSelect,
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
default: () => ({}),
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
},
data() {
let enabled = false;
let whitelistedNamespaces = [];
let privilegedNamespaces = [];
let restrictedNamespaces = [];
try {
const parsed = JSON.parse(this.value.value || this.value.default || '{}');
enabled = !!parsed.enabled;
whitelistedNamespaces = parsed.whitelistedNamespacesList ? parsed.whitelistedNamespacesList.split(',') : [];
privilegedNamespaces = parsed.privilegedNamespacesList ? parsed.privilegedNamespacesList.split(',') : [];
restrictedNamespaces = parsed.restrictedNamespacesList ? parsed.restrictedNamespacesList.split(',') : [];
} catch (e) {
enabled = false;
whitelistedNamespaces = [];
privilegedNamespaces = [];
restrictedNamespaces = [];
}
return {
enabled,
whitelistedNamespaces,
privilegedNamespaces,
restrictedNamespaces,
};
},
computed: {
enabledOptions() {
return [
{ label: this.t('generic.enabled'), value: true },
{ label: this.t('generic.disabled'), value: false },
];
},
allNamespaces() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/all`](NAMESPACE).filter((ns) => !ns.isSystem).map((ns) => ns.id);
},
whitelistedOptions() {
const excluded = new Set([...this.privilegedNamespaces, ...this.restrictedNamespaces]);
return this.allNamespaces.filter((ns) => !excluded.has(ns));
},
privilegedOptions() {
const excluded = new Set([...this.whitelistedNamespaces, ...this.restrictedNamespaces]);
return this.allNamespaces.filter((ns) => !excluded.has(ns));
},
restrictedOptions() {
const excluded = new Set([...this.whitelistedNamespaces, ...this.privilegedNamespaces]);
return this.allNamespaces.filter((ns) => !excluded.has(ns));
},
},
methods: {
useDefault() {
try {
const parsed = JSON.parse(this.value.default || '{}');
this.enabled = !!parsed.enabled;
this.whitelistedNamespaces = parsed.whitelistedNamespacesList ? parsed.whitelistedNamespacesList.split(',') : [];
this.privilegedNamespaces = parsed.privilegedNamespacesList ? parsed.privilegedNamespacesList.split(',') : [];
this.restrictedNamespaces = parsed.restrictedNamespacesList ? parsed.restrictedNamespacesList.split(',') : [];
} catch (e) {
this.enabled = false;
this.whitelistedNamespaces = [];
this.privilegedNamespaces = [];
this.restrictedNamespaces = [];
}
this.save();
},
onUpdateEnabled() {
if (!this.enabled) {
this.whitelistedNamespaces = [];
this.privilegedNamespaces = [];
this.restrictedNamespaces = [];
}
this.save();
},
updateWhitelisted(selected) {
this.whitelistedNamespaces = selected;
this.save();
},
updatePrivileged(selected) {
this.privilegedNamespaces = selected;
this.save();
},
updateRestricted(selected) {
this.restrictedNamespaces = selected;
this.save();
},
save() {
this.value.value = JSON.stringify({
enabled: this.enabled,
whitelistedNamespacesList: this.whitelistedNamespaces.join(','),
privilegedNamespacesList: this.privilegedNamespaces.join(','),
restrictedNamespacesList: this.restrictedNamespaces.join(','),
});
},
},
};
</script>
<template>
<div>
<div class="row mb-20">
<div class="col span-6">
<RadioGroup
v-model:value="enabled"
name="enabled"
:options="enabledOptions"
@update:value="onUpdateEnabled"
/>
</div>
</div>
<template v-if="enabled">
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="whitelistedNamespaces"
:label="t('harvester.setting.clusterPodSecurityStandard.whitelistedNamespaces.label')"
:options="whitelistedOptions"
:multiple="true"
:mode="mode"
@update:value="updateWhitelisted"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="privilegedNamespaces"
:label="t('harvester.setting.clusterPodSecurityStandard.privilegedNamespaces.label')"
:options="privilegedOptions"
:multiple="true"
:mode="mode"
@update:value="updatePrivileged"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="restrictedNamespaces"
:label="t('harvester.setting.clusterPodSecurityStandard.restrictedNamespaces.label')"
:options="restrictedOptions"
:multiple="true"
:mode="mode"
@update:value="updateRestricted"
/>
</div>
</div>
</template>
</div>
</template>
@@ -64,9 +64,6 @@ export default {
try {
parsedDefaultValue = JSON.parse(this.value.value);
if (typeof parsedDefaultValue.exclusiveVlan !== 'boolean') {
parsedDefaultValue.exclusiveVlan = false;
}
networkType = 'vlan' in parsedDefaultValue ? L2VLAN : UNTAGGED; // backend doesn't provide networkType, so we check if vlan is provided instead
openVlan = true;
} catch (error) {
@@ -75,8 +72,7 @@ export default {
vlan: '',
clusterNetwork: '',
range: '',
exclude: [],
exclusiveVlan: false
exclude: []
};
}
const exclude = parsedDefaultValue?.exclude?.toString().split(',') || [];
@@ -98,10 +94,6 @@ export default {
},
computed: {
showExclusiveVlan() {
return this.networkType === L2VLAN &&
Number(this.parsedDefaultValue.vlan) !== 1;
},
showVlan() {
return this.networkType === L2VLAN;
},
@@ -140,18 +132,6 @@ export default {
};
});
},
exclusiveVlanOptions() {
return [
{
label: this.t('generic.enabled'),
value: true
},
{
label: this.t('generic.disabled'),
value: false
}
];
},
},
watch: {
@@ -194,8 +174,7 @@ export default {
vlan: '',
clusterNetwork: '',
range: '',
exclude: [],
exclusiveVlan: false
exclude: []
};
},
@@ -301,15 +280,7 @@ export default {
label-key="harvester.setting.storageNetwork.vlan"
@update:value="inputVlan"
/>
<LabeledSelect
v-if="showExclusiveVlan"
v-model:value="parsedDefaultValue.exclusiveVlan"
class="mb-20"
:options="exclusiveVlanOptions"
:mode="mode"
label-key="harvester.setting.storageNetwork.exclusiveVlan"
@update:value="update"
/>
<LabeledSelect
v-model:value="parsedDefaultValue.clusterNetwork"
label-key="harvester.setting.storageNetwork.clusterNetwork"
@@ -1,561 +0,0 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
useAllProviderData: { type: Boolean, default: false },
existingNetworkMap: { type: Object, default: null },
existingStorageMap: { type: Object, default: null },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const errors = ref([]);
const loading = ref(true);
const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE;
const harvesterNetworkOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
];
harvesterNetworks.value.forEach((net) => {
const ns = net.metadata?.namespace || '';
const name = net.metadata?.name || net.id;
const fullName = ns ? `${ ns }/${ name }` : name;
options.push({ label: fullName, value: fullName });
});
return options;
});
const storageClassOptions = computed(() => {
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => {
const value = sc.name;
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value;
let disabled = false;
if (isInternalStorageClass(value)) {
label += ` (${ t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value,
disabled,
};
});
if (!options.find((o) => o.value === 'harvester-longhorn')) {
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
}
return options;
});
const applyNetworkMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
networkEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination) {
const dest = match.destination;
if (dest.type === 'pod') {
entry.target = 'pod';
} else if (dest.type === 'ignored') {
entry.target = 'ignored';
} else if (dest.type === 'multus' && dest.name) {
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
}
}
});
};
const applyStorageMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
storageEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination?.storageClass) {
entry.target = match.destination.storageClass;
}
});
};
const applyDefaultNetworkMap = () => {
const defaultMap = allNetworkMaps.value.find(
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
);
applyNetworkMapTargets(defaultMap?.spec?.map);
};
const applyDefaultStorageMap = () => {
const defaultMap = allStorageMaps.value.find(
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map);
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
const canSave = computed(() => {
if (props.useAllProviderData) {
return true;
}
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value;
});
watch(canSave, (val) => {
emit('ready', val);
});
// After restore, check if ready
if (canSave.value) {
emit('ready', true);
}
const buildNetworkEntries = () => {
const networkMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => {
const netKey = net.id || net.name || 'default';
if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ netKey }`,
};
}
if (!networkMap[netKey].usedBy.includes(vmName)) {
networkMap[netKey].usedBy.push(vmName);
}
});
}
});
networkEntries.value = Object.values(networkMap);
};
const buildStorageEntries = () => {
const datastoreMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.disks && vm.disks.length > 0) {
vm.disks.forEach((disk) => {
const ds = disk.datastore;
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
datastoreMap[ds.id].capacity += disk.capacity || 0;
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
datastoreMap[ds.id].usedBy.push(vmName);
}
}
});
}
});
storageEntries.value = Object.values(datastoreMap);
};
const buildNetworkEntriesFromProvider = (networksData) => {
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
name: net.name || net.id,
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ net.id || net.name }`,
}));
};
const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
const formatStorageDetail = (entry) => {
const parts = [];
if (entry.type) {
parts.push(entry.type);
}
if (entry.capacity) {
const gb = entry.capacity / (1024 * 1024 * 1024);
if (gb >= 1024) {
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
} else {
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
}
}
return parts.join(' • ');
};
const buildNetworkMapSpec = (providerRef) => ({
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
provider: providerRef,
});
const buildStorageMapSpec = (providerRef) => ({
map: buildStorageMapEntries(storageEntries.value),
provider: providerRef,
});
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Update existing maps in place (edit mode)
if (props.existingNetworkMap && props.existingStorageMap) {
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
await props.existingNetworkMap.save();
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
await props.existingStorageMap.save();
return {
networkMapName: props.existingNetworkMap.metadata.name,
storageMapName: props.existingStorageMap.metadata.name,
};
}
const providerOwnerRef = props.provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.provider.metadata.name,
uid: props.provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildNetworkMapSpec(providerRef),
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildStorageMapSpec(providerRef),
});
await storageMap.save();
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
};
defineExpose({ saveMappings: saveAndReturn });
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
} catch (e) {
harvesterNetworks.value = [];
}
try {
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch (e) {
storageClasses.value = [];
}
try {
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
} catch (e) {
allNetworkMaps.value = [];
}
try {
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
} catch (e) {
allStorageMaps.value = [];
}
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
try {
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [networksData, datastoresData] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
]);
if (props.useAllProviderData) {
if (networkEntries.value.length === 0) {
buildNetworkEntriesFromProvider(networksData);
}
if (storageEntries.value.length === 0) {
buildStorageEntriesFromProvider(datastoresData);
}
} else {
vms.value = props.selectedVms;
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
}
return resolved;
});
if (networkEntries.value.length === 0) {
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
}
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
}
}
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
if (!hasExistingNetworkTargets) {
if (props.existingNetworkMap?.spec?.map) {
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
} else {
applyDefaultNetworkMap();
}
}
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map);
} else {
applyDefaultStorageMap();
}
}
loading.value = false;
};
init();
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-mappings"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
:entries="networkEntries"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
</template>
</MappingColumn>
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
:entries="storageEntries"
:options="storageClassOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span
v-if="entry.type || entry.capacity"
class="source-detail text-deemphasized"
>{{ formatStorageDetail(entry) }}</span>
</template>
</MappingColumn>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-columns {
display: flex;
flex-direction: column;
gap: 32px;
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
gap: 48px;
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
gap: 64px;
}
}
.configure-mappings {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,539 +0,0 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { decodeSecretValue } from '../../utils/forklift';
const CREATE_NEW = '__create_new__';
const props = defineProps({
stepData: { type: Object, required: true },
createOnly: { type: Boolean, default: false },
editMode: { type: Boolean, default: false },
});
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
const store = useStore();
const { t } = useI18n(store);
const allProviders = ref([]);
const allSecrets = ref([]);
const errors = ref([]);
const testBtnRef = ref(null);
const loading = ref(true);
const testing = ref(false);
const {
selectedProvider,
providerName,
url,
username,
password,
skipTlsVerify,
testPassed,
testResult,
testError,
createdProvider,
createdSecret,
} = toRefs(props.stepData);
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const providerOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
];
allProviders.value.forEach((p) => {
if (!p.spec?.url) {
return;
}
const secretRef = p.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (!secret?.data?.user || !secret?.data?.password) {
return;
}
} else {
return;
}
options.push({
label: p.metadata.name,
value: p.metadata.name,
});
});
return options;
});
// When the provider selection changes, populate or clear the fields
watch(selectedProvider, (val) => {
testPassed.value = false;
testResult.value = null;
testError.value = null;
if (val === CREATE_NEW) {
providerName.value = '';
url.value = '';
username.value = '';
password.value = '';
skipTlsVerify.value = false;
createdProvider.value = null;
createdSecret.value = null;
} else {
const provider = allProviders.value.find(
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
);
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
const secretRef = provider.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
username.value = decodeSecretValue(secret.data.user);
password.value = decodeSecretValue(secret.data.password);
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
}
}
createdProvider.value = provider;
}
}
});
// Reset test state when any field changes (only for create new)
watch([providerName, url, username, password], () => {
if (!isExistingProvider.value) {
testPassed.value = false;
testResult.value = null;
}
});
// Emit ready/complete when testPassed changes
watch(testPassed, (val) => {
emit('ready', val);
if (val) {
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
}
}, { immediate: true });
// Emit form-valid when form validity changes
watch(isFormValid, (val) => {
emit('form-valid', val);
}, { immediate: true });
watch(testing, (val) => {
emit('testing', val);
}, { immediate: true });
const pollProviderReady = async(name) => {
const inStore = store.getters['currentProduct'].inStore;
const namespace = FORKLIFT_NAMESPACE;
const maxAttempts = 15;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ name }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
const readyCondition = conditions.find((c) => c.type === 'Ready');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
return { connected: true };
}
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
}
if (readyCondition) {
if (readyCondition.status === 'True') {
return { connected: true };
} else if (readyCondition.status === 'False') {
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
}
}
}
return { connected: false, errorMsg: '' };
};
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
};
const cleanupCreatedResources = async() => {
if (createdProvider.value) {
try {
await createdProvider.value.remove();
} catch (e) {}
createdProvider.value = null;
createdSecret.value = null;
} else if (createdSecret.value) {
try {
await createdSecret.value.remove();
} catch (e) {}
createdSecret.value = null;
}
};
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
testing.value = false;
buttonCb(false);
return;
}
const inStore = store.getters['currentProduct'].inStore;
// Edit mode: update existing provider URL + secret, then poll
if (props.editMode && createdProvider.value) {
try {
createdProvider.value.spec.url = url.value;
await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret;
if (secretRef && createdSecret.value) {
createdSecret.value.data = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await createdSecret.value.save();
}
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For new providers, create provider + secret then poll
try {
await cleanupCreatedResources();
const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec: {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
}
});
await provider.save();
createdProvider.value = provider;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
},
],
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await newSecret.save();
createdSecret.value = newSecret;
const result = await pollProviderReady(providerName.value);
if (!result.connected) {
await cleanupCreatedResources();
}
handlePollResult(result, buttonCb);
} catch (err) {
await cleanupCreatedResources();
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
}
loading.value = false;
};
init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-provider-step"
>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
</p>
<div class="configure-provider-step-content">
<h3 class="table-title m-0">
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
</h3>
<Banner
class="requirements-banner m-0"
color="info"
>
<div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
<br>
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
</div>
</Banner>
<div class="configure-provider-step-form">
<div v-if="!createOnly && !editMode">
<LabeledSelect
v-model:value="selectedProvider"
required
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider || editMode"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
:disabled="editMode"
required
/>
</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">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
</div>
<div>
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider && !editMode"
/>
<p class="text-deemphasized ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p>
</div>
<div>
<AsyncButton
ref="testBtnRef"
mode="test"
:disabled="!isFormValid"
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
:action-color="'role-secondary'"
:waiting-color="'role-disabled'"
@click="testConnection"
/>
</div>
<Banner
v-if="testResult"
color="success"
>
{{ testResult }}
</Banner>
<Banner
v-if="testError"
color="error"
>
{{ testError }}
</Banner>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
>
{{ err }}
</Banner>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.requirements-banner {
font-weight: 400;
.requirements-banner-title {
font-weight: 600;
}
}
.configure-provider-step {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
.configure-provider-step-content {
display: flex;
flex-direction: column;
gap: 12px;
.configure-provider-step-form {
display: flex;
flex-direction: column;
gap: 20px;
}
}
:deep(.checkbox-label) {
color: var(--body-text);
}
}
</style>
@@ -1,184 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
const store = useStore();
const { t } = useI18n(store);
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
});
const selectOptions = computed(() => {
if (!props.clearable) {
return props.options;
}
return [
{
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
value: null,
kind: 'highlighted',
},
{
label: 'divider',
disabled: true,
kind: 'divider',
},
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options,
];
});
</script>
<template>
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ title }}
</h3>
<p
v-if="description"
class="text-deemphasized line-height-20"
>
{{ description }}
</p>
</div>
<RcItemCard
v-for="entry in entries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
>
<template #item-card-content>
<div class="card-content-column">
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<slot
name="source-detail"
:entry="entry"
/>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
<i
class="icon icon-right-arrow-alt"
aria-hidden="true"
/>
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="selectOptions"
:placeholder="placeholder+'...'"
:searchable="true"
/>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
</template>
<style lang="scss" scoped>
.mapping-column {
display: flex;
gap: 12px;
flex-direction: column;
:deep(.item-card-header) {
display: none;
}
}
.mapping-section-title {
font-weight: 600;
margin: 0 0 5px 0;
line-height: 28px;
}
.card-content-row {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
flex: 2;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 3;
min-width: 0;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
</style>
@@ -1,439 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import {
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
} from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
mappingEntries: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
const NAMESPACE = FORKLIFT_NAMESPACE;
const TARGET_NAMESPACE = 'default';
watch(planName, (val) => {
props.stepData.planName = val;
emit('ready', !!val);
}, { immediate: true });
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => {
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return mbToGB(totalMB);
});
const totalStorageGB = computed(() => {
let totalBytes = 0;
vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) {
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
});
return bytesToGB(totalBytes);
});
const vmCards = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-';
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));
return {
id: vm.id,
name: vm.name || vm.id,
os,
cpus,
memGB,
diskDisplay,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
};
});
});
const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
const networkMapName = `${ planName.value }-network-map`;
const storageMapName = `${ planName.value }-storage-map`;
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
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: NAMESPACE },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
});
await storageMap.save();
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NAMESPACE },
spec: {
provider: providerRef,
map: {
network: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: networkMapName,
namespace: NAMESPACE,
},
storage: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: storageMapName,
namespace: NAMESPACE,
},
},
targetNamespace: TARGET_NAMESPACE,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.save();
const planOwnerRef = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
networkMap.metadata.ownerReferences = [planOwnerRef];
await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
};
const init = () => {
vms.value = props.selectedVms;
if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
}
loading.value = false;
};
init();
defineExpose({ startMigration: startMigrationAction });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<div
class="review-migration-content"
>
<!-- Migration Details Summary -->
<div class="migration-details">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<div class="span-9">
<LabeledInput
v-model:value="planName"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
</div>
<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="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">
<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>
</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"
:key="i"
color="error"
:label="err"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-migration {
gap: 36px;
}
.review-migration-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
font-weight: 600;
}
.details-grid {
display: grid;
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;
}
.detail-column {
display: flex;
flex-direction: column;
gap: 4px;
&.grid-column-2 {
grid-column: span 2;
}
}
.detail-item {
display: flex;
flex-direction: row;
justify-content: space-between;
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
&.detail-value-large {
font-size: 24px;
}
}
}
.vm-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.vm-card-content {
display: flex;
flex-direction: column;
gap: 16px;
line-height: 20px;
}
.vm-card-specs {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.vm-os {
font-size: 14px;
}
.vm-resources {
font-size: 14px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.banner-title {
font-weight: 600;
}
.migration-details {
display: flex;
flex-direction: column;
gap: 12px;
}
header {
margin: 0;
}
.vm-section {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
@@ -1,583 +0,0 @@
<script setup>
import {
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
} 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 { useI18n } from '@shell/composables/useI18n';
import { bytesToGB, mbToGB } from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['complete', 'loading']);
const store = useStore();
const { t } = useI18n(store);
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]);
const loading = ref(true);
const networkMap = ref({});
const datastoreMap = ref({});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const errors = ref([]);
let skipNextSelectionEvent = false;
const lastFetchedAt = ref(null);
const now = ref(Date.now());
const formatElapsed = (ms) => {
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
}
if (hours > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
}
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
};
const lastSyncedTime = computed(() => {
if (!lastFetchedAt.value) {
return '';
}
return formatElapsed(now.value - lastFetchedAt.value);
});
const nowTimer = setInterval(() => {
now.value = Date.now();
}, 30000);
onBeforeUnmount(() => {
clearInterval(nowTimer);
});
// Restore selection state from stepData
if (selectedVMIds.value.size > 0) {
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
}
const vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length);
const showSelectAllBanner = computed(() => {
if (allVMsSelected.value) {
return true;
}
const pagedRows = sortableTableRef.value?.pagedRows || [];
if (pagedRows.length === 0) {
return false;
}
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
return allPageSelected && tableRows.value.length > pagedRows.length;
});
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os',
sort: ['os'],
width: 180,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources',
sort: false,
width: 140,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 130,
},
{
name: 'network',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
];
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
let networks = [];
if (vm.networks && vm.networks.length > 0) {
networks = vm.networks.map((n) => ({
name: n.name || networkMap.value[n.id] || n.id || n,
id: n.id || '',
vlanId: n.vlanId || '',
}));
}
const datastores = [];
if (vm.disks && vm.disks.length > 0) {
const seen = new Set();
vm.disks.forEach((d) => {
const dsId = d.datastore?.id || d.datastore?.name;
if (dsId && !seen.has(dsId)) {
seen.add(dsId);
datastores.push({
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
id: d.datastore?.id || '',
type: d.datastore?.type || '',
capacity: d.capacity || 0,
});
}
});
}
return {
_original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name,
vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-',
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
networks,
network: networks.map((n) => n.name).join(', ') || '-',
datastores,
datastore: datastores.map((d) => d.name).join(', ') || '-',
};
});
};
const onSelect = (rows) => {
if (skipNextSelectionEvent) {
return;
}
const currentPageRows = sortableTableRef.value?.pagedRows || [];
const selectedKeys = new Set(rows.map((r) => r._key));
currentPageRows.forEach((row) => {
const vmId = row._original?.id;
if (selectedKeys.has(row._key)) {
selectedVMIds.value.add(vmId);
} else {
selectedVMIds.value.delete(vmId);
}
});
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
emit('complete', { selectedVMs: selectedVMs.value });
};
const clearSelection = () => {
allVMsSelected.value = false;
selectedVMIds.value = new Set();
selectedVMs.value = [];
nextTick(() => {
const table = sortableTableRef.value;
if (table) {
table.clearSelection();
}
});
};
const selectAllVMs = () => {
allVMsSelected.value = true;
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
selectedVMs.value = discoveredVMs.value.slice();
};
watch(
() => sortableTableRef.value?.page,
() => {
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
return;
}
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
}
);
const refreshing = ref(false);
const isLoading = computed(() => loading.value || refreshing.value);
watch(isLoading, (val) => {
emit('loading', val);
}, { immediate: true });
const fetchVMs = async() => {
if (!props.provider) {
return;
}
const inStore = store.getters['currentProduct'].inStore;
const providerUid = props.provider.metadata.uid;
const providerType = props.provider.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
]);
if (Array.isArray(vmsResp)) {
discoveredVMs.value = vmsResp;
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
discoveredVMs.value = vmsResp.data;
}
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
networkMap.value = networks.reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
datastoreMap.value = datastores.reduce((map, d) => {
map[d.id] = d.name;
return map;
}, {});
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
const previousSelectedIds = new Set(selectedVMIds.value);
try {
errors.value = [];
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
}
selectedVMIds.value = new Set(
discoveredVMs.value
.filter((vm) => previousSelectedIds.has(vm.id))
.map((vm) => vm.id)
);
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
props.stepData.discoveredVMs = discoveredVMs.value;
props.stepData.selectedVMIds = selectedVMIds.value;
props.stepData.tableRows = tableRows.value;
emit('complete', { selectedVMs: selectedVMs.value });
refreshing.value = false;
await nextTick();
const table = sortableTableRef.value;
if (table) {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
}
skipNextSelectionEvent = false;
};
const init = async() => {
if (discoveredVMs.value.length > 0) {
if (!lastFetchedAt.value) {
lastFetchedAt.value = Date.now();
}
tableRows.value = buildTableRows();
loading.value = false;
return;
}
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
}
loading.value = false;
};
// Emit initial complete if VMs are already selected (restored from stepData)
if (selectedVMs.value.length > 0) {
emit('complete', { selectedVMs: selectedVMs.value });
}
init();
</script>
<template>
<Loading v-if="loading || refreshing" />
<div
v-else
class="select-vms-step"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link
v-if="provider"
class="provider-link"
:to="provider._detailLocation"
>
{{ providerName }}
</router-link>
<br>
<span
v-if="lastSyncedTime"
>
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
<a
role="button"
class="text-bold"
:class="{ disabled: refreshing }"
@click.prevent="refreshVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
</a>
</span>
</p>
<!-- Discovered VMs table -->
<SortableTable
ref="sortableTableRef"
:rows="tableRows"
:headers="headers"
:search="true"
:table-actions="true"
:row-actions="false"
:groupable="false"
:paging="true"
:rows-per-page="20"
key-field="_key"
@selection="onSelect"
>
<template #header-left>
<div class="vm-table-title">
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
</div>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.vmName }}</span>
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resourcesDisplay }}</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:network="{ row }">
<div>
<div
v-for="(net, i) in row.networks"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ net.name }}</span><br>
<span class="text-deemphasized">{{ net.id }}</span>
</div>
</div>
</template>
<template #cell:datastore="{ row }">
<div>
<div
v-for="(ds, i) in row.datastores"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ ds.name }}</span><br>
<span class="text-deemphasized">{{ ds.id }}</span>
</div>
</div>
</template>
</SortableTable>
<Teleport
v-if="showSelectAllBanner && theadElement"
:to="theadElement"
>
<tr class="select-all-banner-row">
<td
:colspan="headers.length + 1"
class="select-all-banner-cell"
>
<template v-if="allVMsSelected">
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a>
</template>
<template v-else>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a
role="button"
@click.prevent="selectAllVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a>
</template>
</td>
</tr>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.vm-table-title {
.text-deemphasized {
font-size: 13px;
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
font-size: 14px;
.vm-name {
line-height: 20px;
}
.vm-id {
line-height: 20px;
}
}
.select-vms-step {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
.line-height-20 {
line-height: 20px;
}
}
:deep(.select-all-banner-row) {
.select-all-banner-cell {
text-align: center;
padding: 8px 16px;
background-color: var(--sortable-table-header-bg);
border-bottom: 1px solid var(--border);
font-size: 13px;
a {
color: var(--primary);
cursor: pointer;
font-weight: 600;
margin-left: 5px;
&:hover {
text-decoration: underline;
}
}
}
}
</style>
+1 -11
View File
@@ -65,17 +65,7 @@ const FEATURE_FLAGS = {
'vGPUAsPCIDevice',
'instanceManagerResourcesSetting',
'rwxNetworkSetting',
'createPVCWithDataVolume',
'clusterPodSecurityStandardSetting',
],
'v1.8.1': [],
'v1.9.0': [
'supportFilesystem',
'disableResourcePooling',
'expandOnlineEncryptedVolume',
'longhornV2HugepageSettings',
'staticIPForVM',
'fsFreezeDeadline',
'createPVCWithDataVolume'
],
};
+10 -386
View File
@@ -47,13 +47,6 @@ import {
VM_IMPORT_SOURCE_O_STATUS,
VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
FORKLIFT_MIGRATION_PLAN,
} from './table-headers';
import { ADD_ONS } from './harvester-map';
import { registerAddonSideNav } from '../utils/dynamic-nav';
@@ -61,14 +54,6 @@ import { registerAddonSideNav } from '../utils/dynamic-nav';
const TEMPLATE = HCI.VM_VERSION;
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
const LOGGING_GROUP = 'Monitoring & Logging::Logging';
const OVERLAY_NETWORKS_GROUP = 'Overlay Networks';
const UNDERLAY_NETWORKS_GROUP = 'Underlay Networks';
const NAT_INTERNET_GROUP = `${ OVERLAY_NETWORKS_GROUP }::NAT & Internet`;
const GATEWAYS_GROUP = `${ NAT_INTERNET_GROUP }::Gateways`;
const EXTERNAL_IPS_GROUP = `${ NAT_INTERNET_GROUP }::External IPs`;
const RULES_GROUP = `${ NAT_INTERNET_GROUP }::Rules`;
const SOURCE_RULES_GROUP = `${ RULES_GROUP }::Source Rules`;
const DESTINATION_RULES_GROUP = `${ RULES_GROUP }::Destination Rules`;
export const PRODUCT_NAME = 'harvester';
@@ -252,7 +237,6 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimport',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT }
@@ -282,7 +266,6 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_V,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V }
@@ -312,7 +295,6 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_O,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O }
@@ -341,7 +323,6 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_OVA,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
@@ -503,7 +484,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_FLOW,
labelKey: 'harvester.logging.clusterFlow.label',
name: HCI.CLUSTER_FLOW,
namespaced: true,
@@ -527,7 +507,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_OUTPUT,
labelKey: 'harvester.logging.clusterOutput.label',
name: HCI.CLUSTER_OUTPUT,
namespaced: true,
@@ -551,7 +530,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.FLOW,
labelKey: 'harvester.logging.flow.label',
name: HCI.FLOW,
namespaced: true,
@@ -575,7 +553,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.OUTPUT,
labelKey: 'harvester.logging.output.label',
name: HCI.OUTPUT,
namespaced: true,
@@ -596,53 +573,14 @@ export function init($plugin, store) {
[
HCI.CLUSTER_NETWORK,
HCI.NETWORK_ATTACHMENT,
HCI.HOST_NETWORK_CONFIG,
HCI.VPC,
NETWORK_POLICY,
HCI.LB,
HCI.IP_POOL,
],
'networks'
);
basicType(
[HCI.VPC],
OVERLAY_NETWORKS_GROUP
);
basicType(
[NETWORK_POLICY],
OVERLAY_NETWORKS_GROUP
);
basicType(
[HCI.VPC_NAT_GATEWAY],
GATEWAYS_GROUP
);
basicType(
[HCI.IPTABLES_EIP],
EXTERNAL_IPS_GROUP
);
basicType(
[HCI.IPTABLES_SNAT_RULE],
SOURCE_RULES_GROUP
);
basicType(
[HCI.IPTABLES_DNAT_RULE],
DESTINATION_RULES_GROUP
);
basicType(
[HCI.PROVIDER_NETWORK],
UNDERLAY_NETWORKS_GROUP
);
basicType(
[HCI.VLAN],
UNDERLAY_NETWORKS_GROUP
);
basicType(
[
HCI.SCHEDULE_VM_BACKUP,
@@ -654,11 +592,7 @@ export function init($plugin, store) {
);
weightGroup('networks', 494, true);
weightGroup('Overlay Networks', 493, true);
weightGroup('NAT & Internet', 492, true);
weightGroup('Rules', 491, true);
weightGroup('Underlay Networks', 490, true);
weightGroup('backupAndSnapshot', 489, true);
weightGroup('backupAndSnapshot', 493, true);
basicType(
[
@@ -737,7 +671,7 @@ export function init($plugin, store) {
name: HCI.CLUSTER_NETWORK,
ifHaveType: HCI.CLUSTER_NETWORK,
namespaced: false,
weight: 484,
weight: 189,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.CLUSTER_NETWORK }
@@ -752,14 +686,14 @@ export function init($plugin, store) {
},
resource: NETWORK_ATTACHMENT,
resourceDetail: HCI.NETWORK_ATTACHMENT,
resourceEdit: HCI.NETWORK_ATTACHMENT,
resourceEdit: HCI.NETWORK_ATTACHMENT
});
virtualType({
labelKey: 'harvester.network.label',
name: HCI.NETWORK_ATTACHMENT,
namespaced: true,
weight: 485,
weight: 188,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.NETWORK_ATTACHMENT }
@@ -773,7 +707,7 @@ export function init($plugin, store) {
labelKey: 'harvester.vpc.label',
name: HCI.VPC,
namespaced: true,
weight: 195,
weight: 187,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VPC }
@@ -782,73 +716,13 @@ export function init($plugin, store) {
ifHaveType: HCI.VPC,
});
configureType(HCI.VPC_NAT_GATEWAY, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.natGateway.label',
name: HCI.VPC_NAT_GATEWAY,
namespaced: false,
weight: 193,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VPC_NAT_GATEWAY }
},
exact: false,
ifHaveType: HCI.VPC_NAT_GATEWAY,
});
configureType(HCI.IPTABLES_EIP, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.externalIP.label',
name: HCI.IPTABLES_EIP,
namespaced: false,
weight: 192,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IPTABLES_EIP }
},
exact: false,
ifHaveType: HCI.IPTABLES_EIP,
});
configureType(HCI.IPTABLES_SNAT_RULE, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.snat.label',
name: HCI.IPTABLES_SNAT_RULE,
namespaced: false,
weight: 191,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IPTABLES_SNAT_RULE }
},
exact: false,
ifHaveType: HCI.IPTABLES_SNAT_RULE,
});
configureType(HCI.IPTABLES_DNAT_RULE, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.dnat.label',
name: HCI.IPTABLES_DNAT_RULE,
namespaced: false,
weight: 190,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IPTABLES_DNAT_RULE }
},
exact: false,
ifHaveType: HCI.IPTABLES_DNAT_RULE,
});
configureType(NETWORK_POLICY, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.networkPolicy.label',
name: NETWORK_POLICY,
namespaced: true,
weight: 194,
weight: 186,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: NETWORK_POLICY }
@@ -857,53 +731,6 @@ export function init($plugin, store) {
ifHaveType: NETWORK_POLICY,
});
configureType(HCI.PROVIDER_NETWORK, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.providerNetwork.label',
name: HCI.PROVIDER_NETWORK,
namespaced: false,
weight: 189,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.PROVIDER_NETWORK }
},
exact: false,
ifHaveType: HCI.PROVIDER_NETWORK,
});
configureType(HCI.VLAN, { hiddenNamespaceGroupButton: true, canYaml: false });
headers(HCI.VLAN, [
STATE,
NAME_COL,
{
name: 'id',
label: 'ID',
value: 'spec.id',
sort: 'spec.id'
},
{
name: 'provider',
labelKey: 'harvester.subnet.provider.label',
value: 'spec.provider',
sort: 'spec.provider'
}
]);
virtualType({
labelKey: 'harvester.vlanNetwork.label',
name: HCI.VLAN,
namespaced: false,
weight: 188,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VLAN }
},
exact: false,
ifHaveType: HCI.VLAN,
});
configureType(HCI.SNAPSHOT, {
isCreatable: false,
location: {
@@ -1258,7 +1085,7 @@ export function init($plugin, store) {
labelKey: 'harvester.loadBalancer.label',
name: HCI.LB,
namespaced: true,
weight: 483,
weight: 185,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.LB }
@@ -1297,7 +1124,7 @@ export function init($plugin, store) {
labelKey: 'harvester.ipPool.label',
name: HCI.IP_POOL,
namespaced: false,
weight: 482,
weight: 184,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IP_POOL }
@@ -1306,207 +1133,4 @@ export function init($plugin, store) {
ifHaveType: HCI.IP_POOL,
});
headers(HCI.IP_POOL, IP_POOL_HEADERS);
configureType(HCI.HOST_NETWORK_CONFIG, {
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST_NETWORK_CONFIG }
},
canYaml: false,
});
virtualType({
labelKey: 'harvester.hostNetworkConfig.label',
name: HCI.HOST_NETWORK_CONFIG,
namespaced: false,
weight: 481,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST_NETWORK_CONFIG }
},
exact: false,
ifHaveType: HCI.HOST_NETWORK_CONFIG,
});
// ===========================================================================
// Forklift Addon UI Flow
// ===========================================================================
weightGroup('vmMigration', 0, false);
// Provider
headers(HCI.FORKLIFT_PROVIDER, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
AGE
]);
configureType(HCI.FORKLIFT_PROVIDER, {
resource: HCI.FORKLIFT_PROVIDER,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'vmMigration',
namespaced: true,
weight: 100,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
// NetworkMap
headers(HCI.FORKLIFT_NETWORK_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_NETWORK_MAP, {
resource: HCI.FORKLIFT_NETWORK_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
// StorageMap
headers(HCI.FORKLIFT_STORAGE_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_STORAGE_MAP, {
resource: HCI.FORKLIFT_STORAGE_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
// Plan
headers(HCI.FORKLIFT_PLAN, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
AGE
]);
configureType(HCI.FORKLIFT_PLAN, {
resource: HCI.FORKLIFT_PLAN,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
// Migration
headers(HCI.FORKLIFT_MIGRATION, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MIGRATION_PLAN,
AGE
]);
configureType(HCI.FORKLIFT_MIGRATION, {
resource: HCI.FORKLIFT_MIGRATION,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({
name: 'forklift-create',
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'vmMigration',
namespaced: true,
weight: 200,
route: {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {}
}
});
// Enable SideNav based on Forklift Addon Status
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
// is registered with requireSchema:false to still be gated by the addon status.
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
requireSchema: false,
types: ['forklift-create']
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
types: [
HCI.FORKLIFT_PROVIDER,
]
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration::Configurations',
types: [
HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP,
HCI.FORKLIFT_PLAN,
HCI.FORKLIFT_MIGRATION,
]
});
// ===========================================================================
}
-6
View File
@@ -79,7 +79,6 @@ export const ADD_ONS = {
VM_IMPORT_CONTROLLER: 'vm-import-controller',
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
KUBEOVN_OPERATOR: 'kubeovn-operator',
FORKLIFT_OPERATOR: 'forklift-operator',
};
export const CSI_SECRETS = {
@@ -89,13 +88,8 @@ export const CSI_SECRETS = {
CSI_NODE_PUBLISH_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-publish-secret-namespace',
CSI_NODE_STAGE_SECRET_NAME: 'csi.storage.k8s.io/node-stage-secret-name',
CSI_NODE_STAGE_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-stage-secret-namespace',
CSI_NODE_EXPAND_SECRET_NAME: 'csi.storage.k8s.io/node-expand-secret-name',
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
};
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
export const FORKLIFT_NAMESPACE = 'forklift';
// Some harvester CRD type is not equal to model file name, define the mapping here
export const HARVESTER_CRD_MAP = {
node: HCI.HOST,
@@ -16,11 +16,9 @@ export const HCI = {
CREATOR: 'harvesterhci.io/creator',
OS: 'harvesterhci.io/os',
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
NETWORK_TYPE: 'network.harvesterhci.io/type',
VM_NAME: 'harvesterhci.io/vmName',
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
VM_DISPLAY_NAME: 'harvesterhci.io/vmDisplayName',
VM_RESERVED_MEMORY: 'harvesterhci.io/reservedMemory',
MAINTENANCE_STATUS: 'harvesterhci.io/maintain-status',
HOST_CUSTOM_NAME: 'harvesterhci.io/host-custom-name',
@@ -81,9 +79,6 @@ export const HCI = {
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address',
STATIC_IP: 'static-ip.harvesterhci.io',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
};
+43 -57
View File
@@ -1,50 +1,47 @@
export const HCI_SETTING = {
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'vlan',
UI_SOURCE: 'ui-source',
UI_PL: 'ui-pl',
HTTP_PROXY: 'http-proxy',
ADDITIONAL_CA: 'additional-ca',
OVERCOMMIT_CONFIG: 'overcommit-config',
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED: 'longhorn-v2-data-engine-hugepage-enabled',
LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE: 'longhorn-v2-data-engine-memory-size',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'vlan',
UI_SOURCE: 'ui-source',
UI_PL: 'ui-pl',
HTTP_PROXY: 'http-proxy',
ADDITIONAL_CA: 'additional-ca',
OVERCOMMIT_CONFIG: 'overcommit-config',
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources'
};
export const HCI_ALLOWED_SETTINGS = {
@@ -114,14 +111,6 @@ export const HCI_ALLOWED_SETTINGS = {
experimental: true,
featureFlag: 'longhornV2LVMSupport'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: {
kind: 'boolean',
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: {
kind: 'number',
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
[HCI_SETTING.UPGRADE_CONFIG]: {
kind: 'json',
@@ -141,9 +130,6 @@ export const HCI_ALLOWED_SETTINGS = {
},
[HCI_SETTING.INSTANCE_MANAGER_RESOURCES]: {
kind: 'json', from: 'import', featureFlag: 'instanceManagerResourcesSetting'
},
[HCI_SETTING.CLUSTER_POD_SECURITY_STANDARD]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'clusterPodSecurityStandardSetting'
}
};
-67
View File
@@ -230,70 +230,3 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
sort: 'status.status',
align: 'left',
};
// ========================================
// Forklift table headers
// ========================================
// Provider type column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_TYPE = {
name: 'providerType',
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
value: 'spec.type',
sort: 'spec.type',
align: 'left',
};
// Provider URL column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_URL = {
name: 'providerUrl',
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
};
// Source provider column in forklift network/storage map list page
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
name: 'sourceProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
value: 'spec.provider.source.name',
sort: 'spec.provider.source.name',
align: 'left',
};
// Destination provider column in forklift network/storage map list page
export const FORKLIFT_MAP_DEST_PROVIDER = {
name: 'destProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
value: 'spec.provider.destination.name',
sort: 'spec.provider.destination.name',
align: 'left',
};
// Target namespace column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_TARGET_NS = {
name: 'targetNamespace',
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
value: 'spec.targetNamespace',
sort: 'spec.targetNamespace',
align: 'left',
};
// VM count column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_VM_COUNT = {
name: 'vmCount',
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'left',
};
// Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan',
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
value: 'spec.plan.name',
sort: 'spec.plan.name',
align: 'left',
};
-6
View File
@@ -46,9 +46,3 @@ export const CDI_POPULATOR_KIND = {
VOLUME_IMPORT_SOURCE: 'VolumeImportSource',
VOLUME_CLONE_SOURCE: 'VolumeCloneSource',
};
export const FILESYSTEM_SOURCE_TYPE = {
CONFIGMAP: 'configmap',
SECRET: 'secret',
SERVICEACCOUNT: 'serviceaccount',
};
@@ -27,11 +27,15 @@ export default {
await allHash({
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
vmis: this.$store.dispatch('harvester/findAll', { type: HCI.VMI }),
vmims: this.$store.dispatch('harvester/findAll', { type: HCI.VMIM }),
allClusterNetwork: this.$store.dispatch('harvester/findAll', { type: HCI.CLUSTER_NETWORK }),
});
},
computed: {
allClusterNetwork() {
return this.$store.getters['harvester/all'](HCI.CLUSTER_NETWORK);
},
rows() {
const vms = this.$store.getters['harvester/all'](HCI.VM);
@@ -104,6 +108,7 @@ export default {
<HarvesterVmState
class="vmstate"
:row="scope.row"
:all-cluster-network="allClusterNetwork"
/>
</div>
</template>
+2 -3
View File
@@ -7,7 +7,6 @@ import { MiniMap } from '@vue-flow/minimap';
import { allHash } from '@shell/utils/promise';
import { NETWORK_ATTACHMENT } from '@shell/config/types';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { HCI } from '../types';
import { NETWORK_TYPE } from '../config/types';
@@ -455,10 +454,10 @@ export default {
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
const clusterNetwork =
nad?.metadata?.labels?.[HCI_ANNOTATIONS.CLUSTER_NETWORK] || 'mgmt';
nad?.metadata?.labels?.[HCI.CLUSTER_NETWORK] || 'mgmt';
const nadType =
nad?.metadata?.labels?.[HCI_ANNOTATIONS.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
nad?.metadata?.labels?.[HCI.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
const overlayNodeId = `overlay-${ provider }`;
nodes.push({
@@ -98,9 +98,9 @@ export default {
methods: {
escapeHtml,
close(data) {
close() {
this.errors = [];
this.$emit('close', data);
this.$emit('close');
},
async apply(buttonDone) {
@@ -109,7 +109,7 @@ export default {
await resource.doActionGrowl(this.modalData.action, {});
}
buttonDone(true);
this.close({ performCallback: true, clearTableSelection: true });
this.close();
} catch (e) {
this.errors = exceptionToErrorsArray(e);
buttonDone(false);
+6 -29
View File
@@ -1,8 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import { Card } from '@components/Card';
import { Checkbox } from '@components/Form/Checkbox';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
@@ -15,9 +13,7 @@ export default {
components: {
AsyncButton,
Banner,
Card,
Checkbox,
},
props: {
@@ -28,16 +24,10 @@ export default {
},
data() {
return { disableResourcePooling: false };
return {};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
disableResourcePoolingEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('disableResourcePooling');
},
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
@@ -62,10 +52,9 @@ export default {
}]
},
spec: {
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName,
disableResourcePooling: this.disableResourcePooling,
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName
}
} );
@@ -96,19 +85,7 @@ export default {
</template>
<template #body>
<p class="mb-20">
{{ t('harvester.pci.enablePassthroughWarning') }}
</p>
<template v-if="disableResourcePoolingEnabled">
<Checkbox
v-model:value="disableResourcePooling"
label-key="harvester.pci.disableResourcePooling"
/>
<Banner
color="info"
:label="t('harvester.pci.disableResourcePoolingDescription')"
/>
</template>
{{ t('harvester.pci.enablePassthroughWarning') }}
</template>
<template #actions>
+3 -26
View File
@@ -5,8 +5,6 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default {
name: 'HarvesterBackupModal',
@@ -17,7 +15,6 @@ export default {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
@@ -30,9 +27,8 @@ export default {
data() {
return {
backUpName: '',
errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
backUpName: '',
errors: []
};
},
@@ -41,22 +37,12 @@ export default {
actionResource() {
return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
}
},
methods: {
close() {
this.backUpName = '';
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.errors = [];
this.$emit('close');
},
@@ -65,7 +51,7 @@ export default {
try {
const res = await this.actionResource.doAction(
'backup',
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
{ name: this.backUpName },
{},
false
);
@@ -114,18 +100,9 @@ export default {
<template #body>
<LabeledInput
v-model:value="backUpName"
class="mt-20"
:label="t('generic.name')"
required
/>
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.backup.fileSystemFreezeDeadline.label')"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
@@ -1,154 +0,0 @@
<script>
import merge from 'lodash/merge';
import jsyaml from 'js-yaml';
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import { LabeledInput } from '@components/Form/LabeledInput';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
const DEFAULT_VALUE = { image: { repository: 'rancher/harvester-nvidia-driver-toolkit' } };
export default {
name: 'HarvesterEnableNvidiaDriverToolkit',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
const addon = this.resources[0];
let valuesContentJson;
try {
valuesContentJson = merge({}, DEFAULT_VALUE, jsyaml.load(addon.spec.valuesContent));
} catch (e) {
valuesContentJson = { ...DEFAULT_VALUE };
}
return { valuesContentJson };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
buttonDisabled() {
const { image, driverLocation } = this.valuesContentJson;
return !(image?.repository || '').trim() || !(image?.tag || '').trim() || !(driverLocation || '').trim();
}
},
methods: {
close() {
this.$emit('close');
},
async enable(buttonCb) {
const addon = this.resources[0];
try {
addon.spec.valuesContent = jsyaml.dump(this.valuesContentJson);
addon.spec.enabled = true;
await addon.save();
buttonCb(true);
this.close();
} catch (err) {
addon.spec.enabled = false;
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(addon.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.addons.nvidiaDriverToolkit.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<div class="row mb-15">
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.image.repository"
:required="true"
:label="t('harvester.addons.nvidiaDriverToolkit.image.repository')"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.image.tag"
:required="true"
:label="t('harvester.addons.nvidiaDriverToolkit.image.tag')"
/>
</div>
</div>
<div class="row mb-15">
<div class="col span-12">
<LabeledInput
v-model:value="valuesContentJson.driverLocation"
:required="true"
:label="t('harvester.addons.nvidiaDriverToolkit.driver.location')"
/>
</div>
</div>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
:disabled="buttonDisabled"
@click="enable"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.body {
display: flex;
flex-direction: column;
min-width: 400px;
}
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -1,12 +1,15 @@
<script>
import { mapGetters } from 'vuex';
import { NODE } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI } from '../types';
export default {
emits: ['close'],
@@ -59,46 +62,28 @@ export default {
return this.resources[0];
},
anyCpuPinning() {
return this.resources.some((r) => r.isCpuPinning);
},
vmi() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vmiResources = this.$store.getters[`${ inStore }/all`](HCI.VMI);
const resource = vmiResources.find((VMI) => VMI.id === this.actionResource?.id) || null;
vmsByNode() {
const groups = {};
for (const r of this.resources) {
const node = r.nodeName || '';
const name = r.nameDisplay || r.name || r.id;
if (!groups[node]) {
groups[node] = [];
}
groups[node].push(name);
}
return Object.entries(groups).map(([node, vms]) => ({ node, vms })).sort((a, b) => a.node.localeCompare(b.node));
return resource;
},
cpuPinningAlertMessage() {
return this.t('harvester.virtualMachine.cpuPinning.migrationMessage');
},
allVmsOnTargetNode() {
if (!this.nodeName) {
return false;
}
return this.resources.every((r) => r.nodeName === this.nodeName);
},
nodeNameList() {
const nodes = this.$store.getters['harvester/all'](NODE);
return nodes.filter((n) => {
const isNotSelfNode = !!this.availableNodes.includes(n.id);
const isNotWitnessNode = n.isEtcd !== 'true'; // do not allow to migrate to self node and witness node
const matchingCpuManagerConfig = !this.anyCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
const isCpuPinning = this.actionResource?.isCpuPinning;
const matchingCpuManagerConfig = !isCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
return isNotWitnessNode && matchingCpuManagerConfig;
return isNotSelfNode && isNotWitnessNode && matchingCpuManagerConfig;
}).map((n) => {
let label = n?.metadata?.name;
const value = n?.metadata?.name;
@@ -117,10 +102,10 @@ export default {
},
methods: {
close(data) {
close() {
this.nodeName = '';
this.errors = [];
this.$emit('close', data);
this.$emit('close');
},
async apply(buttonDone) {
@@ -141,32 +126,10 @@ export default {
}
try {
// Filter out VMs already running on the selected node
const toMigrate = this.resources.filter((r) => r.nodeName !== this.nodeName);
// await Promise.allSettled(toMigrate.map((r) => r.doAction('migrate', { nodeName: this.nodeName }, {}, false)));
// We want to show all migration errors if there are multiple VMs, so we use allSettled here and handle the results accordingly.
const results = await Promise.allSettled(toMigrate.map((r) => r.doAction('migrate', { nodeName: this.nodeName }, {}, false)));
const failedMigrations = results
.map((result, index) => ({ resource: toMigrate[index], result }))
.filter(({ result }) => result.status === 'rejected');
if (failedMigrations.length) {
this['errors'] = failedMigrations.flatMap(({ resource, result }) => {
const vmName = resource?.nameDisplay || resource?.name || resource?.metadata?.name || this.$store.getters['i18n/t']('generic.unknown');
const error = result.reason?.data || result.reason;
const messages = exceptionToErrorsArray(error);
return messages.map((message) => `${ vmName }: ${ message }`);
});
buttonDone(false);
return;
}
await this.actionResource.doAction('migrate', { nodeName: this.nodeName }, {}, false);
buttonDone(true);
this.close({ performCallback: true, clearTableSelection: true });
this.close();
} catch (err) {
const error = err?.data || err;
const message = exceptionToErrorsArray(error);
@@ -183,35 +146,17 @@ export default {
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.migration.vmMigrationTitle', { count: resources.length }) }}
{{ t('harvester.modal.migration.title') }}
</template>
<template #body>
<Banner
v-if="anyCpuPinning"
v-if="actionResource?.isCpuPinning"
color="warning"
:label="cpuPinningAlertMessage"
/>
<p>
{{ t('harvester.modal.migration.selectedVMs') }}
</p>
<ul class="vm-list">
<li
v-for="group in vmsByNode"
:key="group.node"
>
{{ group.node || t('harvester.modal.migration.unknownNode') }}: {{ group.vms.join(', ') }}
<span
v-if="nodeName && group.node === nodeName"
class="already-on-target"
>
({{ t('harvester.modal.migration.alreadyOnTarget') }})
</span>
</li>
</ul>
<LabeledSelect
v-model:value="nodeName"
class="mt-15"
:label="t('harvester.modal.migration.fields.nodeName.label')"
:placeholder="t('harvester.modal.migration.fields.nodeName.placeholder')"
:options="nodeNameList"
@@ -238,7 +183,7 @@ export default {
<AsyncButton
mode="apply"
:disabled="!nodeName || allVmsOnTargetNode"
:disabled="!nodeName"
@click="apply"
/>
</div>
@@ -256,16 +201,4 @@ export default {
justify-content: flex-end;
width: 100%;
}
.already-on-target {
color: var(--warning);
font-style: italic;
}
.vm-list {
list-style: disc;
padding-left: 1.5em;
margin-bottom: 10px;
margin-top: 10px;
}
</style>
@@ -5,10 +5,8 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI } from '../types';
import { BACKUP_TYPE } from '../config/types';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default {
name: 'HarvesterVMSnapshotDialog',
@@ -19,7 +17,6 @@ export default {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
@@ -34,8 +31,7 @@ export default {
return {
snapshotName: '',
snapshotNamespace: '',
errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
errors: []
};
},
@@ -44,23 +40,13 @@ export default {
actionResource() {
return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
}
},
methods: {
close() {
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.snapshotNamespace = '';
this.snapshotName = '';
this.errors = [];
this.$emit('close');
},
@@ -74,8 +60,7 @@ export default {
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
},
spec: {
fsFreezeDeadline: this.fsFreezeDeadline,
source: {
source: {
apiGroup: 'kubevirt.io',
kind: 'VirtualMachine',
name: this.actionResource.metadata.name
@@ -147,14 +132,6 @@ export default {
:label="t('generic.name')"
required
/>
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.vmSnapshot.fileSystemFreezeDeadline.label')"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
@@ -1,24 +0,0 @@
<script>
import { h } from 'vue';
import { PRODUCT_NAME } from '../config/harvester';
export default {
name: 'ForkliftProviderEdit',
created() {
const { namespace, id } = this.$route.params;
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
this.$router.replace({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$route.params.product,
cluster: this.$route.params.cluster,
},
query,
});
},
render: () => h('div'),
};
</script>
@@ -108,7 +108,6 @@ export default {
<YamlEditor
ref="yamlUser"
v-model:value="config"
:mode="mode"
class="yaml-editor"
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
@onChanges="update"
@@ -468,15 +468,10 @@ export default {
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
const parentDevice = d.status?.deviceStatus?.parentDevice;
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
const mountPoint = d.status?.deviceStatus?.fileSystem?.mountPoint;
const name = d.displayName;
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
if (mountPoint) {
label = `${ label } - ${ this.t('harvester.host.disk.mounted') }`;
}
if (parentDevice) {
label = `- ${ label }`;
}
@@ -486,7 +481,7 @@ export default {
value: d.id,
action: this.addDisk,
kind: !parentDevice ? 'group' : '',
disabled: !!isChildAdded || !!mountPoint,
disabled: !!isChildAdded,
group: parentDevice || devPath,
isParent: !!parentDevice,
};
+2 -10
View File
@@ -8,7 +8,6 @@ import FileSelector, { createOnSelected } from '@shell/components/form/FileSelec
import { randomStr } from '@shell/utils/string';
import CreateEditView from '@shell/mixins/create-edit-view';
import { getLoginAwareErrors } from '../utils/error';
export default {
name: 'HarvesterEditKeypair',
@@ -64,14 +63,6 @@ export default {
}
},
computed: {
normalizedErrors() {
const message = this.t('harvester.virtualMachine.genericLoginError');
return getLoginAwareErrors(this.errors, message);
}
},
methods: { onKeySelected: createOnSelected('publicKey') },
};
</script>
@@ -81,9 +72,10 @@ export default {
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="normalizedErrors"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<div class="header mb-20">
<FileSelector
@@ -9,7 +9,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { NAMESPACE, NODE } from '@shell/config/types';
import { HCI } from '../types';
import { NETWORK_TYPE, L2VLAN_MODE } from '../config/types';
import { removeObject } from '@shell/utils/array';
@@ -21,7 +20,6 @@ const { ACCESS, TRUNK } = L2VLAN_MODE;
const AUTO = 'auto';
const MANUAL = 'manual';
const KUBE_SYSTEM = 'kube-system';
export default {
emits: ['update:value'],
@@ -72,12 +70,7 @@ export default {
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
});
await allHash({ clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }) });
},
created() {
@@ -206,80 +199,6 @@ export default {
}
return this.type === UNTAGGED;
},
showNicsTab() {
return this.isOverlayNetwork && this.value.metadata.namespace === KUBE_SYSTEM;
},
namespaceOptions() {
const ns = this.$store.getters['harvester/all'](NAMESPACE) || [];
// Allow users to select the "kube-system" namespace as the external subnet from Kube-OVN.
// This expects the provider network to be in the "kube-system" namespace for VPC NAT gateway functionality.
return ns
.filter((ns) => !ns.isSystem || ns.id === KUBE_SYSTEM)
.map((ns) => ({ name: ns.id }))
.sort((a, b) => a.name.localeCompare(b.name));
},
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.filter((n) => n.isEtcd !== 'true');
},
nics() {
const inStore = this.$store.getters['currentProduct'].inStore;
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
const linkStatus = linkMonitor?.status?.linkStatus || {};
const nodes = this.nodes.map((n) => n.id);
const out = [];
// Collect all nics from all nodes (for overlay, we select from all nodes)
Object.keys(linkStatus).map((nodeName) => {
if (nodes.includes(nodeName)) {
const nics = linkStatus[nodeName] || [];
nics.map((nic) => {
out.push({
...nic,
nodeName,
});
});
}
});
return out;
},
nicOptions() {
const out = [];
const seen = new Set();
(this.nics || []).forEach((nic) => {
if (!seen.has(nic.name)) {
seen.add(nic.name);
out.push({
label: nic.name,
value: nic.name,
});
}
});
return out.sort((a, b) => a.label.localeCompare(b.label));
},
master: {
get() {
return this.config?.master || '';
},
set(value) {
this.config.master = value;
}
}
},
@@ -295,7 +214,6 @@ export default {
this.config.ipam = {};
this.config.bridge = '';
delete this.config.provider;
delete this.config.master;
delete this.config.server_socket;
}
},
@@ -312,13 +230,6 @@ export default {
this.config.vlan = '';
}
},
'value.metadata.namespace'(newNamespace) {
// NIC selection is only valid for overlay in kube-system namespace.
if (newNamespace !== KUBE_SYSTEM) {
delete this.config.master;
this.value.spec.config = JSON.stringify({ ...this.config });
}
},
},
methods: {
@@ -413,10 +324,6 @@ export default {
delete this.config.promiscMode;
delete this.config.vlan;
delete this.config.ipam;
if (this.value.metadata.namespace !== KUBE_SYSTEM) {
delete this.config.master;
}
}
if (this.isUntaggedNetwork) {
@@ -443,7 +350,6 @@ export default {
ref="nd"
:value="value"
:mode="mode"
:namespace-options="namespaceOptions"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
@@ -615,25 +521,6 @@ export default {
</div>
</div>
</Tab>
<Tab
v-if="showNicsTab"
name="nics"
:label="t('harvester.network.tabs.nic')"
:weight="97"
class="bordered-table"
>
<div class="row mt-10">
<div class="col span-12">
<LabeledSelect
v-model:value="master"
:label="t('harvester.vlanConfig.uplink.nics.label')"
:placeholder="t('harvester.vlanConfig.uplink.nics.overlayWarning')"
:mode="mode"
:options="nicOptions"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -3,9 +3,8 @@ import KeyValue from '@shell/components/form/KeyValue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import RadioGroup from '@components/Form/Radio/RadioGroup';
import Checkbox from '@components/Form/Checkbox/Checkbox';
import { SECRET, LONGHORN } from '@shell/config/types';
import { _CREATE, _VIEW, _EDIT } from '@shell/config/query-params';
import { _CREATE, _VIEW } from '@shell/config/query-params';
import { CSI_SECRETS } from '@pkg/harvester/config/harvester-map';
import { clone } from '@shell/utils/object';
import { uniq } from '@shell/utils/array';
@@ -17,9 +16,7 @@ const {
CSI_NODE_PUBLISH_SECRET_NAME,
CSI_NODE_PUBLISH_SECRET_NAMESPACE,
CSI_NODE_STAGE_SECRET_NAME,
CSI_NODE_STAGE_SECRET_NAMESPACE,
CSI_NODE_EXPAND_SECRET_NAME,
CSI_NODE_EXPAND_SECRET_NAMESPACE
CSI_NODE_STAGE_SECRET_NAMESPACE
} = CSI_SECRETS;
export default {
@@ -30,7 +27,6 @@ export default {
LabeledSelect,
LabeledInput,
RadioGroup,
Checkbox,
},
props: {
@@ -61,10 +57,7 @@ export default {
};
}
const hasExpandSecret = !!(this.value.parameters?.[CSI_NODE_EXPAND_SECRET_NAME] && this.value.parameters?.[CSI_NODE_EXPAND_SECRET_NAMESPACE]);
const volumeExpansionCheckBoxEnabled = this.realMode === _CREATE ? true : hasExpandSecret;
return { volumeExpansionCheckBoxEnabled };
return { };
},
computed: {
@@ -105,11 +98,8 @@ export default {
}, []);
},
isEdit() {
return this.realMode === _EDIT;
},
isView() {
return this.realMode === _VIEW;
return this.mode === _VIEW;
},
migratableOptions() {
@@ -162,10 +152,6 @@ export default {
}
},
enableOnlineExpansionVolumeEncryption() {
return this.value.expandOnlineEncryptedVolumeFeatureEnabled;
},
volumeEncryption: {
set(neu) {
this.value['parameters'] = {
@@ -194,11 +180,6 @@ export default {
set(selectedSecret) {
const [namespace, name] = selectedSecret.split('/');
const expandSecretParams = (this.enableOnlineExpansionVolumeEncryption && this.volumeExpansionCheckBoxEnabled) ? {
[CSI_NODE_EXPAND_SECRET_NAME]: name,
[CSI_NODE_EXPAND_SECRET_NAMESPACE]: namespace,
} : {};
this.value['parameters'] = {
...this.value.parameters,
[CSI_PROVISIONER_SECRET_NAME]: name,
@@ -206,8 +187,7 @@ export default {
[CSI_NODE_STAGE_SECRET_NAME]: name,
[CSI_PROVISIONER_SECRET_NAMESPACE]: namespace,
[CSI_NODE_PUBLISH_SECRET_NAMESPACE]: namespace,
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace,
...expandSecretParams,
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace
};
}
},
@@ -260,32 +240,6 @@ export default {
}
},
},
watch: {
volumeExpansionCheckBoxEnabled(enabled) {
const currentSecret = this.secret;
if (!currentSecret) {
return;
}
const [namespace, name] = currentSecret.split('/');
if (enabled && this.enableOnlineExpansionVolumeEncryption) {
this.value['parameters'] = {
...this.value.parameters,
[CSI_NODE_EXPAND_SECRET_NAME]: name,
[CSI_NODE_EXPAND_SECRET_NAMESPACE]: namespace,
};
} else {
const params = { ...this.value.parameters };
delete params[CSI_NODE_EXPAND_SECRET_NAME];
delete params[CSI_NODE_EXPAND_SECRET_NAMESPACE];
this.value['parameters'] = params;
}
},
},
};
</script>
<template>
@@ -383,16 +337,6 @@ export default {
:mode="mode"
/>
</div>
<div
v-if="enableOnlineExpansionVolumeEncryption"
class="col span-6 flex items-center mt-20"
>
<Checkbox
v-model:value="volumeExpansionCheckBoxEnabled"
:label="t('harvester.storage.volumeExpansionCheckbox')"
:disabled="isEdit || isView"
/>
</div>
</div>
</template>
<KeyValue
@@ -254,24 +254,15 @@ export default {
v-if="restoreNewVm"
v-model:value="restoreResource.spec.keepMacAddress"
type="checkbox"
class="check mb-20"
:label="t('harvester.backup.restore.keepMacAddress')"
/>
<LabeledSelect
v-if="!restoreNewVm"
v-model:value="deletionPolicy"
class="mb-20"
:label="t('harvester.backup.restore.deletePreviousVolumes')"
:options="deletionPolicyOption"
/>
<Checkbox
v-model:value="restoreResource.spec.haltAfterRestore"
type="checkbox"
class="check mb-20"
:label="t('harvester.backup.restore.haltAfterRestore')"
/>
</div>
<Footer
@@ -26,7 +26,6 @@ import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem';
export default {
name: 'HarvesterEditVMTemplate',
@@ -52,7 +51,6 @@ export default {
UnitInput,
Banner,
KeyValue,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
@@ -97,10 +95,6 @@ export default {
secretNamePrefix() {
return this.templateValue?.metadata?.name;
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@@ -160,7 +154,6 @@ export default {
mounted() {
this.imageId = this.diskRows[0]?.image || '';
this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm);
},
methods: {
@@ -356,19 +349,6 @@ export default {
</template>
</Tab>
<Tab
v-if="filesystemEnabled"
name="filesystem"
:label="t('harvester.tab.filesystem')"
:weight="-8"
>
<Filesystem
v-model:value="filesystemRows"
:mode="mode"
:namespace="templateValue.metadata.namespace"
/>
</Tab>
<Tab
name="labels"
:label="t('generic.labels')"
@@ -2,7 +2,6 @@
import Footer from '@shell/components/form/Footer';
import { RadioGroup } from '@components/Form/Radio';
import { LabeledInput } from '@components/Form/LabeledInput';
import Checkbox from '@components/Form/Checkbox/Checkbox';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
@@ -33,7 +32,6 @@ const createObject = {
export default {
name: 'CreateRestore',
components: {
Checkbox,
Footer,
RadioGroup,
LabeledInput,
@@ -251,17 +249,9 @@ export default {
<LabeledSelect
v-if="!restoreNewVm"
v-model:value="deletionPolicy"
class="mb-20"
:label="t('harvester.backup.restore.deletePreviousVolumes')"
:options="deletionPolicyOption"
/>
<Checkbox
v-model:value="restoreResource.spec.haltAfterRestore"
type="checkbox"
class="check mb-20"
:label="t('harvester.backup.restore.haltAfterRestore')"
/>
</div>
<Footer
@@ -1,190 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
eip: this.value?.spec?.eip || '',
externalPort: this.value?.spec?.externalPort || '',
internalIp: this.value?.spec?.internalIp || '',
internalPort: this.value?.spec?.internalPort || '',
protocol: this.value?.spec?.protocol || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
eipOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
return eips.map((eip) => ({
label: eip.id,
value: eip.id,
}));
},
protocolOptions() {
return [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
];
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.eip = this.eip;
this.value.spec.externalPort = this.externalPort;
this.value.spec.internalIp = this.internalIp;
this.value.spec.internalPort = this.internalPort;
this.value.spec.protocol = this.protocol;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="eip"
class="mb-20"
:options="eipOptions"
:mode="mode"
:label="t('harvester.dnat.eip.label')"
:placeholder="t('harvester.dnat.eip.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="externalPort"
class="mb-20"
:mode="mode"
:label="t('harvester.dnat.externalPort.label')"
:placeholder="t('harvester.dnat.externalPort.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalIp"
class="mb-20"
:mode="mode"
:label="t('harvester.dnat.internalIp.label')"
:placeholder="t('harvester.dnat.internalIp.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalPort"
class="mb-20"
:mode="mode"
:label="t('harvester.dnat.internalPort.label')"
:placeholder="t('harvester.dnat.internalPort.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="protocol"
class="mb-20"
:options="protocolOptions"
:mode="mode"
:label="t('harvester.dnat.protocol.label')"
:placeholder="t('harvester.dnat.protocol.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -1,168 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
natGwDp: this.value?.spec?.natGwDp || '',
externalSubnet: this.value?.spec?.externalSubnet || '',
v4ip: this.value?.spec?.v4ip || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
natGateways: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC_NAT_GATEWAY }),
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
natGatewayOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const natGateways = this.$store.getters[`${ inStore }/all`](HCI.VPC_NAT_GATEWAY) || [];
return natGateways.map((gw) => ({
label: gw.id,
value: gw.id,
}));
},
subnetOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const subnets = this.$store.getters[`${ inStore }/all`](HCI.SUBNET) || [];
return subnets.map((subnet) => ({
label: subnet.id,
value: subnet.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.natGwDp = this.natGwDp;
this.value.spec.externalSubnet = this.externalSubnet;
this.value.spec.v4ip = this.v4ip;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="natGwDp"
class="mb-20"
:options="natGatewayOptions"
:mode="mode"
:label="t('harvester.externalIP.natGateway.label')"
:placeholder="t('harvester.externalIP.natGateway.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="externalSubnet"
class="mb-20"
:options="subnetOptions"
:label="t('harvester.externalIP.externalSubnet.label')"
:placeholder="t('harvester.externalIP.externalSubnet.placeholder')"
:mode="mode"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="v4ip"
class="mb-20"
:label="t('harvester.externalIP.v4ip.label')"
:placeholder="t('harvester.externalIP.v4ip.placeholder')"
:mode="mode"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -1,140 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
eip: this.value?.spec?.eip || '',
internalCIDR: this.value?.spec?.internalCIDR || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
eipOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
return eips.map((eip) => ({
label: eip.id,
value: eip.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.eip = this.eip;
this.value.spec.internalCIDR = this.internalCIDR;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="eip"
class="mb-20"
:options="eipOptions"
:mode="mode"
:label="t('harvester.snat.eip.label')"
:placeholder="t('harvester.snat.eip.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalCIDR"
class="mb-20"
:mode="mode"
:label="t('harvester.snat.internalCIDR.label')"
:placeholder="t('harvester.snat.internalCIDR.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -1,384 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import ArrayList from '@shell/components/form/ArrayList';
import InfoBox from '@shell/components/InfoBox';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { NODE } from '@shell/config/types';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
ArrayList,
InfoBox,
NameNsDescription,
ResourceTabs,
Tab,
LabeledSelect,
ArrayListSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
defaultInterface: this.value?.spec?.defaultInterface || '',
excludedNodes: this.value?.spec?.excludeNodes || [],
customInterfaces: this.value?.spec?.customInterfaces || [],
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.filter((n) => n.isEtcd !== 'true');
},
nics() {
const inStore = this.$store.getters['currentProduct'].inStore;
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
const linkStatus = linkMonitor?.status?.linkStatus || {};
const nodes = this.nodes.map((n) => n.id);
const out = [];
// Collect all nics from all nodes
Object.keys(linkStatus).map((nodeName) => {
if (nodes.includes(nodeName)) {
const nics = linkStatus[nodeName] || [];
nics.map((nic) => {
out.push({
...nic,
nodeName,
});
});
}
});
return out;
},
nicOptions() {
const out = [];
const seen = new Set();
(this.nics || []).forEach((nic) => {
if (!seen.has(nic.name)) {
seen.add(nic.name);
out.push({
label: nic.name,
value: nic.name,
});
}
});
return out.sort((a, b) => a.label.localeCompare(b.label));
},
nodeOptions() {
return this.nodes.map((node) => ({
label: node.id,
value: node.id,
}));
},
},
methods: {
removeCustomInterface(index) {
this.customInterfaces.splice(index, 1);
},
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.defaultInterface = this.defaultInterface;
this.value.spec.excludeNodes = this.excludedNodes;
this.value.spec.customInterfaces = (this.customInterfaces || [])
.filter((item) => item?.interface || (item?.nodes || []).length)
.map((item) => ({
interface: item.interface || '',
nodes: (item.nodes || []).filter((node) => !!node),
}))
.filter((item) => item.interface && item.nodes.length > 0);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="Interfaces"
label="Interfaces"
:weight="99"
>
<LabeledSelect
v-model:value="defaultInterface"
class="mb-20"
required
:options="nicOptions"
:mode="mode"
:label="t('harvester.providerNetwork.defaultInterface.label')"
:placeholder="t('harvester.providerNetwork.defaultInterface.placeholder')"
/>
<hr class="section-divider" />
<ArrayList
v-model:value="customInterfaces"
class="mb-20 custom-interface-list"
:mode="mode"
:title="t('harvester.providerNetwork.customInterfaces.label')"
:protip="false"
:remove-allowed="false"
:initial-empty-row="true"
:default-add-value="{ interface: '', nodes: [] }"
>
<template #add="{ add }">
<div class="custom-interface-primary-add">
<button
type="button"
class="btn role-primary"
:disabled="mode === 'view'"
@click="add"
>
{{ t('harvester.providerNetwork.customInterfaces.addLabel') }}
</button>
</div>
</template>
<template #column-headers>
<div class="row custom-interface-header">
<div class="col span-6">
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
</div>
<div class="col span-6">
{{ t('harvester.providerNetwork.customInterfaces.nodes.label') }}
</div>
</div>
</template>
<template #columns="scope">
<InfoBox class="custom-interface-box">
<button
v-if="mode !== 'view'"
type="button"
class="role-link btn btn-sm remove"
@click="removeCustomInterface(scope.i)"
>
<i class="icon icon-x" />
</button>
<div class="custom-interface-content">
<div class="row custom-interface-row interface-row">
<div class="col span-12 interface-col">
<h3 class="mb-10">
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
</h3>
<LabeledSelect
v-model:value="scope.row.value.interface"
class="mb-20"
:label="''"
:options="nicOptions"
:mode="mode"
:placeholder="t('harvester.providerNetwork.customInterfaces.interface.placeholder')"
/>
</div>
</div>
<div class="row custom-interface-row nodes-row">
<div class="col span-12">
<ArrayListSelect
v-model:value="scope.row.value.nodes"
:options="nodeOptions"
:mode="mode"
:disabled="mode === 'view'"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.providerNetwork.customInterfaces.nodes.addLabel'),
initialEmptyRow: true,
title: t('harvester.providerNetwork.customInterfaces.nodes.label'),
required: false,
protip: false,
}"
:select-props="{
placeholder: t('harvester.providerNetwork.customInterfaces.nodes.placeholder'),
disabled: mode === 'view',
}"
>
<template #add="{ add }">
<div class="custom-interface-add">
<button
type="button"
class="btn role-tertiary add"
:disabled="mode === 'view'"
@click="add"
>
{{ t('harvester.providerNetwork.customInterfaces.nodes.addLabel') }}
</button>
</div>
</template>
</ArrayListSelect>
</div>
</div>
</div>
</InfoBox>
</template>
</ArrayList>
</Tab>
<Tab
name="excludedNodes"
:label="t('harvester.providerNetwork.excludedNodes.label')"
:weight="98"
>
<div class="row">
<div class="col span-12">
<ArrayListSelect
v-model:value="excludedNodes"
:options="nodeOptions"
:disabled="mode === 'view'"
:mode="mode"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.providerNetwork.excludedNodes.addLabel'),
initialEmptyRow: true,
required: false,
protip: false,
}"
:select-props="{
placeholder: t('harvester.providerNetwork.excludedNodes.placeholder'),
disabled: mode === 'view',
}"
/>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
<style lang="scss" scoped>
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0 20px;
}
.custom-interface-header {
margin-bottom: 10px;
font-weight: 600;
}
.custom-interface-row {
align-items: flex-start;
}
.interface-row {
width: calc(100% - 90px);
margin-bottom: 10px;
}
.nodes-row {
align-items: flex-start;
}
.custom-interface-add {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
:deep(.nodes-row .array-list-select .box) {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
:deep(.nodes-row .array-list-select .box .remove) {
align-self: center;
}
.custom-interface-primary-add {
max-width: 100%;
}
.custom-interface-box {
position: relative;
width: 100%;
padding: 20px;
margin-bottom: 5px;
}
:deep(.custom-interface-list .box) {
grid-template-columns: 1fr;
}
.remove {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
padding: 0;
}
</style>
+9 -50
View File
@@ -43,20 +43,18 @@ export default {
created() {
const vpc = this.$route.query.vpc || '';
const enableDHCP = this.value?.spec?.enableDHCP || false;
const natOutgoing = this.value?.spec?.natOutgoing || false;
set(this.value.spec, 'enableDHCP', enableDHCP);
set(this.value, 'spec', this.value.spec || {
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
vpc,
gateway: '',
excludeIps: [],
private: false,
gatewayIP: '',
excludeIps: [],
private: false,
enableDHCP,
natOutgoing,
acls: []
acls: []
});
},
@@ -64,9 +62,8 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vlans: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN }),
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
};
await allHash(hash);
@@ -132,20 +129,6 @@ export default {
label: n.id,
value: n.id,
}));
},
natOutgoingDisabled() {
// Disable the NAT Outgoing option when the subnet belongs to the ovn-cluster VPC and its name is join or ovn-default.
return this.value?.spec?.vpc === 'ovn-cluster' && ['join', 'ovn-default'].includes(this.value?.metadata?.name);
},
vlanOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
return vlans.map((vlan) => ({
label: vlan.id,
value: vlan.id,
}));
}
},
@@ -281,16 +264,6 @@ export default {
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.vlan"
class="mb-20"
:options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')"
:mode="mode"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
@@ -331,20 +304,6 @@ export default {
</Banner>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
v-model:value="value.spec.natOutgoing"
name="enableExternalConnectivity"
:disabled="natOutgoingDisabled"
:options="[true, false]"
:label="t('harvester.subnet.externalConnectivity.label')"
:labels="[t('generic.enabled'), t('generic.disabled')]"
:mode="mode"
:tooltip="t('harvester.subnet.externalConnectivity.tooltip')"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
-146
View File
@@ -1,146 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
vlanId: this.value?.spec?.id || '',
provider: this.value?.spec?.provider || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ providerNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.PROVIDER_NETWORK }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
providerNetworks() {
const inStore = this.$store.getters['currentProduct'].inStore;
const providerNetworks = this.$store.getters[`${ inStore }/all`](HCI.PROVIDER_NETWORK) || [];
return providerNetworks.map((pn) => ({
label: pn.id,
value: pn.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
if (this.vlanId !== '') {
this.value.spec.id = Number(this.vlanId);
}
this.value.spec.provider = this.provider;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value.number="vlanId"
class="mb-20"
type="number"
:min="1"
:max="4094"
:label="t('harvester.vlan.id.label')"
:placeholder="t('harvester.vlan.id.placeholder')"
:mode="mode"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="provider"
class="mb-20"
:options="providerNetworks"
:mode="mode"
:label="t('harvester.vlan.provider.label')"
:placeholder="t('harvester.vlan.provider.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -1,246 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI as HCI_ANNOTATIONS } from '@pkg/config/labels-annotations';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
ArrayListSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
const internalTenantNetwork = this.value?.metadata?.annotations?.[HCI_ANNOTATIONS.CNI_NETWORKS] || '';
return {
internalTenantNetwork,
vpc: this.value?.spec?.vpc || '',
subnet: this.value?.spec?.subnet || '',
lanIp: this.value?.spec?.lanIp || '',
externalSubnets: this.value?.spec?.externalSubnets || [],
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
vpcs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
vmNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.NETWORK_ATTACHMENT }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
vpcOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vpcs = this.$store.getters[`${ inStore }/all`](HCI.VPC) || [];
return vpcs.map((vpc) => ({
label: vpc.id,
value: vpc.id,
}));
},
subnetOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const subnets = this.$store.getters[`${ inStore }/all`](HCI.SUBNET) || [];
return subnets.map((subnet) => ({
label: subnet.id,
value: subnet.id,
}));
},
vmNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vmNetworks = this.$store.getters[`${ inStore }/all`](HCI.NETWORK_ATTACHMENT) || [];
return vmNetworks.map((network) => ({
label: network.id,
value: network.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
if (!this.value.metadata) {
this.value.metadata = {};
}
if (!this.value.metadata.annotations) {
this.value.metadata.annotations = {};
}
this.value.spec.vpc = this.vpc;
this.value.spec.subnet = this.subnet;
this.value.spec.lanIp = this.lanIp;
this.value.spec.externalSubnets = (this.externalSubnets || []).filter((subnet) => !!subnet);
if (this.internalTenantNetwork) {
this.value.metadata.annotations[HCI_ANNOTATIONS.CNI_NETWORKS] = this.internalTenantNetwork;
} else {
delete this.value.metadata.annotations[HCI_ANNOTATIONS.CNI_NETWORKS];
}
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="internalTenantNetwork"
class="mb-20"
required
:options="vmNetworkOptions"
:mode="mode"
:label="t('harvester.natGateway.internalTenantNetwork.label')"
:placeholder="t('harvester.natGateway.internalTenantNetwork.placeholder')"
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="vpc"
class="mb-20"
:options="vpcOptions"
:mode="mode"
:label="t('harvester.natGateway.vpc.label')"
:placeholder="t('harvester.natGateway.vpc.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="subnet"
class="mb-20"
:options="subnetOptions"
:mode="mode"
:label="t('harvester.natGateway.subnet.label')"
:placeholder="t('harvester.natGateway.subnet.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="lanIp"
class="mb-20"
:mode="mode"
:label="t('harvester.natGateway.lanIp.label')"
:placeholder="t('harvester.natGateway.lanIp.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
<Tab
name="externalSubnets"
:label="t('harvester.natGateway.externalSubnets.label')"
:weight="98"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<ArrayListSelect
v-model:value="externalSubnets"
:mode="mode"
:disabled="mode === 'view'"
required
:options="subnetOptions"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.natGateway.externalSubnets.addLabel'),
title: t('harvester.natGateway.subnet.label'),
initialEmptyRow: true,
required: true,
protip: false,
}"
:select-props="{
placeholder: t('harvester.natGateway.externalSubnets.placeholder'),
disabled: mode === 'view',
}"
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -143,7 +143,6 @@ export default {
<YamlEditor
ref="yaml"
v-model:value="yamlScript"
:mode="mode"
class="yaml-editor"
:editor-mode="editorMode"
/>
@@ -1,310 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { CONFIG_MAP, SECRET, SERVICE_ACCOUNT } from '@shell/config/types';
import { _VIEW } from '@shell/config/query-params';
import CopyToClipboard from '@shell/components/CopyToClipboard';
import MessageLink from '@shell/components/MessageLink';
import { FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const MAX_FILESYSTEMS = 3;
const { CONFIGMAP: FS_TYPE_CONFIGMAP, SECRET: FS_TYPE_SECRET, SERVICEACCOUNT: FS_TYPE_SERVICEACCOUNT } = FILESYSTEM_SOURCE_TYPE;
const DEFAULT_VOLUME_NAMES = {
[FS_TYPE_CONFIGMAP]: 'appconfigfs',
[FS_TYPE_SECRET]: 'appsecretfs',
[FS_TYPE_SERVICEACCOUNT]: 'appserviceaccountfs',
};
const FS_TYPE_OPTIONS = [
{ label: 'ConfigMap', value: FS_TYPE_CONFIGMAP },
{ label: 'Secret', value: FS_TYPE_SECRET },
{ label: 'ServiceAccount', value: FS_TYPE_SERVICEACCOUNT },
];
function emptyRow() {
return {
fsType: FS_TYPE_CONFIGMAP,
volumeName: DEFAULT_VOLUME_NAMES[FS_TYPE_CONFIGMAP],
resourceName: '',
};
}
export default {
name: 'VirtualMachineFilesystem',
emits: ['update:value'],
components: {
Banner,
LabeledSelect,
LabeledInput,
CopyToClipboard,
MessageLink,
},
props: {
mode: {
type: String,
default: 'create',
},
namespace: {
type: String,
default: '',
},
value: {
type: Array,
default: () => [],
},
},
data() {
return { rows: this.value.length > 0 ? this.value.map((r) => ({ ...r })) : [emptyRow()] };
},
watch: {
value(newVal) {
if (newVal) {
const incoming = JSON.stringify(newVal);
const current = JSON.stringify(this.rows);
if (incoming !== current) {
this.rows = newVal.map((r) => ({ ...r }));
}
}
},
rows: {
deep: true,
handler(val) {
this.$emit('update:value', val.map((r) => ({ ...r })));
},
},
},
computed: {
...mapGetters({ t: 'i18n/t' }),
inStore() {
return this.$store.getters['currentProduct'].inStore;
},
configMaps() {
return this.$store.getters[`${ this.inStore }/all`](CONFIG_MAP)
.filter((cm) => !this.namespace || cm.metadata.namespace === this.namespace)
.map((cm) => ({ label: cm.metadata.name, value: cm.metadata.name }));
},
secrets() {
return this.$store.getters[`${ this.inStore }/all`](SECRET)
.filter((s) => !this.namespace || s.metadata.namespace === this.namespace)
.map((s) => ({ label: s.metadata.name, value: s.metadata.name }));
},
serviceAccounts() {
return this.$store.getters[`${ this.inStore }/all`](SERVICE_ACCOUNT)
.filter((sa) => !this.namespace || sa.metadata.namespace === this.namespace)
.map((sa) => ({ label: sa.metadata.name, value: sa.metadata.name }));
},
canAddRow() {
return this.rows.length < MAX_FILESYSTEMS;
},
isView() {
return this.mode === _VIEW;
},
completedRows() {
return this.rows.filter((r) => r.fsType && r.volumeName && r.resourceName);
},
allMountCommands() {
return this.completedRows.map((r) => this.mountCommands(r)).join('\n');
},
},
methods: {
fsTypeOptions(currentIndex) {
const usedTypes = this.rows
.filter((_, i) => i !== currentIndex)
.map((r) => r.fsType);
return FS_TYPE_OPTIONS.filter((opt) => !usedTypes.includes(opt.value));
},
resourceOptions(fsType) {
if (fsType === FS_TYPE_CONFIGMAP) return this.configMaps;
if (fsType === FS_TYPE_SECRET) return this.secrets;
if (fsType === FS_TYPE_SERVICEACCOUNT) return this.serviceAccounts;
return [];
},
onFsTypeChange(row, newType) {
row.fsType = newType;
row.volumeName = DEFAULT_VOLUME_NAMES[newType] || '';
row.resourceName = '';
},
addRow() {
if (this.canAddRow) {
const usedTypes = this.rows.map((r) => r.fsType);
const nextType = FS_TYPE_OPTIONS.find((opt) => !usedTypes.includes(opt.value))?.value || FS_TYPE_CONFIGMAP;
this.rows.push({
fsType: nextType,
volumeName: DEFAULT_VOLUME_NAMES[nextType] || '',
resourceName: '',
});
}
},
removeRow(index) {
this.rows.splice(index, 1);
},
mountCommands(row) {
const vol = row.volumeName || '<volume-name>';
return `- mkdir -p /mnt/${ vol }\n- mount -t virtiofs ${ vol } /mnt/${ vol }`;
},
},
};
</script>
<template>
<div class="vm-filesystem">
<p class="mb-20">
{{ t('harvester.virtualMachine.filesystem.description') }}
</p>
<div
v-for="(row, index) in rows"
:key="index"
class="filesystem-row mb-15"
>
<div class="row">
<div class="col span-3">
<LabeledSelect
:value="row.fsType"
:label="t('harvester.virtualMachine.filesystem.type')"
:options="fsTypeOptions(index)"
:mode="mode"
required
@update:value="onFsTypeChange(row, $event)"
/>
</div>
<div class="col span-3">
<LabeledInput
v-model:value="row.volumeName"
:label="t('harvester.virtualMachine.filesystem.volume')"
:mode="mode"
required
/>
</div>
<div class="col span-5">
<LabeledSelect
v-model:value="row.resourceName"
:label="t('harvester.virtualMachine.filesystem.resource')"
:options="resourceOptions(row.fsType)"
:mode="mode"
required
/>
</div>
<div
v-if="!isView"
class="col span-1 remove-col"
>
<button
type="button"
class="btn role-link remove-btn"
@click="removeRow(index)"
>
{{ t('generic.remove') }}
</button>
</div>
</div>
</div>
<Banner
v-if="completedRows.length > 0 && mode === 'create'"
color="warning"
class="mt-10"
>
<div>
<MessageLink
:to="{ hash: '#advanced' }"
prefix-label="harvester.virtualMachine.filesystem.mountBannerHint"
middle-label="harvester.virtualMachine.filesystem.mountBannerHintLink"
suffix-label="harvester.virtualMachine.filesystem.mountBannerHintSuffix"
/>
<div class="pre-wrapper mt-10">
<pre class="mt-5 mb-0">{{ allMountCommands }}</pre>
<CopyToClipboard
:text="allMountCommands"
:show-label="false"
class="icon-btn"
action-color="bg-transparent"
/>
</div>
</div>
</Banner>
<button
v-if="!isView && canAddRow"
type="button"
class="btn role-tertiary add"
@click="addRow"
>
{{ t('harvester.virtualMachine.filesystem.add') }}
</button>
</div>
</template>
<style lang="scss" scoped>
.vm-filesystem {
padding: 10px 0;
}
.filesystem-row {
border-bottom: 1px solid var(--border);
padding-bottom: 15px;
}
.remove-col {
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn {
padding: 0;
}
.pre-wrapper {
position: relative;
pre {
padding-right: 36px;
}
.icon-btn {
position: absolute;
top: 0px;
right: 5px;
padding: 2px 4px;
line-height: 1;
&:active {
background-color: var(--success) !important;
}
}
}
</style>
@@ -133,10 +133,6 @@ export default {
}];
return this.isMasquerade ? masquerade : other;
},
staticIPForVMEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('staticIPForVM');
}
},
@@ -154,7 +150,6 @@ export default {
isSingle(neu) {
if (!neu) {
this.value['macAddress'] = '';
this.value['staticIp'] = '';
this.update();
}
}
@@ -168,7 +163,6 @@ export default {
if (neu === MANAGEMENT_NETWORK) {
this.value.isPod = true;
this.value.macAddress = '';
this.value.staticIp = '';
} else {
this.value.isPod = false;
}
@@ -295,30 +289,9 @@ export default {
</a>
</div>
<div
v-if="showAdvanced"
class="row"
>
<div
v-if="staticIPForVMEnabled"
data-testid="input-hen-staticIp"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.staticIp')"
:value="value.staticIp"
:mode="mode"
>
<LabeledInput
v-model:value="value.staticIp"
label-key="harvester.fields.staticIp"
:mode="mode"
:tooltip="t('harvester.virtualMachine.volume.staticIpTip', { nic_name: value.name || 'default' })"
@update:value="update"
/>
</InputOrDisplay>
</div>
<div class="row">
<div
v-if="showAdvanced"
data-testid="input-hen-macAddress"
class="col span-6"
>
@@ -152,10 +152,6 @@ export default {
return !rows.find((device) => !device.passthroughClaim);
},
canManageGroup(rows = []) {
return rows.length > 0 && rows.every((row) => row.canUpdate === true);
},
changeRows(filterRows, parentSriov) {
this['filterRows'] = filterRows;
this['parentSriov'] = parentSriov;
@@ -189,27 +185,22 @@ export default {
v-trim-whitespace
class="group-tab"
>
<div
v-if="canManageGroup(group.rows)"
class="group-actions"
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
>
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.enableGroup') }}
</button>
</div>
{{ t('harvester.pci.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.enableGroup') }}
</button>
<span v-clean-html="group.key" />
</div>
</template>
@@ -241,9 +232,3 @@ export default {
</template>
</ResourceTable>
</template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
</style>
@@ -52,21 +52,31 @@ export default {
}
const selectedDevices = [];
const oldFormatDevices = [];
const vmDevices = this.value?.domain?.devices?.hostDevices || [];
const otherDevices = this.otherDevices(vmDevices).map(({ name }) => name);
const vmDeviceNames = vmDevices.map(({ name }) => name);
this.pciDevices.forEach((row) => {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
});
vmDevices.forEach(({ name }) => {
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
vmDevices.forEach(({ name, deviceName }) => {
const checkName = (deviceName || '').split('/')?.[1];
if (checkName && name.includes(checkName) && !otherDevices.includes(name)) {
oldFormatDevices.push(name);
} else if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
selectedDevices.push(name);
}
});
this.selectedDevices = selectedDevices;
if (oldFormatDevices.length > 0) {
this.oldFormatDevices = oldFormatDevices;
} else {
this.selectedDevices = selectedDevices;
}
},
data() {
@@ -77,6 +87,7 @@ export default {
selectedDevices: [],
pciDeviceSchema: this.$store.getters['harvester/schemaFor'](HCI.PCI_DEVICE),
showMatrix: false,
oldFormatDevices: [],
};
},
@@ -188,6 +199,11 @@ export default {
});
},
oldFormatDevicesHTML() {
return this.oldFormatDevices.map((device) => {
return `<li>${ device }</li>`;
}).join('');
},
},
methods: {
@@ -211,89 +227,101 @@ export default {
<template>
<div>
<div class="row">
<div
v-if="oldFormatDevices.length > 0"
class="row"
>
<div class="col span-12">
<Banner color="info">
<MessageLink
:to="toVGpuDevicesPage"
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
/>
</Banner>
<Banner
v-if="selectedDevices.length > 0"
color="info"
>
<t k="harvester.pci.deviceInTheSameHost" />
<Banner color="warning">
<p v-clean-html="t('harvester.pci.oldFormatDevices.help', {oldFormatDevicesHTML}, true)" />
</Banner>
</div>
</div>
<template v-if="enabledDevices.length">
<div v-else>
<div class="row">
<div class="col span-6">
<LabeledSelect
v-model:value="selectedDevices"
label="Available PCI Devices"
searchable
multiple
taggable
:options="deviceOpts"
:mode="mode"
>
<template #option="option">
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
</template>
</LabeledSelect>
</div>
</div>
<div
v-if="compatibleNodes.length && selectedDevices.length"
class="row"
>
<div class="col span-12 text-muted">
Compatible hosts:
<!-- eslint-disable-next-line vue/no-parsing-error -->
<span
v-for="(node, idx) in compatibleNodes"
:key="idx"
>{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
</div>
</div>
<div
v-else-if="selectedDevices.length"
class="text-error"
>
{{ t('harvester.pci.impossibleSelection') }}
</div>
<button
type="button"
class="btn btn-sm role-link pl-0"
@click="e=>{showMatrix = !showMatrix; e.target.blur()}"
>
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
</button>
<div
v-if="showMatrix"
class="row mt-20"
>
<div class="col span-12">
<CompatibilityMatrix
:enabled-devices="enabledDevices"
:devices-by-node="devicesByNode"
:devices-in-use="devicesInUse"
<Banner color="info">
<MessageLink
:to="toVGpuDevicesPage"
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
/>
</Banner>
<Banner
v-if="selectedDevices.length > 0"
color="info"
>
<t k="harvester.pci.deviceInTheSameHost" />
</Banner>
</div>
</div>
<template v-if="enabledDevices.length">
<div class="row">
<div class="col span-6">
<LabeledSelect
v-model:value="selectedDevices"
label="Available PCI Devices"
searchable
multiple
taggable
:options="deviceOpts"
:mode="mode"
>
<template #option="option">
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
</template>
</LabeledSelect>
</div>
</div>
<div
v-if="compatibleNodes.length && selectedDevices.length"
class="row"
>
<div class="col span-12 text-muted">
Compatible hosts:
<!-- eslint-disable-next-line vue/no-parsing-error -->
<span
v-for="(node, idx) in compatibleNodes"
:key="idx"
>{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
</div>
</div>
<div
v-else-if="selectedDevices.length"
class="text-error"
>
{{ t('harvester.pci.impossibleSelection') }}
</div>
<button
type="button"
class="btn btn-sm role-link pl-0"
@click="e=>{showMatrix = !showMatrix; e.target.blur()}"
>
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
</button>
<div
v-if="showMatrix"
class="row mt-20"
>
<div class="col span-12">
<CompatibilityMatrix
:enabled-devices="enabledDevices"
:devices-by-node="devicesByNode"
:devices-in-use="devicesInUse"
/>
</div>
</div>
</template>
<div class="row mt-20">
<div class="col span-12">
<DeviceList
:schema="pciDeviceSchema"
:devices="pciDevices"
@submit.prevent
/>
</div>
</div>
</template>
<div class="row mt-20">
<div class="col span-12">
<DeviceList
:schema="pciDeviceSchema"
:devices="pciDevices"
@submit.prevent
/>
</div>
</div>
</div>
</template>
@@ -10,7 +10,6 @@ import { _VIEW } from '@shell/config/query-params';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types';
import { getLoginAwareErrors } from '../../utils/error';
const _NEW = '_NEW';
@@ -215,9 +214,7 @@ export default {
buttonCb(true);
this.cancel();
} catch (err) {
const message = this.t('harvester.virtualMachine.genericLoginError');
this.errors = getLoginAwareErrors(err, message);
this.errors = [err.message];
buttonCb(false);
}
},
@@ -64,12 +64,6 @@ export default {
value: 'status.productID',
sort: ['status.productID', 'status.vendorID']
},
{
name: 'classType',
labelKey: 'harvester.usb.classType',
value: 'status.classType',
sort: ['status.classType']
},
];
if (!isSingleProduct) {
@@ -113,11 +107,6 @@ export default {
}
});
},
canManageGroup(rows = []) {
return rows.every((row) => row.canUpdate === true);
},
groupIsAllEnabled(rows = []) {
return !rows.find((device) => !device.passthroughClaim);
},
@@ -158,27 +147,22 @@ export default {
v-trim-whitespace
class="group-tab"
>
<div
v-if="canManageGroup(group.rows)"
class="group-actions"
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
>
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.enableGroup') }}
</button>
</div>
{{ t('harvester.usb.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.enableGroup') }}
</button>
<span v-clean-html="group.key" />
</div>
</template>
@@ -191,9 +175,3 @@ export default {
</template>
</ResourceTable>
</template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
</style>
@@ -475,6 +475,7 @@ export default {
<button
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
:disabled="rows.length === 0"
@click="addVolume(SOURCE_TYPE.NEW)"
>
{{ t('harvester.virtualMachine.volume.addVolume') }}
@@ -1,346 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
import ModalWithCard from '@shell/components/ModalWithCard';
import { SECRET } from '@shell/config/types';
const _AUTOUNATTENDXML = 'autounattend.xml';
const _NEW = '_NEW';
export default {
name: 'VirtualMachineWindowsSysprep',
components: {
Banner,
LabeledInput,
LabeledSelect,
ModalWithCard,
YamlEditor,
},
props: {
mode: {
type: String,
default: 'create',
},
namespace: {
type: String,
required: true,
},
value: {
type: Object,
default: () => ({
secretName: '',
xmlContent: '',
}),
},
},
data() {
return {
secretOptions: [],
errors: [],
secretName: '',
xmlContent: '',
newSecretName: '',
newXmlContent: '',
isOpen: false,
};
},
async fetch() {
await this.loadSecretsOptions();
},
mounted() {
// Force the loading of the XML content.
this.secretName = this.value?.secretName || '';
},
watch: {
secretName() {
if (this.secretName === _NEW) {
this.isOpen = true;
return;
}
// Load the content from the given secret, or set empty value.
this.xmlContent = this.secretName === '' ? '' : this.loadXmlContentFromSecret(this.secretName);
// Update the editor form field.
this.updateValue();
// Update the property.
this.update();
},
xmlContent() {
this.update();
}
},
computed: {
...mapGetters({ t: 'i18n/t' }),
editorMode() {
return this.mode === 'view' ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
},
isView() {
return this.mode === 'view';
},
xmlContentValidationError() {
return this.validateXmlContent(this.xmlContent);
},
},
methods: {
update() {
this.$emit('update:value', {
secretName: this.secretName,
xmlContent: this.xmlContent,
});
},
updateValue() {
this.$refs?.xmlEditor?.updateValue?.(this.xmlContent);
},
refresh() {
this.$refs?.xmlEditor?.refresh?.();
},
cancel() {
if (this.secretName === _NEW) {
this.secretName = '';
}
this.newSecretName = '';
this.newXmlContent = '';
this.errors = [];
this.isOpen = false;
},
async loadSecretsOptions() {
const secrets = await this.$store.dispatch('harvester/findAll', { type: SECRET });
const templates = secrets
.filter(
(s) => s.metadata?.namespace === this.namespace &&
s.decodedData?.[_AUTOUNATTENDXML]
)
.map((s) => ({
label: s.metadata.name,
value: `${ s.metadata.namespace }/${ s.metadata.name }`,
}));
this.secretOptions = [
{
label: this.t('generic.none'),
value: '',
},
{
label: this.t('harvester.virtualMachine.sysprep.createNew'),
value: _NEW,
},
...templates,
];
},
async save(buttonCb) {
this.errors = [];
if (!this.newSecretName?.trim()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
buttonCb(false);
return;
}
if (!this.newXmlContent?.trim()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
buttonCb(false);
return;
}
const xmlError = this.validateXmlContent(this.newXmlContent);
if (xmlError) {
this.errors.push(xmlError);
buttonCb(false);
return;
}
try {
const secret = await this.$store.dispatch('harvester/create', {
type: SECRET,
metadata: {
name: this.newSecretName,
namespace: this.namespace,
},
});
secret.setData(_AUTOUNATTENDXML, this.newXmlContent);
const res = await secret.save();
if (res?.metadata?.name) {
await this.loadSecretsOptions();
this.secretName = `${ this.namespace }/${ res.metadata.name }`;
}
buttonCb(true);
this.cancel();
} catch (err) {
this.errors = [err.message];
buttonCb(false);
}
},
validateXmlContent(content) {
if (!content?.trim()) {
return null;
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/xml');
const parseError = doc.querySelector('parsererror');
if (parseError) {
return this.t('harvester.virtualMachine.sysprep.validation.invalidXml');
}
// See https://learn.microsoft.com/en-us/windows-hardware/drivers/gpiobtn/implement-the-unattended-windows-setup-setting
if (doc.documentElement.namespaceURI !== 'urn:schemas-microsoft-com:unattend') {
return this.t('harvester.virtualMachine.sysprep.validation.invalidNamespace');
}
return null;
} catch (e) {
return e.message;
}
},
loadXmlContentFromSecret(id) {
const secret = this.$store.getters['harvester/byId'](SECRET, id);
return secret?.decodedData?.[_AUTOUNATTENDXML] || '';
}
},
};
</script>
<template>
<div>
<h2 v-if="!isView">
{{ t('harvester.virtualMachine.sysprep.title') }}
</h2>
<p
v-if="!isView"
class="text-muted mb-20"
>
<t
k="harvester.virtualMachine.sysprep.description"
:raw="true"
/>
</p>
<!-- Secret Selection -->
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="secretName"
:options="secretOptions"
:label="t('harvester.virtualMachine.sysprep.secret.label')"
:mode="mode"
/>
</div>
</div>
<!-- XML Editor -->
<div class="mb-20">
<Banner
v-if="xmlContentValidationError"
color="error"
class="mb-10"
>
<i class="icon icon-error" />
{{ xmlContentValidationError }}
</Banner>
<div class="resource-xml">
<YamlEditor
ref="xmlEditor"
v-model:value="xmlContent"
:editor-mode="editorMode"
class="xml-editor"
/>
</div>
</div>
<ModalWithCard
v-if="isOpen"
name="createSysprepSecret"
width="40%"
:errors="errors"
@finish="save"
@close="cancel"
>
<template #title>
{{ t('harvester.virtualMachine.sysprep.createNewTitle') }}
</template>
<template #content>
<LabeledInput
v-model:value="newSecretName"
:label="t('harvester.virtualMachine.input.name')"
class="mb-20"
required
@keydown.native.enter.prevent="()=>{}"
/>
<div class="xml">
<div class="resource-xml">
<YamlEditor
ref="createTemplate"
v-model:value="newXmlContent"
:editor-mode="editorMode"
class="xml-editor"
/>
</div>
</div>
</template>
</ModalWithCard>
</div>
</template>
<style lang="scss" scoped>
$xml-height: 350px;
:deep() .resource-xml {
flex: 1;
display: flex;
flex-direction: column;
& .xml-editor {
flex: 1;
min-height: $xml-height;
font-family: monospace;
& .code-mirror .CodeMirror {
min-height: $xml-height;
}
}
}
.xml {
height: $xml-height;
overflow: auto;
}
</style>
@@ -21,7 +21,9 @@ import { saferDump } from '@shell/utils/create-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
import CreateEditView from '@shell/mixins/create-edit-view';
import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
import VM_MIXIN from '../../mixins/harvester-vm';
import { HCI } from '../../types';
@@ -29,14 +31,12 @@ import RestartVMDialog from '../../dialog/RestartVMDialog';
import PciDevices from './VirtualMachinePciDevices/index';
import AccessCredentials from './VirtualMachineAccessCredentials';
import CloudConfig from './VirtualMachineCloudConfig';
import WindowsSysprep from './VirtualMachineWindowsSysprep';
import CpuMemory from './VirtualMachineCpuMemory';
import CpuModel from './VirtualMachineCpuModel';
import Network from './VirtualMachineNetwork';
import Volume from './VirtualMachineVolume';
import SSHKey from './VirtualMachineSSHKey';
import Reserved from './VirtualMachineReserved';
import Filesystem from './VirtualMachineFilesystem';
import { Banner } from '@components/Banner';
import MessageLink from '@shell/components/MessageLink';
@@ -60,7 +60,6 @@ export default {
CpuMemory,
CpuModel,
CloudConfig,
WindowsSysprep,
NodeScheduling,
PodAffinity,
AccessCredentials,
@@ -73,7 +72,6 @@ export default {
Banner,
MessageLink,
UsbDevices,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
@@ -93,36 +91,22 @@ export default {
const hostname = this.value.spec.template.spec.hostname || '';
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return {
cloneVM,
count: 2,
templateId: '',
templateVersionId: '',
namePrefix: '',
isSingle: true,
isOpen: false,
count: 2,
templateId: '',
templateVersionId: '',
namePrefix: '',
isSingle: true,
isOpen: false,
hostname,
isRestartImmediately,
customizeDisplayName,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
// VM display name is stored as an annotation; bind a dedicated input to it
// so we don't have to mutate metadata.name (which would break the k8s PUT).
displayName: {
get() {
return this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME] || '';
},
set(val) {
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, val);
},
},
to() {
return {
name: 'harvester-c-cluster-resource',
@@ -234,9 +218,6 @@ export default {
usbPassthroughEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough');
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@@ -308,12 +289,6 @@ export default {
this.getInitConfig({ value: this.value, init: this.isCreate });
}
},
customizeDisplayName(neu) {
if (!neu) {
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, '');
}
},
},
created() {
@@ -326,10 +301,6 @@ export default {
try {
await this.saveSecret(res);
await this.saveAccessCredentials(res);
if (this.isWindows) {
await this.saveSysprepConfig(res);
}
} catch (e) {
this.errors.push(...exceptionToErrorsArray(e));
}
@@ -350,7 +321,6 @@ export default {
const diskRows = this.getDiskRows(this.value);
this['diskRows'] = diskRows;
this['filesystemRows'] = this.getFilesystemRows(this.value);
const templateId = this.$route.query.templateId;
const templateVersionId = this.$route.query.versionId;
@@ -375,7 +345,6 @@ export default {
clear(this.errors);
this.validateCPUMemory();
this.validateWindowsSysprep();
// block create VM flow if has validation errors
if (this.errors.length) {
@@ -403,24 +372,6 @@ export default {
}
},
validateWindowsSysprep() {
if (!this.isWindows) {
return;
}
if (this.sysprep?.secretName?.trim?.() && !this.sysprep?.xmlContent?.trim?.()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
return;
}
const sysprepValidationError = this.$refs.sysprepConfig?.xmlContentValidationError;
if (sysprepValidationError) {
this.errors.push(sysprepValidationError);
}
},
async saveSingle(buttonCb) {
this.parseVM();
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
@@ -571,7 +522,6 @@ export default {
onTabChanged({ tab }) {
if (tab.name === 'advanced') {
this.$refs.yamlEditor?.refresh();
this.$refs.sysprepConfig?.refresh();
}
},
@@ -660,33 +610,6 @@ export default {
</template>
</NameNsDescription>
<div v-if="isSingle">
<div class="row mb-20">
<div class="col span-12">
<Checkbox
v-model:value="customizeDisplayName"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.input.customizeDisplayName')"
:mode="mode"
/>
</div>
</div>
<div
v-if="customizeDisplayName"
class="row mb-20"
>
<div class="col span-6">
<LabeledInput
v-model:value="displayName"
:mode="mode"
:label="t('harvester.virtualMachine.input.displayName')"
:placeholder="t('harvester.virtualMachine.input.displayNamePlaceholder')"
/>
</div>
</div>
</div>
<Checkbox
v-if="isCreate"
v-model:value="useTemplate"
@@ -860,23 +783,10 @@ export default {
/>
</Tab>
<Tab
v-if="filesystemEnabled"
name="filesystem"
:label="t('harvester.tab.filesystem')"
:weight="-9"
>
<Filesystem
v-model:value="filesystemRows"
:mode="isCreate ? mode : 'view'"
:namespace="value.metadata.namespace"
/>
</Tab>
<Tab
name="labels"
:label="t('generic.labels')"
:weight="-10"
:weight="-9"
>
<Banner color="info">
<t k="harvester.virtualMachine.labels.banner" />
@@ -895,7 +805,7 @@ export default {
<Tab
name="instanceLabel"
:label="t('harvester.tab.instanceLabel')"
:weight="-11"
:weight="-10"
>
<Banner color="info">
<t k="harvester.virtualMachine.instanceLabels.banner" />
@@ -916,7 +826,7 @@ export default {
<Tab
name="annotations"
:label="t('harvester.tab.annotations')"
:weight="-12"
:weight="-11"
>
<Banner color="info">
<t k="harvester.virtualMachine.annotations.banner" />
@@ -937,7 +847,7 @@ export default {
<Tab
name="advanced"
:label="t('harvester.tab.advanced')"
:weight="-13"
:weight="-12"
>
<div class="row mb-20">
<div class="col span-6">
@@ -1045,11 +955,11 @@ export default {
</div>
<CloudConfig
v-if="!isWindows"
ref="yamlEditor"
:user-script="userScript"
:mode="mode"
:os-type="osType"
:view-code="isWindows"
:namespace="value.metadata.namespace"
:network-script="networkScript"
@updateUserData="updateUserData"
@@ -1057,14 +967,6 @@ export default {
@updateDataTemplateId="updateDataTemplateId"
/>
<WindowsSysprep
v-if="isWindows"
ref="sysprepConfig"
v-model:value="sysprep"
:mode="mode"
:namespace="value.metadata.namespace"
/>
<Checkbox
v-if="value.cpuPinningFeatureEnabled"
v-model:value="cpuPinning"
@@ -1,419 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import InfoBox from '@shell/components/InfoBox';
import MessageLink from '@shell/components/MessageLink';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
import { Checkbox } from '@components/Form/Checkbox';
import HarvesterNodeSelector from '../components/HarvesterNodeSelector';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { set } from '@shell/utils/object';
import { NODE } from '@shell/config/types';
import { HCI } from '../types';
import { ADD_ONS } from '../config/harvester-map';
const MODE_DHCP = 'dhcp';
const MODE_STATIC = 'static';
export default {
name: 'HarvesterHostNetworkConfigEditPage',
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
InfoBox,
MessageLink,
LabeledSelect,
LabeledInput,
RadioGroup,
Checkbox,
HarvesterNodeSelector,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
hostNetworkConfigs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HOST_NETWORK_CONFIG }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
});
},
data() {
const networkMode = this.value?.spec?.mode || MODE_DHCP;
const ips = { ...(this.value?.spec?.ips || {}) };
if (!this.value.spec) {
set(this.value, 'spec', {});
}
return {
networkMode,
ips,
hasNodeSelector: !!this.value?.spec?.nodeSelector,
};
},
computed: {
modeOptions() {
return [
{ label: 'DHCP', value: MODE_DHCP },
{ label: 'Static', value: MODE_STATIC },
];
},
clusterNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
return clusterNetworks.map((n) => {
const disabled = !n.isReady;
return {
label: disabled ? `${ n.id } (${ this.t('generic.notReady') })` : n.id,
value: n.id,
disabled,
};
});
},
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/all`](NODE) || [];
},
isStaticMode() {
return this.networkMode === MODE_STATIC;
},
underlay: {
get() {
return !!this.value?.spec?.underlay;
},
set(val) {
set(this.value, 'spec.underlay', val);
},
},
vlanID: {
get() {
return this.value?.spec?.vlanID;
},
set(val) {
set(this.value, 'spec.vlanID', val);
},
},
clusterNetwork: {
get() {
return this.value?.spec?.clusterNetwork;
},
set(val) {
set(this.value, 'spec.clusterNetwork', val);
},
},
underlayConflict() {
const inStore = this.$store.getters['currentProduct'].inStore;
const all = this.$store.getters[`${ inStore }/all`](HCI.HOST_NETWORK_CONFIG) || [];
const currentId = this.value?.id;
return all.find((c) => c.id !== currentId && c.spec?.underlay === true) || null;
},
kubeovnEnabled() {
const inStore = this.$store.getters['currentProduct'].inStore;
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS) || [];
return addons.find((a) => a.name === ADD_ONS.KUBEOVN_OPERATOR)?.spec?.enabled === true;
},
underlayDisabled() {
return !this.kubeovnEnabled || !!this.underlayConflict;
},
kubeovnAddonTo() {
return {
name: 'c-cluster-product-resource-namespace-id',
params: {
cluster: this.$route.params.cluster,
product: this.$store.getters['productId'],
resource: HCI.ADD_ONS,
namespace: 'kube-system',
id: ADD_ONS.KUBEOVN_OPERATOR,
},
query: { mode: 'edit' },
hash: '#basic',
};
},
},
watch: {
networkMode(neu) {
set(this.value, 'spec.mode', neu);
if (neu !== MODE_STATIC) {
if (this.value?.spec?.ips !== undefined) {
delete this.value.spec.ips;
}
this.ips = {};
}
},
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
methods: {
updateBeforeSave() {
set(this.value, 'spec.mode', this.networkMode);
if (this.isStaticMode) {
set(this.value, 'spec.ips', { ...this.ips });
}
},
updateIp(nodeName, val) {
this.ips = { ...this.ips, [nodeName]: val };
},
addNodeSelector() {
set(this.value.spec, 'nodeSelector', {
matchExpressions: [{
key: '', operator: 'In', values: []
}]
});
this.hasNodeSelector = true;
},
removeNodeSelector() {
delete this.value.spec.nodeSelector;
this.hasNodeSelector = false;
},
},
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:mode="mode"
:resource="value"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@cancel="done"
@error="e => errors = e"
>
<NameNsDescription
:value="value"
:mode="mode"
:namespaced="false"
description-key="spec.description"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('harvester.hostNetworkConfig.tabs.mode')"
:weight="99"
class="bordered-table"
>
<div class="row mb-20">
<div class="col span-6">
<RadioGroup
v-model:value="networkMode"
name="hostNetworkConfigMode"
:options="modeOptions"
:mode="mode"
:row="true"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="clusterNetwork"
:label="t('harvester.network.clusterNetwork.label')"
:options="clusterNetworkOptions"
:mode="mode"
required
:placeholder="t('harvester.network.clusterNetwork.selectPlaceholder')"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value.number="vlanID"
type="number"
required
:min="1"
:max="4094"
placeholder="e.g. 1 ~ 4094"
:label="t('harvester.hostNetworkConfig.vlanID.label')"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<Checkbox
v-model:value="underlay"
:label="t('harvester.hostNetworkConfig.underlay.label')"
:tooltip="t('harvester.hostNetworkConfig.underlay.tooltip')"
:mode="mode"
:disabled="underlayDisabled"
/>
<span
v-if="!kubeovnEnabled"
class="underlay-conflict-warning"
>
<i class="icon icon-warning" />
<MessageLink
:to="kubeovnAddonTo"
prefix-label="harvester.hostNetworkConfig.underlay.noKubeovn.prefix"
middle-label="harvester.hostNetworkConfig.underlay.noKubeovn.middle"
suffix-label="harvester.hostNetworkConfig.underlay.noKubeovn.suffix"
/>
</span>
<span
v-else-if="underlayConflict"
class="underlay-conflict-warning"
>
<i class="icon icon-warning" />
{{ t('harvester.hostNetworkConfig.underlay.conflict', { name: underlayConflict.nameDisplay || underlayConflict.id }) }}
</span>
</div>
</div>
<template v-if="isStaticMode">
<hr class="section-divider" />
<div
v-for="node in nodes"
:key="node.id"
class="row mb-10 ips-row"
>
<div class="col span-3">
<LabeledInput
:value="node.nameDisplay || node.id"
:label="t('harvester.hostNetworkConfig.ips.nodeLabel')"
mode="view"
:disabled="true"
/>
</div>
<div class="col span-5">
<LabeledInput
:value="ips[node.id]"
:label="t('harvester.hostNetworkConfig.ips.label')"
:placeholder="t('harvester.hostNetworkConfig.ips.placeholder')"
:mode="mode"
required
@update:value="updateIp(node.id, $event)"
/>
</div>
</div>
</template>
</Tab>
<Tab
name="nodeSelector"
:label="t('harvester.hostNetworkConfig.tabs.nodeSelector')"
:weight="98"
>
<template v-if="hasNodeSelector">
<InfoBox class="node-selector-box">
<button
v-if="!isView"
type="button"
class="role-link btn btn-sm remove"
:aria-label="t('generic.remove')"
@click="removeNodeSelector"
>
<i class="icon icon-x" />
</button>
<HarvesterNodeSelector
class="mt-20"
:value="value.spec.nodeSelector"
:mode="mode"
/>
</InfoBox>
</template>
<template v-else>
<button
type="button"
class="btn role-secondary"
:disabled="isView"
@click="addNodeSelector"
>
{{ t('harvester.hostNetworkConfig.nodeSelector.addButton') }}
</button>
</template>
</Tab>
</ResourceTabs>
</CruResource>
</template>
<style lang="scss" scoped>
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0 20px;
}
.node-selector-box {
position: relative;
.remove {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
padding: 0;
}
}
.underlay-conflict-warning {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: var(--warning);
font-size: 13px;
}
</style>
@@ -31,10 +31,6 @@ export default {
to() {
return this.vm?.detailLocation;
},
attachVMName() {
return this.vm?.nameDisplay || this.vm?.metadata?.name || this.value;
}
}
};
</script>
@@ -44,10 +40,10 @@ export default {
v-if="to"
:to="to"
>
{{ attachVMName }}
{{ value }}
</router-link>
<span v-else>
{{ attachVMName }}
{{ value }}
</span>
</template>
@@ -1,23 +0,0 @@
<script>
export default {
name: 'HarvesterBooleanFormatter',
props: {
value: {
type: Boolean,
default: false,
},
},
};
</script>
<template>
<span v-if="value">
<i class="icon icon-checkmark" />
</span>
<span
v-else
class="text-muted"
>
&mdash;
</span>
</template>
@@ -1,29 +0,0 @@
<script>
export default {
name: 'HarvesterHostNetworkConfigModeFormatter',
props: {
value: {
type: String,
default: '',
},
},
computed: {
displayMode() {
if (this.value?.toLowerCase() === 'dhcp') {
return 'DHCP';
}
if (this.value?.toLowerCase() === 'static') {
return 'Static';
}
return this.value;
},
},
};
</script>
<template>
<span>{{ displayMode }}</span>
</template>
@@ -36,10 +36,6 @@ export default {
computed: {
ips() {
if (this.staticAnnotationIP.length) {
return this.staticAnnotationIP;
}
if (this.vmiIp.length) {
return [...this.vmiIp, ...this.networkAnnotationIP]
.filter(Boolean)
@@ -49,24 +45,6 @@ export default {
return this.customAnnotationIP;
},
staticAnnotationIP() {
const annotations = this.row?.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
return Object.entries(annotations)
.filter(([key, value]) => key.startsWith(staticIpPrefix) && !!value)
.map(([key, value]) => {
const nicName = key.slice(staticIpPrefix.length);
const ip = String(value).replace(/\/[\d\D]*/, '');
return {
ip,
name: nicName
};
})
.filter(({ ip }) => isIpv4(ip));
},
customAnnotationIP() {
const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`);
@@ -20,7 +20,12 @@ export default {
computed: {
vmiResource() {
return this.$store.getters['harvester/byId'](HCI.VMI, this.vmResource?.id) || null;
const vmiList = this.$store.getters['harvester/all'](HCI.VMI) || [];
const vmi = vmiList.find( (VMI) => {
return VMI?.metadata?.ownerReferences?.[0]?.uid === this.vmResource?.metadata?.uid;
});
return vmi;
},
migrationState() {
return this.vmiResource?.migrationState?.status || '';
@@ -1,24 +0,0 @@
<script>
export default {
name: 'HarvesterVlanFormatter',
props: {
value: {
type: String,
default: '',
},
},
};
</script>
<template>
<span v-if="value">
{{ value }}
</span>
<span
v-else
class="text-muted"
>
&mdash;
</span>
</template>
@@ -14,6 +14,20 @@ export default {
type: Object,
required: true
},
allNodeNetwork: {
type: Array,
default: () => {
return [];
}
},
allClusterNetwork: {
type: Array,
default: () => {
return [];
}
}
},
data() {
+23 -449
View File
@@ -1,8 +1,3 @@
wizard:
create: Create
next: Proceed
previous: Back
generic:
tip: Tip
resourceExternalLinkTips: 'External Link'
@@ -12,16 +7,6 @@ generic:
inProgress: In Progress
basic: Basic
loading: Loading...
none: None
duration:
infinite: Infinite
1s: 1 second
5s: 5 seconds
10s: 10 seconds
30s: 30 seconds
1m: 1 minute
3m: 3 minutes
5m: 5 minutes
unsupported:
serverVersion: 'Current version: <code>{serverVersion}</code>'
@@ -36,8 +21,6 @@ nav:
Logging: Logging
'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports
vmMigration: Virtual Machine Migration
Configurations: Configurations
resourceTable:
groupBy:
@@ -51,8 +34,6 @@ members:
projectMembership: Project Membership
asyncButton:
createAndStart:
action: Create and Start
restart:
action: Save and Restart
success: Restarted
@@ -93,8 +74,6 @@ harvester:
backup:
success: 'Backup { backUpName } has been initiated.'
addBackup: Add Backup
fileSystemFreezeDeadline:
label: 'File System Freeze Deadline'
quota:
editVMQuota: Edit VM Quota
editQuota: Edit Quota
@@ -165,10 +144,6 @@ harvester:
migration:
failedMessage: Latest migration failed!
title: Migration
vmMigrationTitle: '{count, plural, one {Migrating # VM} other {Migrating # VMs}}'
selectedVMs: "The following virtual machine(s) will be migrated to the target node"
unknownNode: (unknown node)
alreadyOnTarget: Already on Target
fields:
nodeName:
label: Target Node
@@ -234,8 +209,6 @@ harvester:
title: Take Virtual Machine Snapshot
name: Name
success: 'Take virtual machine Snapshot { name } successfully.'
fileSystemFreezeDeadline:
label: 'File System Freeze Deadline'
restart:
title: Restart Virtual Machine
tip: Restart the virtual machine for configuration changes to take effect.
@@ -275,7 +248,6 @@ harvester:
suspendSchedule: Suspend
restoreExistingVM: Replace Existing
migrate: Migrate
vmMigrate: Virtual Machine Migration
cpuAndMemoryHotplug: Edit CPU and Memory
abortMigration: Abort Migration
storageMigration: Storage Migration
@@ -318,12 +290,6 @@ harvester:
harvesterIpAddress:
customIpTooltip: "Custom IP (set via annotation)"
tableHeaders:
hostNetworkConfig:
underlay: Underlay
underlayTooltip: Allow this interface to act as the underlay for VM overlay networks.
vlanID: VLAN ID
mode: Mode
clusterNetwork: Cluster Network
imageEncryption: Encryption
size: Size
virtualSize: Virtual Size
@@ -332,7 +298,6 @@ harvester:
phase: Phase
attachedVM: Attached Virtual Machine
cpuManager: CPU Manager
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
fingerprint: Fingerprint
value: Value
actions: Actions
@@ -371,16 +336,6 @@ harvester:
vmImportSourceOClusterStatus: Cluster Status
vmImportSourceOVAUrl: URL
vmImportSourceOVAStatus: Status
v4ip: V4 IP
v6ip: V6 IP
eipName: EIP Name
vmMigrationProviderType: Type
vmMigrationProviderUrl: URL
vmMigrationMapSourceProvider: Source Provider
vmMigrationMapDestProvider: Destination Provider
vmMigrationPlanTargetNs: Target Namespace
vmMigrationPlanVmCount: VM Count
vmMigrationMigrationPlan: Plan
tab:
volume: Volumes
network: Networks
@@ -394,7 +349,6 @@ harvester:
snapshots: Snapshots
instanceLabel: Instance Labels
annotations: Annotations
filesystem: Filesystem Volume
fields:
version: Version
name: Name
@@ -405,7 +359,6 @@ harvester:
network: Network
model: Model
macAddress: MAC address
staticIp: Static IP
port: Port
protocol: Protocol
remove: Remove
@@ -432,6 +385,21 @@ harvester:
middle: vGPU Devices
suffix: page.
deviceInTheSameHost: 'You can only select devices on the same host.'
oldFormatDevices:
help: |-
<p>
The following PCI devices are using the old naming convention and need to be updated in the YAML file:
</p>
<ul>
{oldFormatDevicesHTML}
</ul>
<p>
Please use the following instructions to update the virtual machine:
</p>
<ol>
<li>Stop the virtual machine, edit the virtual machine YAML, and remove the <Code>hostDevices</Code> section, and save virtual machine the changes to the YAML file.</li>
<li>Edit the virtual machine, and add the already enabled PCI Device from the list of available PCIDevices, and save and start VM.</li>
</ol>
showCompatibility: Show device compatibility matrix
hideCompatibility: Hide device compatibility matrix
claimError: Error enabling passthrough on {name}
@@ -449,8 +417,6 @@ harvester:
suffix: to enable the add-on to successfully manage your PCI devices.
noPCIPermission: Please contact your system administrator to enable the PCI devices first.
enablePassthroughWarning: Please be careful not to use host-owned PCI devices (e.g., management and VLAN NICs). Incorrect device allocation may cause damage to your cluster, including node failure.
disableResourcePooling: Disable Resource Pooling
disableResourcePoolingDescription: Assigns this device a unique resource name so it can be pinned to a specific VM, instead of sharing the pool with other identical devices.
devices:
matrixHostName: Host Name
@@ -641,7 +607,6 @@ harvester:
notification:
success: 'Update host "{name}" storage configurations successfully.'
error: Host has unready or unschedulable disks.
mounted: Already Mounted
fileSystem:
info: Current file system is {system}, You can format it manually.
formatting: Disk is formatting, please wait.
@@ -761,7 +726,6 @@ harvester:
other {Start}
} Now
createSSHKey: Create a New...
genericLoginError: Authentication failed. Please re-log in and try again.
installAgent: Install guest agent
enableUsb: Enable USB Tablet
advancedOptions:
@@ -781,7 +745,6 @@ harvester:
dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order.
volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes.
macTip: "MAC address as seen inside the guest system."
staticIpTip: "Static IP that will be stored to annotation static-ip.harvesterhci.io/{nic_name}."
volumeUpdate: 'Set volume { name } successfully'
type: Type
size: Size
@@ -817,7 +780,7 @@ harvester:
addPort: Add Port
cloudConfig:
title: Cloud Configuration
createTemplateTitle: 'Create {name}'
createTemplateTitle: 'Create {name}.'
createNew: Create new...
cloudInit:
label: Cloud Init
@@ -830,17 +793,6 @@ harvester:
label: Network Data Template
title: "Network Data:"
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
sysprep:
title: Windows Sysprep Configuration
description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
createNew: Create new...
createNewTitle: Create Windows Sysprep Template
xmlContent: autounattend.xml content
secret:
label: Windows Sysprep Template
validation:
invalidXml: Invalid XML syntax
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
scheduling:
affinity:
anyNode: 'Run virtual machine on any available node'
@@ -871,18 +823,6 @@ harvester:
username: Username
password: Password
reservedMemory: Reserved Memory
customizeDisplayName: Customize virtual machine display name
displayNamePlaceholder: Virtual machine alias name
displayName: Display Name
filesystem:
description: Harvester supports filesystem volumes for VM via virtiofs.
type: Filesystem Type
volume: Volume
resource: Resource
add: Add
mountBannerHint: "Please update the mount path (e.g. /mnt/appconfigfs) to your preferred location, then add the corresponding commands to the"
mountBannerHintLink: "runcmd"
mountBannerHintSuffix: "in Advanced tab User Data."
machineTypeTip: 'Specify a processor architecture to emulate. To see a list of supported architectures, run: qemu-system-x86_64 -cpu ?'
detail:
tabs:
@@ -1171,7 +1111,6 @@ harvester:
replaceExisting: Replace existing
virtualMachineName: Virtual Machine Name
keepMacAddress: Keep MAC Address
haltAfterRestore: Keep powered off after restore
matchTarget: The current backup target does not match the existing one.
progress:
details: Volume details
@@ -1202,9 +1141,6 @@ harvester:
gateway:
label: Gateway IP
placeholder: e.g. 172.20.0.1
vlan:
label: VLAN
placeholder: Select a VLAN
dhcp:
label: Dynamic Host Configuration Protocol (DHCP)
v4Options: DHCPV4Options
@@ -1212,9 +1148,6 @@ harvester:
placeholder: key1=value1, key2=value2
dhcpOptionBanner: DHCP options is a key/value string concatenate with comma. For more detail, please refer to <a href="https://kubeovn.github.io/docs/v1.13.x/en/kubevirt/dhcp/" target="_blank">KubOVN document</a>
tooltip: Enable DHCP server for this subnet. When enabled, VMs can automatically obtain IP addresses from this subnet.
externalConnectivity:
label: NAT Outgoing
tooltip: Enable NAT for VMs using this subnet to access external networks.
private:
label: Private Subnet
tooltip: Enable network isolation for this Subnet. When enabled, VMs can only communicate within this subnet, even if other subnets exist under the same VPC.
@@ -1290,97 +1223,14 @@ harvester:
remoteVpc:
label: Remote VPC
infoBanner: The static route destination CIDR must cover all subnets CIDR from remote VPC Peer. Read <a href="{url}" target="_blank">VPC Peering Configuration Examples</a> for more information.
natGateway:
label: Gateways
internalTenantNetwork:
label: Internal Tenant Network
placeholder: Select a Virtual Machine Network
vpc:
label: VPC
placeholder: Select a VPC
subnet:
label: Subnet
placeholder: Select a subnet
lanIp:
label: LAN IP
placeholder: Enter LAN IP
externalSubnets:
label: External Subnets
addLabel: Add
placeholder: Select a subnet
externalIP:
label: External IPs
natGateway:
label: VpcNatGateway
placeholder: Select a VpcNatGateway
externalSubnet:
label: External Subnet
placeholder: Select an external subnet
v4ip:
label: V4 IP
placeholder: public ip from external subnet
snat:
label: Source Rules
eip:
label: EIP
placeholder: Select an external IP
internalCIDR:
label: Internal CIDR
placeholder: internal subnet CIDR
dnat:
label: Destination Rules
eip:
label: EIP
placeholder: Select an external IP
externalPort:
label: External Port
placeholder: port number
internalIp:
label: Internal IP
placeholder: internal IP address
internalPort:
label: Internal Port
placeholder: port number
protocol:
label: Protocol
placeholder: Select protocol (tcp or udp)
providerNetwork:
label: Provider Networks
defaultInterface:
label: Default Interface
placeholder: Select the interface the same as master interface of external overlay network
customInterfaces:
label: Custom Interfaces
addLabel: Add Custom Interface
interface:
label: Network Interface
placeholder: e.g. eth2
nodes:
label: Nodes
addLabel: Add Node
placeholder: Select a node
excludedNodes:
label: Excluded Nodes
addLabel: Add Excluded Node
placeholder: Select node to exclude from this provider network
vlanNetwork:
label: VLANs
vlan:
id:
label: VLAN ID
placeholder: "e.g. 1-4094"
provider:
label: Provider Network
placeholder: Select a provider network
networkPolicy:
label: Policies
label: Network Policies
banner: The network policies must be used for VMs attached to overlay networks. Please read the <a href="{url}" target="_blank">harvester document</a> how the network policy works.
network:
label: Virtual Machine Networks
tabs:
basics: Basics
layer3Network: Route
nic: Network Interface Card
clusterNetwork:
label: Cluster Network
create: Create a new cluster network
@@ -1435,13 +1285,6 @@ harvester:
rancherCluster:
kubeConfig: Rancher KubeConfig
removeUpstreamClusterWhenNamespaceIsDeleted: Remove Upstream Cluster When Namespace Is Deleted
clusterPodSecurityStandard:
whitelistedNamespaces:
label: 'Whitelisted Namespaces'
privilegedNamespaces:
label: 'Privileged Namespaces'
restrictedNamespaces:
label: 'Restricted Namespaces'
storageNetwork:
range:
placeholder: e.g. 172.16.0.0/24
@@ -1449,7 +1292,6 @@ harvester:
invalid: '"Range" is invalid.'
clusterNetwork: Cluster Network
vlan: VLAN ID
exclusiveVlan: Exclusive VLAN
exclude:
label: Exclude IPs
placeholder: CIDR format, e.g. 172.16.0.10/32
@@ -1666,7 +1508,6 @@ harvester:
vmSnapshot:
label: Virtual Machine Snapshots
createText: Restore Snapshot
title: Restore Virtual Machine
snapshot: Snapshot
storage:
@@ -1674,7 +1515,6 @@ harvester:
useDefault: Use the default storage
volumeEncryption: Volume Encryption
secret: Secret
volumeExpansionCheckbox: Enable Expansion
migratable:
label: Migratable
numberOfReplicas:
@@ -1767,9 +1607,8 @@ harvester:
schedulingRules: Select node(s) matching rules
uplink:
nics:
label: NIC
label: NICs
addLabel: Add NIC
overlayWarning: The NIC selected here must match the NIC provided in the provider network.
placeholder: Select a NIC that is available on all the selected nodes
validate:
available: NIC "{nic}" is not available on the selected nodes
@@ -1831,181 +1670,6 @@ harvester:
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
vmMigration:
generic:
unknown: Unknown
identifier: Identifier
podNetwork: Pod Network
usedBy: "Used by:"
vlan: "VLAN {id}"
vCpu: "{count} vCPU"
memoryGb: "{value} GB"
memoryTb: "{value} TB"
elapsed:
hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}"
hours: "{hours} {hours, plural, one {hour} other {hours}}"
minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}"
errors:
failedLoadPlans: Failed to load migration plans
failedLoadVms: Failed to load virtual machines
failedRefreshVms: Failed to refresh virtual machines
failedLoadProviders: Failed to load providers
failedLoadProvider: "Failed to load provider: {error}"
failedResolveDetails: Failed to resolve network and datastore details
failedDecodeCredentials: Failed to decode provider credentials
connectionFailed: Connection failed
providerNotReady: Provider not ready
labels:
dashboard: Migration Overview
provider: Providers
networkMap: Network Maps
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
providerWizard:
title: Create Provider
editTitle: Edit Provider
wizard:
title: VM Migration
steps:
configureProvider:
label: Provider
description: Configure Provider
selectVms:
label: VMs
description: Select VMs
configureMappings:
label: Mappings
description: Define Mappings
reviewMigration:
label: Migration Plan
description: Review Migration Plan
fields:
username: Username
password: Password
configureProvider:
title: Configure Provider
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
connectionDetails: Connection Details
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 Rancher."
name: Name
urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https://
skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments
testConnection:
action: Test Connection
success: Connection successful
error: Connection failed
waiting: Testing connection...
testSuccess: Connection test passed
testFailed: Connection test failed
testMissingFields: Please fill in all required fields before testing
testTimeout: Connection test timed out — the provider did not report a status in time
save: Save Provider and Continue
saveExisting: Check Provider and Continue
selectVms:
title: Select Virtual Machines
description: Select the virtual machines you want to migrate from the source provider
discovered: "{count} VMs discovered from"
lastSynced: "Last synced {time} ago • "
refreshNow: Refresh now
availableVms: Available Virtual Machines
selected: selected
saveSelection: Save Selection and Continue
selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page."
selectAll: "Select all {count} VMs from this provider"
allSelected: "Your current selection includes all VMs."
clearSelection: "Clear Selection"
manualEntry:
title: Add Virtual Machines
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
vmId: VM ID
vmIdPlaceholder: "e.g. vm-60527"
add: Add
added: VMs added
columns:
vmName: VM Name
vmId: VM ID
os: Operating System
resources: Resources
powerState: Power State
network: Network
datastore: Datastore
dashboard:
title: Virtual Machine Migration Overview
description: "Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester."
createPlan: Create Migration
tableTitle: Migrations
columns:
status: Status
plan: Migration Plan
progress: Progress
mappings: Mappings
duration: Duration
progress:
failed: Failed
migration: Migration
initializingMigration: Initializing migration
finishedSuccessfully: Finished Successfully
step: "Step {index}"
vmCount: "{count} VMs"
vmId: "id: {id}"
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 Map
networkMapping:
title: Network Mapping
description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network
template: Use existing network mapping as template
options:
podNetworking: Management Network
ignored: Ignored
storageMapping:
title: Storage Mapping
description: Map VMware datastores to Harvester storage classes
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"
migrationDetails: Migration Details
totalVms: Total VMs
vcpu: vCPU
memory: Memory
storage: Storage
source: Source
targetNamespace: Target Namespace
migrationMode: Migration Mode
coldMigration: Cold Migration
virtualMachines: Virtual Machines
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
plan:
states:
error: Error
canceled: Canceled
inProgress: In Progress
active: Succeeded
actions:
start: Start
restart: Restart
stop: Stop
deleteDialog:
warning: Deleting this migration plan will also delete the following related resources
vmImport:
titles:
basic: Basic
@@ -2100,8 +1764,6 @@ harvester:
repository: Image Repository
driver:
location: Driver Location
enable:
title: Enable NVIDIA Driver Toolkit
parsingSpecError:
The field 'spec.valuesContent' has invalid format.
usbController:
@@ -2186,32 +1848,6 @@ harvester:
addLabel: Add CIDR
range:
addLabel: Add Range
hostNetworkConfig:
label: Host Networks
mode:
label: Mode
tabs:
mode: Mode
nodeSelector: Node Selector
nodeSelector:
addButton: Add Node Selector
underlay:
label: Underlay
tooltip: Allow this interface to act as the underlay for VM overlay networks.
conflict: '`{name}` host network config already has underlay enabled. Only one underlay is allowed in the cluster.'
noKubeovn:
prefix: The kubeovn-operator add-on is not enabled. Click
middle: here
suffix: to enable the add-on for overlay networking.
vlanID:
label: VLAN ID
ipRange:
label: IP Range ({node})
placeholder: e.g. 192.168.1.10/24
ips:
nodeLabel: Node
label: IP
placeholder: 'e.g. 192.168.1.10/24'
service:
healthCheckPort:
@@ -2257,8 +1893,6 @@ harvester:
migconfiguration:
label: vGPU MIG Configurations
status:
outOfSync: Out of Sync
infoBanner: To configure the MIG configuration, please disable it first and re-enable after editing the configuration.
profileSpec: Profile Specs
profileStatus: Profile Status
@@ -2321,7 +1955,6 @@ harvester:
title: Cannot Disable Passthrough
message: Please detach the device from the VM and save it first before disabling passthrough.
enablePassthroughWarning: 'Please re-enable the USB device if the device path changes in the following situations:<br/>&nbsp1) Re-plugging the USB device.<br/>&nbsp2) Rebooting the node.<br/><br/>An incorrect device path may cause passthrough to fail.'
classType: Class Type
harvesterVlanConfigMigrateDialog:
targetClusterNetwork:
@@ -2399,8 +2032,6 @@ advancedSettings:
'harv-auto-rotate-rke2-certs': The certificate rotation mechanism relies on Rancher. Harvester will automatically update certificates generation to trigger rotation.
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on Harvester administration kubeconfig files. Default is 0, which means to never expire.'
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
'harv-longhorn-v2-data-engine-hugepage-enabled': 'Enable hugepages when using the Longhorn V2 data engine. Default is true. Disabling hugepages reduces memory pressure on low-spec nodes and increases deployment flexibility. However, performance may be lower compared to running with hugepages.'
'harv-longhorn-v2-data-engine-memory-size': 'Configure the amount of memory allocated to the SPDK target daemon when using the Longhorn V2 data engine. Default is 2048 MiB.'
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
'harv-upgrade-config': 'Configure image preloading and VM restore options for upgrades. See related fields in <a href="{url}" target="_blank" rel="noopener">settings/upgrade-config</a>'
'harv-vm-migration-network': 'Segregated network for VM migration traffic.'
@@ -2408,7 +2039,6 @@ advancedSettings:
'harv-max-hotplug-ratio': 'The ratio for kubevirt to limit the maximum CPU and memory that can be hotplugged to a VM. The value could be an integer between 1 and 20, default to 4.'
'harv-kubevirt-migration': 'Configure cluster-wide KubeVirt live migration parameters.'
'harv-instance-manager-resources': 'Configure resource percentage reservations for Longhorn instance manager V1 and V2. Valid instance manager CPU range between 0 - 40.'
'harv-cluster-pod-security-standard': 'Enforce Kubernetes Pod Security Standards (PSS) at the cluster level.'
typeLabel:
kubevirt.io.virtualmachine: |-
@@ -2471,36 +2101,6 @@ typeLabel:
one { Virtual Private Cloud }
other { Virtual Private Clouds }
}
kubeovn.io.vlan: |-
{count, plural,
one { VLAN Network }
other { VLAN Networks }
}
kubeovn.io.providernetwork: |-
{count, plural,
one { Provider Network }
other { Provider Networks }
}
kubeovn.io.vpcnatgateway: |-
{count, plural,
one { NAT Gateway }
other { NAT Gateways }
}
kubeovn.io.iptablessnatrule: |-
{count, plural,
one { Source Rule }
other { Source Rules }
}
kubeovn.io.iptablesdnatrule: |-
{count, plural,
one { Destination Rule }
other { Destination Rules }
}
kubeovn.io.iptableseip: |-
{count, plural,
one { External IP }
other { External IPs }
}
networking.k8s.io.networkpolicy: |-
{count, plural,
one { Network Policy }
@@ -2561,17 +2161,16 @@ typeLabel:
one { PCI Device }
other { PCI Devices }
}
persistentvolumeclaim: |-
{count, plural,
one { Volume }
other { Volumes }
}
network.harvesterhci.io.clusternetwork: |-
{count, plural,
one { Cluster Network }
other { Cluster Networks }
}
network.harvesterhci.io.hostnetworkconfig: |-
{count, plural,
one { Host Network }
other { Host Networks }
}
harvesterhci.io.addon: |-
{count, plural,
one { Add-on }
@@ -2641,28 +2240,3 @@ typeLabel:
one { Virtual Machine Import }
other { Virtual Machine Imports }
}
forklift.konveyor.io.provider: |-
{count, plural,
one { Provider }
other { Providers }
}
forklift.konveyor.io.networkmap: |-
{count, plural,
one { Network Map }
other { Network Maps }
}
forklift.konveyor.io.storagemap: |-
{count, plural,
one { Storage Map }
other { Storage Maps }
}
forklift.konveyor.io.plan: |-
{count, plural,
one { Migration Plan }
other { Migration Plans }
}
forklift.konveyor.io.migration: |-
{count, plural,
one { Migration }
other { Migrations }
}
@@ -53,6 +53,7 @@ export default {
devices() {
const inStore = this.$store.getters['currentProduct'].inStore;
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
data.forEach((row) => {
@@ -3,6 +3,7 @@ import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading';
import ResourceTable from '@shell/components/ResourceTable';
import BadgeState from '@shell/components/formatter/BadgeStateFormatter';
import { NAME, AGE, NAMESPACE, STATE } from '@shell/config/table-headers';
import { NETWORK_ATTACHMENT, SCHEMA } from '@shell/config/types';
import { allHash } from '@shell/utils/promise';
@@ -112,7 +113,6 @@ export default {
value: 'connectivity',
labelKey: 'tableHeaders.routeConnectivity',
formatter: 'NetworkRouteConnectivity',
tooltip: 'harvester.tableHeaders.routeConnectivityTooltip',
formatterOpts: { arbitrary: true },
width: 130,
},
@@ -166,7 +166,6 @@ export default {
:schema="schema"
:groupable="true"
:rows="filterRows"
:ignore-filter="true"
key-field="_key"
>
<template #cell:state="{row}">
@@ -106,8 +106,8 @@ export default {
name: 'AttachedVM',
labelKey: 'tableHeaders.attachedVM',
type: 'attached',
value: 'attachVMName',
sort: 'attachVMName',
value: 'spec.claimRef',
sort: 'name',
},
{
name: 'VolumeSnapshotCounts',
@@ -134,8 +134,8 @@ export default {
return row?.attachVM?.detailLocation;
},
getAttachedVMName(row) {
return row.attachVMName || '';
getVMName(row) {
return row.attachVM?.metadata?.name || '';
},
isInternalStorageClass(storageClassName) {
@@ -173,10 +173,10 @@ export default {
<template #cell:AttachedVM="{row}">
<div>
<router-link
v-if="getAttachedVMName(row)"
v-if="getVMName(row)"
:to="goTo(row)"
>
{{ getAttachedVMName(row) }}
{{ getVMName(row) }}
</router-link>
</div>
</template>
-7
View File
@@ -67,13 +67,6 @@ export default {
NAMESPACE,
CIDR_BLOCK,
PROTOCOL,
{
name: 'vlan',
labelKey: 'harvester.subnet.vlan.label',
value: 'spec.vlan',
sort: 'spec.vlan',
formatter: 'HarvesterVlan',
},
PROVIDER,
AGE
];
@@ -1,5 +1,4 @@
<script>
import { mapGetters } from 'vuex';
import ResourceTable from '@shell/components/ResourceTable';
import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
import {
@@ -13,18 +12,11 @@ import { HCI } from '../types';
import HarvesterVmState from '../formatters/HarvesterVmState';
import ConsoleBar from '../components/VMConsoleBar';
const ENCRYPTED_VOLUME_TOOLTIP_KEYS = {
all: 'harvester.virtualMachine.volume.lockTooltip.all',
partial: 'harvester.virtualMachine.volume.lockTooltip.partial',
};
export const VM_HEADERS = [
STATE,
{
...NAME,
width: 350,
value: 'nameDisplay',
sort: ['nameDisplay'],
},
NAMESPACE,
{
@@ -101,9 +93,19 @@ export default {
this.hasNode = true;
}
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.NODE_NETWORK)) {
_hash.nodeNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.NODE_NETWORK });
}
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.CLUSTER_NETWORK)) {
_hash.clusterNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK });
}
const hash = await allHash(_hash);
this.allVMs = hash.vms;
this.allNodeNetworks = hash.nodeNetworks || [];
this.allClusterNetworks = hash.clusterNetworks || [];
},
data() {
@@ -111,14 +113,14 @@ export default {
hasNode: false,
allVMs: [],
allVMIs: [],
allNodeNetworks: [],
allClusterNetworks: [],
restartNotificationDisplayed: false,
HCI
};
},
computed: {
...mapGetters({ actionCb: 'action-menu/performCallbackData' }),
headers() {
const restoreCol = {
name: 'restoreProgress',
@@ -161,12 +163,6 @@ export default {
*/
hasBackUpRestoreInProgress() {
return !!this.rows.find((r) => r.restoreResource && !r.restoreResource.fromSnapshot && !r.restoreResource.isComplete);
},
vmRestartRequiredNames() {
return this.allVMs
.filter((vm) => vm.isRestartRequired)
.map((vm) => vm.metadata.name);
}
},
@@ -185,42 +181,53 @@ export default {
},
watch: {
actionCb(neu) {
if (neu?.clearTableSelection) {
this.$refs.resourceTable.clearSelection();
this.$store.dispatch('action-menu/clearCallbackData');
}
},
allVMs: {
handler(neu) {
const vmNames = [];
vmRestartRequiredNames(vmNames) {
const count = vmNames.length;
neu.forEach((vm) => {
if (vm.isRestartRequired) {
vmNames.push(vm.metadata.name);
}
});
const count = vmNames.length;
if (count === 0 && this.restartNotificationDisplayed) {
this.restartNotificationDisplayed = false;
if ( count === 0 && this.restartNotificationDisplayed) {
this.restartNotificationDisplayed = false;
return;
}
if (count > 0) {
// clear old notification before showing new one
if (this.restartNotificationDisplayed) {
this.$store.dispatch('growl/clear');
return;
}
this.$store.dispatch('growl/warning', {
title: this.t('harvester.notification.restartRequired.title', { count }),
message: this.t('harvester.notification.restartRequired.message', { vmNames: vmNames.join(', ') }),
timeout: 10000,
}, { root: true });
this.restartNotificationDisplayed = true;
}
if (count > 0) {
// clear old notification before showing new one
if (this.restartNotificationDisplayed) {
this.$store.dispatch('growl/clear');
}
}
if (count > 0 && vmNames.length > 0) {
this.$store.dispatch('growl/warning', {
title: this.t('harvester.notification.restartRequired.title', { count }),
message: this.t('harvester.notification.restartRequired.message', { vmNames: vmNames.join(', ') }),
timeout: 10000,
}, { root: true });
this.restartNotificationDisplayed = true;
}
},
deep: true,
}
},
methods: {
lockIconTooltipMessage(row) {
const key = ENCRYPTED_VOLUME_TOOLTIP_KEYS[row.encryptedVolumeType];
const message = '';
return key ? this.t(key) : '';
if (row.encryptedVolumeType === 'all') {
return this.t('harvester.virtualMachine.volume.lockTooltip.all');
} else if (row.encryptedVolumeType === 'partial') {
return this.t('harvester.virtualMachine.volume.lockTooltip.partial');
}
return message;
}
}
};
@@ -230,7 +237,6 @@ export default {
<Loading v-if="$fetchState.pending" />
<div v-else>
<ResourceTable
ref="resourceTable"
v-bind="$attrs"
:headers="headers"
default-sort-by="age"
@@ -247,6 +253,8 @@ export default {
<HarvesterVmState
class="vmstate"
:row="scope.row"
:all-node-network="allNodeNetworks"
:all-cluster-network="allClusterNetworks"
/>
</div>
</template>
@@ -257,16 +265,16 @@ export default {
v-if="scope.row.type !== HCI.VMI"
:to="scope.row.detailLocation"
>
{{ scope.row.nameDisplay }}
{{ scope.row.metadata.name }}
<i
v-if="scope.row.encryptedVolumeType !== 'none'"
v-if="lockIconTooltipMessage(scope.row)"
v-tooltip="lockIconTooltipMessage(scope.row)"
class="icon icon-lock"
:class="{'green-icon': scope.row.encryptedVolumeType === 'all', 'yellow-icon': scope.row.encryptedVolumeType === 'partial'}"
/>
</router-link>
<span v-else>
{{ scope.row.nameDisplay }}
{{ scope.row.metadata.name }}
</span>
<ConsoleBar
:resource-type="scope.row"
@@ -59,7 +59,7 @@ export default {
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
return configs.filter((config) => config.spec?.clusterNetwork !== 'mgmt');
return configs;
},
vlanConfigSchema() {
@@ -121,22 +121,6 @@ export default {
return [...this.rows, ...fakeRows];
},
managementVlanConfigs() {
const inStore = this.$store.getters['currentProduct'].inStore;
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
return configs
.filter((vc) => vc.spec?.clusterNetwork === 'mgmt')
.map((vc) => ({
id: vc.id,
node: vc.id.replace('mgmt-vlanconfig-', ''),
bondMode: vc.spec?.uplink?.bondOptions?.mode || '-',
interfaces: (vc.spec?.uplink?.nics?.length ? vc.spec.uplink.nics.join(', ') : '-'),
mtu: vc.spec?.uplink?.linkAttributes?.mtu || '-',
}));
},
},
methods: {
@@ -240,29 +224,6 @@ export default {
</button>
</div>
</div>
<div
v-if="group.key === 'mgmt'"
class="mgmt-summary"
>
<div class="mgmt-header">
<div>{{ t('harvester.tableHeaders.vm.node') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.bondOptions.mode.label') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.nics.label') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.linkAttributes.mtu.label') }}</div>
</div>
<div
v-for="config in managementVlanConfigs"
:key="config.id"
class="mgmt-row"
>
<div>{{ config.node }}</div>
<div>{{ config.bondMode }}</div>
<div>{{ config.interfaces }}</div>
<div>{{ config.mtu }}</div>
</div>
</div>
</template>
<template
v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs"
@@ -312,21 +273,6 @@ export default {
}
}
}
.mgmt-header,
.mgmt-row {
display: grid;
grid-template-columns: 2fr 2fr 2fr 1fr;
padding: 8px 12px;
}
.mgmt-header {
font-weight: 600;
}
.mgmt-row {
border-top: 1px solid var(--border);
}
</style>
<style lang="scss">
@@ -1,86 +0,0 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
import { HCI } from '../types';
const UNDERLAY = {
name: 'underlay',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.underlay',
tooltip: 'harvester.tableHeaders.hostNetworkConfig.underlayTooltip',
value: 'spec.underlay',
sort: 'spec.underlay',
formatter: 'HarvesterBoolean',
};
const VLAN_ID = {
name: 'vlanID',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.vlanID',
value: 'spec.vlanID',
sort: 'spec.vlanID',
};
const MODE = {
name: 'mode',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.mode',
value: 'spec.mode',
sort: 'spec.mode',
formatter: 'HarvesterHostNetworkConfigMode',
};
const CLUSTER_NETWORK = {
name: 'clusterNetwork',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.clusterNetwork',
value: 'spec.clusterNetwork',
sort: 'spec.clusterNetwork',
align: 'center',
};
export default {
name: 'HarvesterListHostNetworkConfig',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HOST_NETWORK_CONFIG });
},
data() {
return { rows: [] };
},
computed: {
schema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/schemaFor`](HCI.HOST_NETWORK_CONFIG);
},
headers() {
return [
STATE,
NAME_COL,
UNDERLAY,
VLAN_ID,
MODE,
CLUSTER_NETWORK,
AGE,
];
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:headers="headers"
:rows="rows"
:schema="schema"
key-field="_key"
/>
</template>
-23
View File
@@ -255,29 +255,6 @@ export default {
return this.convertToJson(userData)?.ssh_authorized_keys || [];
},
getSysprepConfig(spec) {
const sysprepVolume = spec?.template?.spec?.volumes?.find(
(v) => v.name === 'sysprep' && v.sysprep?.secret
);
if (!sysprepVolume) {
return { secretName: '', xmlContent: '' };
}
const inStore = this.$store.getters['currentProduct'].inStore;
const namespace = this.value.metadata.namespace;
const secretName = sysprepVolume.sysprep.secret.name;
const secret = this.$store.getters[`${ inStore }/byId`](
SECRET,
`${ namespace }/${ secretName }`
);
return {
secretName: `${ namespace }/${ secretName }`,
xmlContent: secret?.decodedData?.['autounattend.xml'] || ''
};
},
compareSSHValue(a = '', b = '') {
const r = /(\r\n\t|\n|\r\t)|(\s*)/gm;
+10 -219
View File
@@ -12,7 +12,7 @@ import { base64Decode } from '@shell/utils/crypto';
import { formatSi, parseSi } from '@shell/utils/units';
import { _CLONE, _CREATE, _VIEW } from '@shell/config/query-params';
import {
PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, SERVICE_ACCOUNT, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN
PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN
} from '@shell/config/types';
import { HOSTNAME } from '@shell/config/labels-annotations';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
@@ -25,7 +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, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
@@ -102,8 +102,6 @@ export default {
vmims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIM }),
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }),
secrets: this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }),
configMaps: this.$store.dispatch(`${ inStore }/findAll`, { type: CONFIG_MAP }),
serviceAccounts: this.$store.dispatch(`${ inStore }/findAll`, { type: SERVICE_ACCOUNT }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
longhornV2Engine: this.$store.dispatch(`${ inStore }/find`, { type: LONGHORN.SETTINGS, id: LONGHORN_V2_DATA_ENGINE }),
};
@@ -158,7 +156,6 @@ export default {
imageId: '',
diskRows: [],
networkRows: [],
filesystemRows: [],
machineType: '',
machineTypes: [],
secretName: '',
@@ -186,7 +183,6 @@ export default {
terminationGracePeriodSeconds: '',
cpuPinning: false,
cpuModel: '',
sysprep: { secretName: '', xmlContent: '' },
};
},
@@ -444,18 +440,6 @@ export default {
this['imageId'] = imageId;
this['diskRows'] = diskRows;
this['filesystemRows'] = this.getFilesystemRows(vm);
let sysprepConfig = { secretName: '', xmlContent: '' };
if (osType === 'windows') {
sysprepConfig = this.getSysprepConfig(spec);
}
this['sysprep'] = {
secretName: sysprepConfig.secretName || this.sysprep?.secretName || '',
xmlContent: sysprepConfig.xmlContent || this.sysprep?.xmlContent || '',
};
this.refreshYamlEditor();
},
@@ -621,8 +605,7 @@ export default {
out = sortBy(out, 'bootOrder');
// Filter out cloudinitdisk and sysprep disk from UI display.
return out.filter( (O) => O.name !== 'cloudinitdisk' && O.name !== 'sysprep');
return out.filter( (O) => O.name !== 'cloudinitdisk');
},
getNetworkRows(vm, config) {
@@ -630,8 +613,6 @@ export default {
const networks = vm.spec.template.spec.networks || [];
const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
const annotations = vm.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const out = interfaces.map( (I, index) => {
const network = networks.find( (N) => I.name === N.name);
@@ -648,7 +629,6 @@ export default {
newCreateId: (fromTemplate || init) ? randomStr(10) : false,
model: I.model,
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
staticIp: annotations[`${ staticIpPrefix }${ I.name }`] || '',
};
});
@@ -661,80 +641,6 @@ export default {
this.parseAccessCredentials();
this.parseNetworkRows(this.networkRows);
this.parseDiskRows(this.diskRows);
this.parseFilesystemRows();
},
getFilesystemRows(vm) {
const _filesystems = vm.spec.template.spec.domain.devices?.filesystems || [];
const _volumes = vm.spec.template.spec.volumes || [];
return _filesystems.map((fs) => {
const volume = _volumes.find((v) => v.name === fs.name);
let fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP;
let resourceName = '';
if (volume?.configMap) {
fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP;
resourceName = volume.configMap.name;
} else if (volume?.secret) {
fsType = FILESYSTEM_SOURCE_TYPE.SECRET;
resourceName = volume.secret.secretName;
} else if (volume?.serviceAccount) {
fsType = FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT;
resourceName = volume.serviceAccount.serviceAccountName;
}
return {
fsType,
volumeName: fs.name,
resourceName,
};
});
},
parseFilesystemRows() {
const completedRows = this.filesystemRows.filter(
(r) => r.fsType && r.volumeName && r.resourceName
);
const filesystems = completedRows.map((r) => ({
name: r.volumeName,
virtiofs: {},
}));
const fsVolumes = completedRows.map((r) => {
if (r.fsType === FILESYSTEM_SOURCE_TYPE.CONFIGMAP) {
return {
name: r.volumeName,
configMap: { name: r.resourceName },
};
} else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SECRET) {
return {
name: r.volumeName,
secret: { secretName: r.resourceName },
};
} else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT) {
return {
name: r.volumeName,
serviceAccount: { serviceAccountName: r.resourceName },
};
}
return null;
}).filter(Boolean);
if (filesystems.length > 0) {
this.spec.template.spec.domain.devices['filesystems'] = filesystems;
} else {
delete this.spec.template.spec.domain.devices['filesystems'];
}
if (fsVolumes.length > 0) {
if (!this.spec.template.spec.volumes) {
this.spec.template.spec['volumes'] = [];
}
this.spec.template.spec.volumes.push(...fsVolumes);
}
},
parseOther() {
@@ -750,8 +656,6 @@ export default {
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
this.syncStaticIpAnnotations(vm);
// parse reserved memory
if (!this.reservedMemory) {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
@@ -773,30 +677,6 @@ export default {
}
},
syncStaticIpAnnotations(vm) {
if (!vm?.metadata) {
return;
}
if (!vm.metadata.annotations) {
vm.metadata.annotations = {};
}
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
Object.keys(vm.metadata.annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
delete vm.metadata.annotations[key];
}
});
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
vm.metadata.annotations[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
}
});
},
setCPUAndMemory() {
if (this.cpuMemoryHotplugEnabled) {
// set CPU
@@ -870,32 +750,6 @@ export default {
this.secretName = this.generateSecretName(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.
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) }`;
}
// Preserve/attach sysprep whenever a secret is selected/known.
if (this.sysprep.secretName) {
disks.push({
name: 'sysprep',
cdrom: { bus: 'sata' }
});
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
volumes.push({
name: 'sysprep',
sysprep: { secret: { name: secretName } }
});
}
}
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
if (!this.isWindows) {
disks.push({
@@ -904,6 +758,7 @@ export default {
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
@@ -1052,22 +907,14 @@ export default {
const specInterfaces = this.spec?.template?.spec?.domain?.devices?.interfaces;
const mergedInterfaces = this.mergeInterfaceList(specInterfaces, interfaces);
const devices = {
...this.spec.template.spec.domain.devices,
interfaces: mergedInterfaces,
};
if (this.isEdit && networkRow.length === 0) {
devices.autoattachPodInterface = false;
} else {
delete devices.autoattachPodInterface;
}
const spec = {
...this.spec.template.spec,
domain: {
...this.spec.template.spec.domain,
devices,
devices: {
...this.spec.template.spec.domain.devices,
interfaces: mergedInterfaces,
},
},
networks
};
@@ -1549,42 +1396,6 @@ export default {
}
},
async saveSysprepConfig(vm) {
if (!this.isWindows) {
return;
}
if (!this.sysprep.secretName?.trim?.() && !this.sysprep.xmlContent?.trim?.()) {
return;
}
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
const namespace = vm.metadata.namespace;
const namespacedName = `${ namespace }/${ secretName }`;
let secret;
try {
secret = await this.$store.dispatch('harvester/find', { type: SECRET, id: namespacedName });
} catch (e) {
if (e?.status !== 404) {
throw e;
}
secret = await this.$store.dispatch('harvester/create', {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { [HCI_ANNOTATIONS.WINDOWS_SYSPREP]: 'true' },
},
});
}
secret.setData('autounattend.xml', this.sysprep.xmlContent);
await secret.save();
},
getAccessCredentialsValidation() {
const errors = [];
@@ -1788,18 +1599,7 @@ export default {
const specDevice = specDeviceMap.get(device.name);
if (specDevice) {
const merged = { ...specDevice, ...device };
// A disk entry must be either `disk` or `cdrom`, never both.
const type = device?.cdrom ? CD_ROM : device?.disk ? HARD_DISK : '';
if (type === CD_ROM) {
delete merged.disk;
} else if (type === HARD_DISK) {
delete merged.cdrom;
}
return merged;
return { ...specDevice, ...device };
}
return device;
@@ -1847,15 +1647,6 @@ export default {
},
watch: {
networkRows: {
handler() {
const vm = this.resourceType === HCI.VM ? this.value : this.value?.spec?.vm;
this.syncStaticIpAnnotations(vm);
},
deep: true
},
diskRows: {
handler(neu, old) {
if (Array.isArray(neu)) {
@@ -1865,7 +1656,7 @@ export default {
const oldImageId = old[0]?.image;
if (this.isCreate && oldImageId !== imageId && imageId && osType) {
if (this.isCreate && oldImageId === imageId && imageId) {
this.osType = osType;
}
}
@@ -9,7 +9,6 @@ import HarvesterResource from './harvester';
export default class MIGCONFIGURATION extends HarvesterResource {
get _availableActions() {
let out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out = out.map((action) => {
if (action.action === 'showConfiguration') {
@@ -27,13 +26,13 @@ export default class MIGCONFIGURATION extends HarvesterResource {
out.push(
{
action: 'enableConfig',
enabled: !this.isEnabled && canUpdate,
enabled: !this.isEnabled,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableConfig',
enabled: this.isEnabled && canUpdate,
enabled: this.isEnabled,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
},
@@ -63,27 +62,13 @@ export default class MIGCONFIGURATION extends HarvesterResource {
}
get stateDisplay() {
if (this.configStatus === 'out-of-sync') {
return this.t('harvester.migconfiguration.status.outOfSync');
}
return this.actualState;
}
get stateDescription() {
if (this.status?.message) {
return this.status.message;
}
return super.stateDescription;
}
get stateColor() {
if (this.configStatus === 'out-of-sync') {
return 'text-warning';
}
const state = this.actualState;
return colorForState(this.actualState);
return colorForState(state);
}
get isEnabled() {
@@ -34,7 +34,7 @@ export default class PCIDevice extends SteveModel {
out.push(
{
action: 'enablePassthroughBulk',
enabled: !this.isEnabling && !this.isvGPUDevice && this.canUpdate,
enabled: !this.isEnabling && !this.isvGPUDevice,
icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough',
bulkable: true,
@@ -43,7 +43,7 @@ export default class PCIDevice extends SteveModel {
},
{
action: 'disablePassthrough',
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice && this.canUpdate,
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough',
bulkable: true,
@@ -54,10 +54,6 @@ export default class PCIDevice extends SteveModel {
return out;
}
get canUpdate() {
return !!this.linkFor('update');
}
get isvGPUDevice() {
if (!this.vGPUAsPCIDeviceFeatureEnabled) {
return false;
@@ -16,13 +16,13 @@ export default class SRIOVDevice extends SteveModel {
out.push(
{
action: 'enableDevice',
enabled: !this.isEnabled && this.canUpdate,
enabled: !this.isEnabled,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableDevice',
enabled: this.isEnabled && this.canUpdate,
enabled: this.isEnabled,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
},
@@ -31,10 +31,6 @@ export default class SRIOVDevice extends SteveModel {
return out;
}
get canUpdate() {
return !!this.linkFor('update');
}
get canYaml() {
return false;
}
@@ -33,7 +33,7 @@ export default class USBDevice extends SteveModel {
out.push(
{
action: 'enablePassthroughBulk',
enabled: !this.passthroughClaim && !this.status.enabled && this.canUpdate,
enabled: !this.passthroughClaim && !this.status.enabled,
icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough',
bulkable: true,
@@ -42,7 +42,7 @@ export default class USBDevice extends SteveModel {
},
{
action: 'disablePassthrough',
enabled: this.status.enabled && this.canUpdate,
enabled: this.status.enabled,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough',
bulkable: true,
@@ -53,10 +53,6 @@ export default class USBDevice extends SteveModel {
return out;
}
get canUpdate() {
return !!this.linkFor('update');
}
get canYaml() {
return false;
}
@@ -27,18 +27,17 @@ const STATUS_DISPLAY = {
export default class VGpuDevice extends SteveModel {
get _availableActions() {
const out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out.push(
{
action: 'enableVGpu',
enabled: !this.isEnabled && canUpdate,
enabled: !this.isEnabled,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableVGpu',
enabled: this.isEnabled && canUpdate,
enabled: this.isEnabled,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
bulkable: true,
@@ -1,286 +0,0 @@
import HarvesterResource from './harvester';
import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester';
import { randomStr } from '@shell/utils/string';
export default class ForkliftPlan extends HarvesterResource {
get listLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
};
}
/**
* The base HarvesterResource sets `doneOverride` to `listLocation`, which would
* force every single-item delete to redirect to the VM Migration dashboard.
* Return undefined so the delete flow stays on the current page (list/dashboard)
* and only falls back to the shell's default navigation when a now-invalid
* detail page is being viewed.
*/
get doneOverride() {
return undefined;
}
get planFailed() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
}
get planCritical() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.category === 'Critical' && c.status === 'True');
}
get criticalMessages() {
const conditions = this.status?.conditions || [];
return conditions
.filter((c) => c.category === 'Critical' && c.status === 'True')
.map((c) => c.message);
}
get stateDescription() {
if (this.metadata?.deletionTimestamp) {
return null;
}
const messages = [];
const conditions = this.status?.conditions || [];
if (this.planFailed) {
const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message;
if (failedMsg) {
messages.push(failedMsg);
}
}
if (this.planCritical) {
messages.push(...this.criticalMessages);
}
return messages.length > 0 ? messages.join(' ') : null;
}
get stateObj() {
return {
error: this.planFailed || this.planCritical,
transitioning: this.isMigrating || this.isPendingMigrationStart || !!this.metadata?.deletionTimestamp,
message: this.stateDescription,
};
}
get isMigrating() {
const migration = this.status?.migration;
return !!migration?.started && !migration?.completed;
}
// Right after creating a plan, controller status can lag briefly.
// Treat this window as in-progress so we don't flash a succeeded badge.
get isPendingMigrationStart() {
const vmCount = this.spec?.vms?.length || 0;
return vmCount > 0 && !this.planFailed && !this.planCritical && !this.planCanceled && !this.planSucceeded && !this.isMigrating;
}
get planCanceled() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return false;
}
const latest = history[history.length - 1];
const conditions = latest.conditions || [];
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
}
get planSucceeded() {
const migration = this.status?.migration;
return !!migration?.completed && !this.planFailed && !this.planCanceled;
}
get stateDisplay() {
if (this.metadata?.deletionTimestamp) {
return 'Terminating';
}
if (this.planFailed) {
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCritical) {
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCanceled) {
return this.t('harvester.addons.vmMigration.plan.states.canceled');
}
if (this.isMigrating) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
if (this.isPendingMigrationStart) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
return this.t('harvester.addons.vmMigration.plan.states.active');
}
get stateBackground() {
if (this.metadata?.deletionTimestamp) {
return 'bg-info';
}
if (this.planFailed) {
return 'bg-error';
}
if (this.planCritical) {
return 'bg-error';
}
if (this.planCanceled) {
return 'bg-warning';
}
if (this.isMigrating) {
return 'bg-info';
}
if (this.isPendingMigrationStart) {
return 'bg-info';
}
return 'bg-success';
}
get isForkliftDashboard() {
const route = this.currentRouter()?.currentRoute?.value;
return route?.name?.endsWith('-vm-migration');
}
get _availableActions() {
const canStop = this.isMigrating && !this.planCanceled;
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
const out = [];
if (canStart) {
out.push({
action: 'startMigration',
enabled: true,
icon: 'icon icon-play',
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
});
}
if (canStop) {
out.push({
action: 'stopMigration',
enabled: true,
icon: 'icon icon-pause',
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
});
}
if (this.isForkliftDashboard) {
out.push({
action: 'promptRemove',
altAction: 'remove',
label: this.t('action.remove'),
icon: 'icon icon-trash',
bulkable: true,
enabled: this.canDelete,
bulkAction: 'promptRemove',
weight: -10,
});
return out;
} else {
out.push(...super._availableActions);
return out;
}
}
async stopMigration() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return;
}
const latest = history[history.length - 1];
const migrationName = latest.migration?.name;
if (migrationName) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
await this.$dispatch('request', { url, method: 'DELETE' });
}
}
async startMigration() {
const namespace = this.metadata.namespace;
const history = this.status?.migration?.history || [];
// Delete previous migrations before starting a new one
for (const entry of history) {
const name = entry.migration?.name;
if (name) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
try {
await this.$dispatch('request', { url, method: 'DELETE' });
} catch (e) {
// Migration may already be gone — ignore 404s
}
}
}
const migration = await this.$dispatch('create', {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
namespace,
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
uid: this.metadata.uid,
blockOwnerDeletion: true,
},
],
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
namespace,
},
},
});
await migration.save();
}
async deletePlan() {
await this.remove();
}
}
@@ -1,36 +0,0 @@
import HarvesterResource from './harvester';
import { PRODUCT_NAME } from '../config/harvester';
export default class ForkliftProvider extends HarvesterResource {
get _createLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
}
};
}
get _editLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
};
}
/**
* Deleting a Provider cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove(opt = {}) {
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
return this._remove(opt);
}
}
+1 -11
View File
@@ -24,7 +24,7 @@ const OBSCURE_NAMESPACE_PREFIX = [
export default class HciNamespace extends namespace {
get _availableActions() {
let out = super._availableActions;
const out = super._availableActions;
const remove = out.findIndex((a) => a.action === 'promptRemove');
const promptRemove = {
@@ -53,16 +53,6 @@ export default class HciNamespace extends namespace {
insertAt(out, out.length - 1, promptRemove);
insertAt(out, out.length - 5, editQuotaAction);
const canUpdate = !!this.linkFor('update');
out = out.map((action) => {
if (['move'].includes(action.action)) {
return { ...action, enabled: action.enabled && canUpdate };
}
return action;
});
return out;
}
@@ -235,10 +235,6 @@ export default class HciPv extends HarvesterResource {
return allVMs.find(findAttachVM);
}
get attachVMName() {
return this.attachVM?.nameDisplay || this.attachVM?.metadata?.name || '';
}
get isAvailable() {
const unAvailable = ['Resizing', 'Not Ready'];
-14
View File
@@ -5,20 +5,6 @@ import Secret from '@shell/models/secret';
import { NAMESPACE } from '@shell/config/types';
export default class HciSecret extends Secret {
get _availableActions() {
let out = super._availableActions;
out = out.map((action) => {
if (['download'].includes(action.action)) {
return { ...action, enabled: !!this.linkFor('update') };
}
return action;
});
return out;
}
// prevent harvester secret detail page be overridden.
// See isFullPageOverride in https://github.com/rancher/dashboard/blob/master/shell/components/ResourceDetail/index.vue
get fullDetailPageOverride() {
@@ -96,10 +96,6 @@ export default class HciStorageClass extends StorageClass {
return this.$rootGetters['harvester-common/getFeatureEnabled']('volumeEncryption');
}
get expandOnlineEncryptedVolumeFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('expandOnlineEncryptedVolume');
}
get thirdPartyStorageFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
}
@@ -110,15 +106,6 @@ export default class HciStorageClass extends StorageClass {
get availableActions() {
let out = super.availableActions || [];
const canUpdate = !!this.linkFor('update');
out = out.map((action) => {
if (['setDefault', 'setAsDefault', 'resetDefault'].includes(action.action)) {
return { ...action, enabled: canUpdate };
}
return action;
});
if (this.isInternalStorageClass()) {
out = out.filter((action) => {
+1 -13
View File
@@ -4,8 +4,6 @@ import { HCI as HCI_ANNOTATIONS } from '../config/labels-annotations';
import HarvesterResource from './harvester';
import { HCI } from '../types';
const HARVESTER_NVIDIA_DRIVER_TOOLKIT = 'harvester-system/nvidia-driver-toolkit';
export default class HciAddonConfig extends HarvesterResource {
get availableActions() {
const out = super._availableActions;
@@ -21,10 +19,9 @@ export default class HciAddonConfig extends HarvesterResource {
out.push(rancherDashboard);
}
const canUpdate = !!this.linkFor('update');
const toggleAddon = {
action: 'toggleAddon',
enabled: canUpdate,
enabled: true,
icon: this.spec.enabled ? 'icon icon-pause' : 'icon icon-play',
label: this.spec.enabled ? this.t('generic.disable') : this.t('generic.enable'),
};
@@ -48,15 +45,6 @@ export default class HciAddonConfig extends HarvesterResource {
}
}
if (!this.spec.enabled && this.id === HARVESTER_NVIDIA_DRIVER_TOOLKIT) {
this.$dispatch('promptModal', {
resources: [this],
component: 'HarvesterEnableNvidiaDriverToolkit',
});
return;
}
this.spec.enabled = !this.spec.enabled;
await this.save();
} catch (err) {
@@ -3,20 +3,6 @@ import { findBy } from '@shell/utils/array';
import HarvesterResource from './harvester';
export default class HciKeypair extends HarvesterResource {
get _availableActions() {
let out = super._availableActions;
out = out.map((action) => {
if (['download'].includes(action.action)) {
return { ...action, enabled: !!this.linkFor('update') };
}
return action;
});
return out;
}
get stateDisplay() {
const conditions = get(this, 'status.conditions');
const status = (findBy(conditions, 'type', 'validated') || {}).status ;
@@ -19,18 +19,16 @@ export default class ScheduleVmBackup extends HarvesterResource {
}
});
const canUpdate = !!this.linkFor('update');
return [
{
action: 'resumeSchedule',
enabled: canUpdate && ucFirst(this.state) === STATES.suspended.label,
enabled: ucFirst(this.state) === STATES.suspended.label,
icon: 'icons icon-play',
label: this.t('harvester.action.resumeSchedule'),
},
{
action: 'suspendSchedule',
enabled: canUpdate && ucFirst(this.state) === STATES.active.label,
enabled: ucFirst(this.state) === STATES.active.label,
icon: 'icons icon-pause',
label: this.t('harvester.action.suspendSchedule'),
},
@@ -39,6 +39,9 @@ function isReady() {
export default class HciVmImage extends HarvesterResource {
get availableActions() {
let out = super._availableActions;
const toFilter = ['goToEditYaml'];
out = out.filter( (A) => !toFilter.includes(A.action));
// show `Clone` only when imageSource is `download`
if (this.imageSource !== 'download') {
@@ -52,7 +55,6 @@ export default class HciVmImage extends HarvesterResource {
canCreateVM = false;
}
const canCreateImage = !!this.$getters?.['schemaFor']?.(HCI.IMAGE)?.collectionMethods?.some((method) => method.toLowerCase() === 'post');
const customActions = this.isReady ? [
{
action: 'createFromImage',
@@ -62,13 +64,13 @@ export default class HciVmImage extends HarvesterResource {
},
{
action: 'encryptImage',
enabled: this.volumeEncryptionFeatureEnabled && !this.isEncrypted && canCreateImage,
enabled: this.volumeEncryptionFeatureEnabled && !this.isEncrypted,
icon: 'icon icon-lock',
label: this.t('harvester.action.encryptImage'),
},
{
action: 'decryptImage',
enabled: this.volumeEncryptionFeatureEnabled && this.isEncrypted && canCreateImage,
enabled: this.volumeEncryptionFeatureEnabled && this.isEncrypted,
icon: 'icon icon-unlock',
label: this.t('harvester.action.decryptImage'),
},
@@ -23,17 +23,17 @@ export default class HciVmTemplateVersion extends HarvesterResource {
});
const schema = this.$getters['schemaFor'](HCI.VM);
let canCreateVM = false;
let canCreateVM = true;
if (schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase())) ) {
canCreateVM = true;
if ( schema && !schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase())) ) {
canCreateVM = false;
}
return [
{
action: 'launchFromTemplate',
icon: 'icon icon-spinner',
enabled: canCreateVM && this.isReady,
disabled: !canCreateVM || !this.isReady,
label: this.t('harvester.action.launchFormTemplate'),
},
{
@@ -83,60 +83,17 @@ const VMIPhase = {
let productInStore;
let _podOwnerMap = null;
let _podOwnerMapSource = null;
function getPodByOwnerName(rootGetters, inStore, ownerName) {
const podList = rootGetters[`${ inStore }/all`](POD);
if (!Array.isArray(podList)) {
return undefined;
}
// if not equals (usually means the pod list has been updated), we need to rebuild the map, otherwise we can reuse the map for better performance
if (_podOwnerMapSource !== podList) {
_podOwnerMap = new Map(); // use Map to store ownerReference name and pod mapping
for (const pod of podList) {
const refName = pod.metadata?.ownerReferences?.[0]?.name;
if (refName) {
_podOwnerMap.set(refName, pod);
}
}
_podOwnerMapSource = podList;
}
return _podOwnerMap.get(ownerName);
}
function getPvcsByNames(rootGetters, inStore, names) {
const pvcList = rootGetters[`${ inStore }/all`](PVC);
if (!Array.isArray(pvcList)) {
return [];
}
const uniqueNames = new Set(names);
return pvcList.filter((pvc) => uniqueNames.has(pvc.metadata?.name));
}
const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims'];
export default class VirtVm extends HarvesterResource {
get availableActions() {
let out = super._availableActions;
if (this.isCloneBackendStorageCloning || this.isCloneBackendStorageFailed) {
out = out.filter(({ action }) => action !== 'goToClone');
}
const out = super._availableActions;
const clone = out.find((action) => action.action === 'goToClone');
if (clone) {
clone.action = 'goToCloneVM';
}
const canCreateVMSSchedule = !!this.$getters?.['schemaFor']?.(HCI.SCHEDULE_VM_BACKUP)?.collectionMethods?.find((x) => ['post'].includes(x.toLowerCase()));
return [
{
action: 'stopVM',
@@ -169,7 +126,6 @@ export default class VirtVm extends HarvesterResource {
},
{
action: 'restartVM',
altAction: 'altRestartVM',
enabled: !!this.actions?.restart,
icon: 'icon icon-refresh',
label: this.t('harvester.action.restart'),
@@ -177,19 +133,17 @@ export default class VirtVm extends HarvesterResource {
bulkAction: 'restartVM'
},
{
action: 'softrebootVM',
altAction: 'doSoftReboot',
enabled: !!this.actions?.softreboot,
icon: 'icon icon-pipeline',
label: this.t('harvester.action.softreboot')
action: 'softrebootVM',
enabled: !!this.actions?.softreboot,
icon: 'icon icon-pipeline',
label: this.t('harvester.action.softreboot')
},
{
action: 'startVM',
enabled: !!this.actions?.start,
icon: 'icon icon-play',
label: this.t('harvester.action.start'),
bulkable: true,
bulkAction: 'startVM'
action: 'startVM',
enabled: !!this.actions?.start,
icon: 'icon icon-play',
label: this.t('harvester.action.start'),
bulkable: true
},
{
action: 'backupVM',
@@ -217,7 +171,7 @@ export default class VirtVm extends HarvesterResource {
},
{
action: 'createSchedule',
enabled: canCreateVMSSchedule && this.schedulingVMBackupFeatureEnabled,
enabled: this.schedulingVMBackupFeatureEnabled,
icon: 'icon icon-history',
label: this.t('harvester.action.createSchedule')
},
@@ -234,12 +188,10 @@ export default class VirtVm extends HarvesterResource {
label: this.t('harvester.action.ejectCDROM')
},
{
action: 'migrateVM',
enabled: !!this.actions?.migrate,
icon: 'icon icon-copy',
label: this.t('harvester.action.vmMigrate'),
bulkable: true,
bulkAction: 'migrateVM'
action: 'migrateVM',
enabled: !!this.actions?.migrate,
icon: 'icon icon-copy',
label: this.t('harvester.action.migrate')
},
{
action: 'abortMigrationVM',
@@ -550,38 +502,16 @@ export default class VirtVm extends HarvesterResource {
});
}
async altRestartVM() {
await this.doActionGrowl('restart', {});
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
altStopVM() {
this.doActionGrowl('stop', {});
}
async altStopVM() {
await this.doActionGrowl('stop', {});
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
forceStop() {
this.doActionGrowl('forceStop', {});
}
async forceStop() {
await this.doActionGrowl('forceStop', {});
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
async startVM(resources = this) {
const list = Array.isArray(resources) ? resources : [resources];
for (const r of list) {
await r.doActionGrowl('start', {});
}
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
async download() {
await super.download();
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
async downloadBulk(items) {
await super.downloadBulk(items);
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
startVM() {
this.doActionGrowl('start', {});
}
migrateVM(resources = this) {
@@ -730,13 +660,16 @@ export default class VirtVm extends HarvesterResource {
get podResource() {
const inStore = this.productInStore;
const vmiResource = this.$rootGetters[`${ inStore }/byId`](HCI.VMI, this.id);
const podList = this.$rootGetters[`${ inStore }/all`](POD);
if (!vmiResource?.metadata?.name) {
return undefined;
}
return getPodByOwnerName(this.$rootGetters, inStore, vmiResource.metadata.name);
return podList.find((P) => {
return (
vmiResource?.metadata?.name &&
vmiResource?.metadata?.name === P.metadata?.ownerReferences?.[0].name
);
});
}
get isPaused() {
@@ -777,13 +710,17 @@ export default class VirtVm extends HarvesterResource {
get vmi() {
const inStore = this.productInStore;
return this.$rootGetters[`${ inStore }/byId`](HCI.VMI, this.id);
const vmis = this.$rootGetters[`${ inStore }/all`](HCI.VMI);
return vmis.find((VMI) => VMI.id === this.id);
}
get volumes() {
const pvcs = this.$rootGetters[`${ this.productInStore }/all`](PVC);
const volumeClaimNames = this.spec.template.spec.volumes?.map((v) => v.persistentVolumeClaim?.claimName).filter((v) => !!v) || [];
return getPvcsByNames(this.$rootGetters, this.productInStore, volumeClaimNames);
return pvcs.filter((pvc) => volumeClaimNames.includes(pvc.metadata.name));
}
get lvmVolumes() {
@@ -794,18 +731,6 @@ export default class VirtVm extends HarvesterResource {
return this.volumes.filter((volume) => volume?.isLonghornV2);
}
get cloneBackendStorageStatus() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS]?.toLowerCase() || '';
}
get isCloneBackendStorageCloning() {
return this.cloneBackendStorageStatus === 'cloning';
}
get isCloneBackendStorageFailed() {
return this.cloneBackendStorageStatus === 'failed';
}
get encryptedVolumeType() {
if (!this.volumes || this.volumes.length === 0) {
return 'none';
@@ -828,6 +753,17 @@ export default class VirtVm extends HarvesterResource {
return { status: 'VMI error', detailedMessage: vmiFailureCond.message };
}
if ((this.vmi || this.isVMCreated) && this.podResource) {
// const podStatus = this.podResource.getPodStatus;
// if (POD_STATUS_ALL_ERROR.includes(podStatus?.status)) {
// return {
// ...podStatus,
// status: 'LAUNCHER_POD_ERROR',
// pod: this.podResource,
// };
// }
}
return this?.vmi?.status?.phase;
}
@@ -861,21 +797,13 @@ export default class VirtVm extends HarvesterResource {
!this.isVMExpectedRunning &&
this.isVMCreated &&
this.vmi?.status?.phase === VMIPhase.Pending
) || this.isCloneBackendStorageCloning) {
) || (this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS] === 'cloning')) {
return { status: VMIPhase.Pending };
}
return null;
}
get isCloneFailed() {
if (this.isCloneBackendStorageFailed) {
return { status: VMIPhase.Failed };
}
return null;
}
get isStopping() {
if (this &&
!this.isVMExpectedRunning &&
@@ -973,7 +901,9 @@ export default class VirtVm extends HarvesterResource {
const inStore = this.productInStore;
const res = this.$rootGetters[`${ inStore }/byId`](HCI.RESTORE, id);
const allRestore = this.$rootGetters[`${ inStore }/all`](HCI.RESTORE);
const res = allRestore.find((O) => O.id === id);
if (res) {
const allBackups = this.$rootGetters[`${ inStore }/all`](HCI.BACKUP);
@@ -1032,7 +962,6 @@ export default class VirtVm extends HarvesterResource {
this.isUnschedulable?.status ||
this.isPaused?.status ||
this.isVMError?.status ||
this.isCloneFailed?.status ||
this.isPending?.status ||
this.isStopping?.status ||
this.isOff?.status ||
@@ -1040,7 +969,7 @@ export default class VirtVm extends HarvesterResource {
this.isRunning?.status ||
this.isNotReady?.status ||
this.isStarting?.status ||
this.isWaitingForVMI?.status ||
this.isWaitingForVMI?.state ||
this.otherState?.status;
return state;
@@ -1144,6 +1073,42 @@ export default class VirtVm extends HarvesterResource {
return out;
}
get warningCount() {
return this.resourcesStatus.warningCount;
}
get errorCount() {
return this.resourcesStatus.errorCount;
}
get resourcesStatus() {
const inStore = this.productInStore;
const vmList = this.$rootGetters[`${ inStore }/all`](HCI.VM);
let warningCount = 0;
let errorCount = 0;
vmList.forEach((vm) => {
const status = vm.actualState;
if (status === VM_ERROR) {
errorCount += 1;
} else if (
status === 'Stopping' ||
status === 'Waiting' ||
status === 'Pending' ||
status === 'Starting' ||
status === 'Terminating'
) {
warningCount += 1;
}
});
return {
warningCount,
errorCount
};
}
get volumeClaimTemplates() {
return parseVolumeClaimTemplates(this);
}
@@ -1161,6 +1126,7 @@ export default class VirtVm extends HarvesterResource {
get rootImageId() {
let imageId = '';
const inStore = this.productInStore;
const pvcs = this.$rootGetters[`${ inStore }/all`](PVC) || [];
const volumes = this.spec.template.spec.volumes || [];
@@ -1170,7 +1136,9 @@ export default class VirtVm extends HarvesterResource {
});
if (!isNoExistingVolume) {
const existingVolume = this.$rootGetters[`${ inStore }/byId`](PVC, `${ this.metadata.namespace }/${ firstVolumeName }`);
const existingVolume = pvcs.find(
(P) => P.id === `${ this.metadata.namespace }/${ firstVolumeName }`
);
if (existingVolume) {
return existingVolume?.metadata?.annotations?.[
@@ -1319,10 +1287,6 @@ export default class VirtVm extends HarvesterResource {
return this.$rootGetters['harvester-common/getFeatureEnabled']('schedulingVMBackup');
}
get nameDisplay() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME] || this.metadata?.name || this.id;
}
get volumeEncryptionFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('volumeEncryption');
}
@@ -1352,7 +1316,8 @@ export default class VirtVm extends HarvesterResource {
}
get isBackupTargetUnavailable() {
const backupTargetSetting = this.$rootGetters['harvester/byId'](HCI.SETTING, 'backup-target');
const allSettings = this.$rootGetters['harvester/all'](HCI.SETTING) || [];
const backupTargetSetting = allSettings.find( (O) => O.id === 'backup-target');
return isBackupTargetSettingUnavailable(backupTargetSetting);
}
@@ -1,19 +0,0 @@
import shellProject from '@shell/models/management.cattle.io.project';
// This model controls `Project / Namespace` page in rancher integration mode
// Extend management.cattle.io.project model from shell
export default class Project extends shellProject {
get _availableActions() {
const canUpdate = !!this.linkFor('update');
// disable `Edit Config` action if user does not have update permission.
return super._availableActions.map((action) => {
if (action.action === 'goToEdit') {
return { ...action, enabled: canUpdate };
}
return action;
});
}
}
@@ -57,11 +57,7 @@ export default class HciVlanConfig extends HarvesterResource {
get _availableActions() {
const out = super._availableActions;
const canMigrate = !!this.$getters?.['schemaFor']?.(HCI.VLAN_CONFIG)?.collectionMethods?.find((x) => ['post'].includes(x.toLowerCase()));
if (canMigrate) {
insertAt(out, 0, this.migrateAction);
}
insertAt(out, 0, this.migrateAction);
return out;
}
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "harvester",
"description": "Rancher UI Extension for Harvester",
"version": "1.9.0-rc2",
"version": "1.8.2-rc1",
"private": false,
"rancher": {
"annotations": {
"catalog.cattle.io/display-name": "Harvester",
"catalog.cattle.io/kube-version": ">= 1.16.0-0",
"catalog.cattle.io/rancher-version": ">= 2.15.0-0",
"catalog.cattle.io/rancher-version": ">= 2.14.0-0",
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
}
},
@@ -1,503 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable';
import PercentageBar from '@shell/components/PercentageBar';
import { Banner } from '@components/Banner';
import MappingsCell from '../../../../components/MappingsCell';
import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { ADD_ONS } from '../../../../config/harvester-map';
import { currentRouter } from '../../../../utils/router';
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
const store = useStore();
const { t } = useI18n(store);
const loading = ref(true);
const errors = ref([]);
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 escapeHtml = (value = '') => String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const getPipelineStepMeta = (step = {}) => {
const phase = String(step.phase || '').toLowerCase();
const hasProgress = typeof step.progress?.completed === 'number' && typeof step.progress?.total === 'number' && step.progress.total > 0;
const progressPct = hasProgress ? Math.round((step.progress.completed / step.progress.total) * 1000) / 10 : null;
if (phase === 'completed' || phase === 'succeeded') {
return {
icon: '✓',
color: 'var(--success)',
text: ''
};
}
if (phase === 'running' || phase === 'inprogress' || phase === 'progressing' || phase === 'started' || (hasProgress && progressPct > 0)) {
return {
icon: '↻',
color: 'var(--primary)',
text: progressPct !== null ? `${ step.phase || t('generic.inProgress') } (${ progressPct }%)` : (step.phase || t('generic.inProgress'))
};
}
if (phase === 'failed' || phase === 'error') {
return {
icon: '✕',
color: 'var(--error)',
text: step.phase || t('harvester.addons.vmMigration.dashboard.progress.failed')
};
}
return {
icon: '○',
color: 'var(--muted)',
text: ''
};
};
const formatPipelineTooltip = (pipeline = []) => {
if (!pipeline.length) {
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:var(--muted);font-weight:700;">○</span><span>${ escapeHtml(t('harvester.addons.vmMigration.dashboard.progress.initializingMigration')) }</span></div>`;
}
return pipeline
.map((step, idx) => {
const stepName = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
const meta = getPipelineStepMeta(step);
const lineText = meta.text ? `${ escapeHtml(stepName) } : ${ escapeHtml(meta.text) }` : escapeHtml(stepName);
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:${ meta.color };font-weight:700;">${ meta.icon }</span><span>${ lineText }</span></div>`;
})
.join('');
};
const formatDuration = (started, completed) => {
if (!started || !completed) {
return { display: null, seconds: -1 };
}
const startMs = new Date(started).getTime();
const endMs = new Date(completed).getTime();
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
return { display: null, seconds: -1 };
}
const totalSeconds = Math.floor((endMs - startMs) / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const display = hours > 0 ? `${ hours }h ${ minutes }m` : `${ minutes }m ${ seconds }s`;
return { display, seconds: totalSeconds };
};
const rows = computed(() => {
return allPlans.value.map((plan) => {
const netMapName = plan.spec?.map?.network?.name;
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
const storMapName = plan.spec?.map?.storage?.name;
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
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 || '-' } → ${ 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(', ') || '-';
const vms = plan.status?.migration?.vms || [];
plan.vmProgress = vms.map((vm) => {
const pipeline = vm.pipeline || [];
const totalSteps = pipeline.length || 1;
let overallProgress = 0;
let currentStep = '';
let errorMsg = '';
pipeline.forEach((step, idx) => {
const stepWeight = 100 / totalSteps;
if (step.phase === 'Completed') {
overallProgress += stepWeight;
} else {
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
overallProgress += (stepPct / 100) * stepWeight;
if (!currentStep) {
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
}
if (step.error && !errorMsg) {
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
}
if (step.phase === 'Failed' && !errorMsg) {
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
}
});
// Fallback to VM-level error if no step-level error found
if (!errorMsg && vm.error) {
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
}
overallProgress = Math.round(overallProgress * 10) / 10;
// 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') }`;
}
return {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: overallProgress,
currentStep,
pipelineTooltip: formatPipelineTooltip(pipeline),
errorMsg,
canceled: plan.planCanceled,
};
});
// If no migration progress but we have VMs in the spec, show them at 0%
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
plan.vmProgress = plan.spec.vms.map((vm) => ({
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: 0,
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
pipelineTooltip: formatPipelineTooltip([]),
errorMsg: '',
canceled: false,
}));
}
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
const durationResult = formatDuration(plan.status?.migration?.started, plan.status?.migration?.completed);
plan.duration = durationResult.display;
plan.durationSeconds = durationResult.seconds;
return plan;
});
});
const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
}));
const headers = [
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
{
...NAME_COL,
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
},
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
{
name: 'progress',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
value: 'progress',
width: 450,
},
{
name: 'mappings',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
},
{
name: 'duration',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
value: 'duration',
sort: 'durationSeconds',
width: 120,
},
{ ...AGE },
];
const init = async() => {
try {
// Guard: the Forklift dashboard is only available when the Forklift addon is
// enabled. Redirect back to the Harvester dashboard otherwise (e.g. direct URL).
let forkliftEnabled = false;
if (store.getters[`${ inStore.value }/schemaFor`](HCI.ADD_ONS)) {
const addons = await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.ADD_ONS });
forkliftEnabled = addons.find((a) => a.metadata?.name === ADD_ONS.FORKLIFT_OPERATOR)?.spec?.enabled === true;
}
if (!forkliftEnabled) {
await currentRouter().replace({
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.DASHBOARD,
},
});
return;
}
await Promise.all([
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally {
loading.value = false;
}
};
init();
</script>
<template>
<Loading v-if="loading" />
<div v-else>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
>
<template #subHeader>
<div class="mmt-5">
<p
v-clean-html="t('harvester.addons.vmMigration.dashboard.description')"
class="text-muted"
></p>
</div>
</template>
<template #createButton>
<router-link
:to="createLocation"
class="btn role-primary"
>
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
</router-link>
</template>
</Masthead>
<ResourceTable
:schema="schema"
:rows="rows"
:headers="headers"
:groupable="false"
:table-actions="false"
:search="false"
key-field="_key"
>
<template #cell:name="{ row }">
<div class="plan-name-cell">
<router-link
v-if="row.detailLocation"
:to="row.detailLocation"
class="plan-name"
>
{{ row.metadata.name }}
</router-link>
<div
v-else
class="plan-name"
>
{{ row.metadata.name }}
</div>
</div>
</template>
<template #cell:vmCount="{ row }">
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
</template>
<template #cell:progress="{ row }">
<div
v-if="row.vmProgress && row.vmProgress.length"
class="progress-cells"
>
<div
v-for="vm in row.vmProgress"
:key="vm.vmId"
class="vm-progress"
>
<div class="vm-progress-header">
<div class="vm-name-block">
<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>
<div class="vm-pct-block">
<div
v-clean-tooltip="{ content: vm.pipelineTooltip, html: true }"
class="vm-bar"
>
<PercentageBar
:model-value="vm.progress"
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
preferred-direction="MORE"
/>
</div>
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
</div>
<div
v-if="vm.progress >= 100"
class="step-label text-muted"
>
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
</div>
<div
v-else-if="vm.errorMsg"
class="step-label text-error"
>
{{ vm.errorMsg }}
</div>
<div
v-else-if="vm.canceled"
class="step-label text-muted"
>
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
</div>
<div
v-else-if="vm.currentStep"
class="step-label text-muted"
>
{{ vm.currentStep }}
</div>
</div>
</div>
<span v-if="!row.vmProgress.length">-</span>
</template>
<template #cell:mappings="{ row }">
<MappingsCell
:network-entries="row.networkEntries"
:storage-entries="row.storageEntries"
/>
</template>
<template #cell:duration="{ row }">
<span v-if="row.duration">{{ row.duration }}</span>
<span
v-else
class="text-muted"
>&mdash;</span>
</template>
</ResourceTable>
</div>
</template>
<style lang="scss" scoped>
.plan-name-cell {
.plan-name {
font-weight: 500;
line-height: 20px;
}
.plan-vms {
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
}
.progress-cells {
display: flex;
flex-direction: column;
gap: 16px;
}
.vm-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.vm-name {
font-size: 14px;
}
.vm-name-block {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.vm-id {
font-size: 11px;
}
.vm-pct {
font-size: 14px;
min-width: 40px;
text-align: left;
}
.vm-pct-block {
display: flex;
align-items: center;
gap: 14px;
justify-content: space-between;
}
.vm-bar {
width: 100%;
}
.step-label {
font-size: 13px;
margin-top: 4px;
line-height: 20px;
&.text-error {
color: var(--error);
}
}
.table-title {
font-weight: 600;
}
</style>
@@ -1,286 +0,0 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
import { HCI } from '@pkg/harvester/types';
const store = useStore();
const route = currentRoute();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const mappingsStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const mappingsReady = ref(false);
const existingNetworkMap = ref(null);
const existingStorageMap = ref(null);
const initialLoading = ref(true);
const dummyResource = ref({ save: () => Promise.resolve() });
const providerId = route.query?.providerId || null;
const isEditMode = !!providerId;
const stepData = reactive({
provider: {
selectedProvider: isEditMode ? '' : '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
mappings: {
networkEntries: [],
storageEntries: [],
},
});
const steps = reactive([
{
name: 'configure-provider',
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
ready: false,
},
{
name: 'configure-mappings',
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
ready: false,
},
]);
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch(mappingsReady, (val) => {
steps[1].ready = val;
});
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady`, mark the step ready and advance
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
watch(
() => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => {
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
wizardComponent.value.goToStep(1);
pendingProceed.value = true;
providerStepRef.value.clickTestButton();
}
}
);
watch(providerReady, (val) => {
if (val && pendingProceed.value) {
pendingProceed.value = false;
steps[0].ready = true;
wizardComponent.value.goToStep(2);
}
});
const wizardTitle = computed(() => {
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
// Clear mappings when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
}
});
const providerListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.FORKLIFT_PROVIDER,
}
};
const onFinish = async(buttonCb) => {
try {
await mappingsStepRef.value.saveMappings();
buttonCb(true);
currentRouter().push(providerListLocation);
} catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)];
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(providerListLocation);
};
const init = async() => {
if (!isEditMode) {
initialLoading.value = false;
return;
}
const inStore = store.getters['currentProduct'].inStore;
try {
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: providerId,
opt: { force: true },
});
provider.value = fetchedProvider;
providerName.value = fetchedProvider.metadata.name;
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret;
if (secretRef) {
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
});
const secret = allSecrets.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
stepData.provider.username = decodeSecretValue(secret.data.user);
stepData.provider.password = decodeSecretValue(secret.data.password);
stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
stepData.provider.createdSecret = secret;
}
}
// Fetch existing default maps for this provider
try {
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
existingNetworkMap.value = allNetworkMaps.find(
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
) || null;
existingStorageMap.value = allStorageMaps.find(
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
) || null;
} catch (e) {
// Maps may not exist yet
}
} catch (err) {
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
}
initialLoading.value = false;
};
init();
</script>
<template>
<Loading v-if="initialLoading" />
<CruResource
v-else
ref="cruRef"
:resource="dummyResource"
:mode="isEditMode ? 'edit' : 'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
:title="wizardTitle"
finish-mode="finish"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
:create-only="true"
:edit-mode="isEditMode"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
ref="mappingsStepRef"
:provider-name="providerName"
:provider="provider"
:step-data="stepData.mappings"
:use-all-provider-data="true"
:existing-network-map="existingNetworkMap"
:existing-storage-map="existingStorageMap"
@ready="onMappingsReady"
/>
</template>
</CruResource>
</template>
@@ -1,296 +0,0 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import CruResource from '@shell/components/CruResource';
import { useI18n } from '@shell/composables/useI18n';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import SelectVmsStep from '@pkg/harvester/components/vm-migration/SelectVmsStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter } from '@pkg/harvester/utils/router';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
const store = useStore();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const reviewStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const selectedVMs = ref([]);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const vmsReady = ref(false);
const vmsLoading = ref(true);
const mappingsReady = ref(false);
const reviewReady = ref(false);
const dummyResource = ref({ save: () => Promise.resolve() });
const stepData = reactive({
provider: {
selectedProvider: '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
vms: {
discoveredVMs: [],
selectedVMIds: new Set(),
tableRows: [],
},
mappings: {
networkEntries: [],
storageEntries: [],
},
review: { planName: '' },
});
const steps = reactive([
{
name: 'configure-provider',
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
ready: false,
},
{
name: 'select-vms',
label: t('harvester.addons.vmMigration.wizard.steps.selectVms.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.selectVms.description'),
ready: false,
},
{
name: 'configure-mappings',
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
ready: false,
},
{
name: 'review-migration',
label: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description'),
ready: false,
},
]);
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch([vmsReady, vmsLoading], () => {
steps[1].ready = vmsReady.value && !vmsLoading.value;
});
watch(mappingsReady, (val) => {
steps[2].ready = val;
});
watch(reviewReady, (val) => {
steps[3].ready = val;
});
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
// watcher clears `pendingProceed` and the user stays put.
watch(
() => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => {
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
wizardComponent.value.goToStep(1);
pendingProceed.value = true;
providerStepRef.value.clickTestButton();
}
}
);
watch(providerReady, (val) => {
if (val && pendingProceed.value) {
pendingProceed.value = false;
steps[0].ready = true;
wizardComponent.value.goToStep(2);
}
});
watch(providerTesting, (testing) => {
if (!testing && pendingProceed.value && !providerReady.value) {
pendingProceed.value = false;
}
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onVmsComplete = (data) => {
selectedVMs.value = data.selectedVMs;
vmsReady.value = data.selectedVMs.length > 0;
};
const onVmsLoading = (val) => {
vmsLoading.value = val;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
const onReviewReady = (ready) => {
reviewReady.value = ready;
};
// Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.vms.discoveredVMs = [];
stepData.vms.selectedVMIds = new Set();
stepData.vms.tableRows = [];
selectedVMs.value = [];
vmsReady.value = false;
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
// 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 changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id));
if (oldVal.length > 0 && changed) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
const migrationListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
};
const onFinish = async(buttonCb) => {
try {
await reviewStepRef.value.startMigration();
buttonCb(true);
currentRouter().push(migrationListLocation);
} catch (err) {
errors.value = exceptionToErrorsArray(err).map((e) => {
if (typeof e === 'string') {
return e;
}
return stringify(e);
});
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(migrationListLocation);
};
</script>
<template>
<CruResource
ref="cruRef"
:resource="dummyResource"
:mode="'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
finish-mode="finish"
finish-button-mode="createAndStart"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #select-vms>
<SelectVmsStep
:provider-name="providerName"
:provider="provider"
:step-data="stepData.vms"
@complete="onVmsComplete"
@loading="onVmsLoading"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
:provider-name="providerName"
:provider="provider"
:selected-vms="selectedVMs"
:step-data="stepData.mappings"
@ready="onMappingsReady"
/>
</template>
<template #review-migration>
<ReviewMigrationStep
ref="reviewStepRef"
:provider-name="providerName"
:provider="provider"
:selected-vms="selectedVMs"
:mapping-entries="stepData.mappings"
:step-data="stepData.review"
@ready="onReviewReady"
/>
</template>
</CruResource>
</template>
@@ -1,316 +0,0 @@
<script>
import { defineComponent } from 'vue';
import { mapState, mapGetters } from 'vuex';
import { resourceNames } from '@shell/utils/string';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI } from '../types';
export default defineComponent({
name: 'PromptRemoveForkliftPlanDialog',
emits: ['errors'],
props: {
value: {
type: Array,
default: () => []
},
names: {
type: Array,
default: () => []
},
type: {
type: String,
required: true
},
close: {
type: Function,
required: true
},
doneLocation: {
type: Object,
default: () => {}
}
},
data() {
return {
errors: [],
removing: false,
};
},
computed: {
...mapState('action-menu', ['toRemove']),
...mapGetters({ t: 'i18n/t' }),
existingRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
return {
[HCI.FORKLIFT_MIGRATION]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_MIGRATION) || [],
[HCI.FORKLIFT_NETWORK_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [],
[HCI.FORKLIFT_STORAGE_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [],
};
},
resourceType() {
return this.t('typeLabel."forklift.konveyor.io.plan"', { count: this.names?.length || 1 });
},
relatedSummary() {
const existing = this.existingRelatedResources;
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 migrationsMap = new Map();
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: [...migrationsMap.values()],
networkMap: matchedNetworkMap ? {
namespace: networkMap.namespace || namespace,
name: networkMap.name,
} : null,
storageMap: matchedStorageMap ? {
namespace: storageMap.namespace || namespace,
name: storageMap.name,
} : null,
};
});
},
},
async mounted() {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
await Promise.all([
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
} catch {
// Best-effort: the dialog can still function without this prefetch.
}
},
methods: {
resourceNames,
buildDeleteTargets() {
const targets = new Map();
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 (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,
});
}
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()];
},
async deleteRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
const targets = this.buildDeleteTargets();
if (!targets.length) {
return;
}
let cache = {};
try {
cache = {
[HCI.FORKLIFT_MIGRATION]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
[HCI.FORKLIFT_NETWORK_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
[HCI.FORKLIFT_STORAGE_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
};
} catch (err) {
// Best-effort cleanup; don't block closing the dialog if listing related resources fails.
return;
}
for (const target of targets) {
const matched = (cache[target.type] || []).find((resource) => resource?.metadata?.name === target.name && resource?.metadata?.namespace === target.namespace
);
if (!matched) {
continue;
}
try {
await matched.remove({ params: { propagationPolicy: 'Background' } });
} catch (err) {
// Continue deleting remaining resources and let final plan deletion fail if needed.
}
}
},
async remove(buttonDone) {
this.errors = [];
this.removing = true;
try {
const results = await Promise.allSettled((this.value || []).map((resource) => resource.remove({ params: { propagationPolicy: 'Background' } })));
await this.deleteRelatedResources();
const rejected = results.filter((r) => r.status === 'rejected');
if (rejected.length) {
throw rejected[0].reason;
}
this.close(buttonDone);
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.$emit('errors', err);
if (buttonDone) {
buttonDone(false);
}
} finally {
this.removing = false;
}
},
}
});
</script>
<template>
<div class="mt-10">
<div class="mb-10">
{{ t('promptRemove.attemptingToRemove', { type: resourceType }) }}
<span v-clean-html="resourceNames(names, null, t)" />
</div>
<hr class="mb-10">
<div class="mb-10 text-warning">
<i class="icon icon-warning icon-lg mr-5" />
{{ t('harvester.addons.vmMigration.deleteDialog.warning') }}
</div>
<div
v-for="summary in relatedSummary"
:key="`${ summary.namespace }/${ summary.planName }`"
class="mb-10"
>
<div>• {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
<template v-if="summary.migrations.length">
<div
v-for="migration in summary.migrations"
:key="`${ migration.namespace }/${ migration.name }`"
>
<div class="ml-20">
- {{ migration.name }}
</div>
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div>• {{ t('harvester.addons.vmMigration.labels.networkMap') }}:</div>
<template v-if="summary.networkMap">
<div class="ml-20">
- {{ summary.networkMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div>• {{ t('harvester.addons.vmMigration.labels.storageMap') }}:</div>
<template v-if="summary.storageMap">
<div class="ml-20">
- {{ summary.storageMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
</div>
<Banner
v-for="(error, i) in errors"
:key="i"
color="error"
:label="error"
/>
</div>
</template>
@@ -123,7 +123,6 @@ export default {
this.value?.[0]?.currentRouter().push(goTo);
}
this.close();
this.$store.commit('action-menu/togglePromptModal', { performCallback: true, clearTableSelection: true });
}).catch((err) => {
this.$emit('errors', err);
});

Some files were not shown because too many files have changed in this diff Show More