Compare commits

..
Author SHA1 Message Date
renovate[bot] e9a9035cb8 deps: update patch dependencies (#1163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 11:16:50 +08:00
renovate[bot] 1ff6137a9d deps: update dependency qs to v6.16.0 [security] (#1136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-07 13:56:43 +08:00
renovate[bot] 311de06483 deps: update dependency @types/lodash to v4.17.25 (#1102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 10:11:00 +08:00
Harvester Bot 37ddf83f42 chore: bump version to 1.8.2 (#1095)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-06 11:30:49 +08:00
renovate[bot] 76bd9195f9 deps: update patch dependencies (#1078)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 16:47:21 +08:00
renovate[bot] 50d66a521d deps: update dependency @types/node to v25.9.5 (#1024)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 14:06:41 +08:00
mergify[bot]andAlexandre Alves c81d2a87fd feat: security update (#967) (#983)
(cherry picked from commit 479c298f4a)

Co-authored-by: Alexandre Alves <alexandre.alves@suse.com>
2026-07-09 18:44:44 +08:00
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
158 changed files with 4575 additions and 16063 deletions
+6 -15
View File
@@ -1,23 +1,14 @@
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
### PR Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
### PR Checklists
- Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
- [ ] Yes, the backend owner is:
### Related Issue
<!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
### Test Steps
<!-- Provide the test steps for reviewer to verify the issue -->
1. Go to ... page
### Related Issue #
<!-- Define findings related to the feature or bug issue. -->
### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison -->
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
+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"]
}
]
}
@@ -125,13 +125,6 @@ jobs:
node-version-file: '.nvmrc'
cache: 'yarn'
# Tag builds (RC, formal and sprint releases) ship the tag as the package
# version. RC/formal tags already match package.json; sprint tags
# (vX.Y.Z-dev-YYYYMMDD) only share its prefix, so pin it here for the build.
- name: Pin package version to tag
if: ${{ inputs.CI_BUILD_TAG != '' }}
run: ./scripts/bump_version.sh "${{ inputs.CI_BUILD_TAG }}"
- id: ci-build-pkg
name: Build pkg
run: ./scripts/ci-build-pkg.sh harvester
@@ -58,12 +58,6 @@ jobs:
- name: Setup Nodejs with yarn install
uses: ./.github/actions/setup
# The catalog image is tagged with the package.json version. Sprint tags
# (vX.Y.Z-dev-YYYYMMDD) only share its prefix, so pin it to the tag here.
- name: Pin package version to tag
if: ${{ github.ref_type == 'tag' }}
run: ./scripts/bump_version.sh "${{ github.ref_name }}"
- name: Build and push UI image
run: |
publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}"
@@ -1,7 +1,6 @@
name: Build Standalone on PR Merge
on:
workflow_dispatch: # manually trigger on Github Actions only support PR branch pushed to harvester/harvester-ui-extension
push:
branches:
- main
@@ -30,35 +29,3 @@ jobs:
id-token: write
with:
CI_BRANCH: ${{github.ref_name}}
comment-on-pr:
name: Comment build result on PR
# Only for manual runs so the existing push / pull_request flows are untouched.
if: github.event_name == 'workflow_dispatch'
needs:
- build
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Add build artifact comment to the PR opened from this branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
# Match scripts/version: sanitize the branch and map main -> latest.
if [ "$BRANCH" = "main" ]; then
DIR="latest"
else
DIR=$(printf '%s' "$BRANCH" | sed -E 's/[^a-zA-Z0-9.-]+/-/g')
fi
URL="https://releases.rancher.com/harvester-ui/dashboard/${DIR}/index.html"
PR=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$BRANCH" --json number --jq '.[0].number // empty')
if [ -z "$PR" ]; then
echo "No open PR found for branch $BRANCH, skipping comment."
exit 0
fi
BODY=$(printf '🚀 **UI Artifact Build Status:** `SUCCESS` ✅\n> ui-index: %s\n> ui-source: external' "$URL")
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$BODY"
+20 -20
View File
@@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
# Project Overview
@@ -28,7 +28,9 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
- Run `yarn lint:fix` before commits.
- Use conventional commit format:
```
<type>: <description>
<type>:
<description>
```
- Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
@@ -128,8 +130,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`:
- Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- The `<style>` tag should include `lang="scss" scoped`.
- Place source tag above template above style.
- style tag should contain `lang='scss' scoped`.
- `Linting`: Follow the ESLint configuration in the root.
- **File Structure:**
@@ -161,26 +163,24 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide
### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
1. Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps:
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Check: Use the following getter to verify if the feature is enabled for the current version:
3. **Render**: Use the result of the check to conditionally render the UI components.
```
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
## E2E Tests (Cypress)
@@ -228,7 +228,7 @@ For the Renovate config, see `.github/renovate.json`.
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>: <description>
<type>[optional scope]: <description>
[optional body]
-1
View File
@@ -1 +0,0 @@
./AGENTS.md
-1
View File
@@ -1 +0,0 @@
./AGENTS.md
+1 -21
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).
@@ -25,7 +12,7 @@ For Harvester UI extension installation instructions, please refer to the page *
## Development Setup
Ensure **Node.js v24 or later** is installed for development and debugging.
Ensure **Node.js v20 or later** is installed for development and debugging.
### Standalone Mode
@@ -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.
+2 -3
View File
@@ -32,11 +32,10 @@ module.exports = {
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'],
'header-max-length': [0, 'always', 100],
'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [0, 'always', 100],
'footer-max-line-length': [2, 'always', 100],
},
// Ignore merge commits and revert commits
ignores: [
+3 -1
View File
@@ -6,7 +6,9 @@
- Run `yarn lint:fix` before commits.
- Use conventional commit format:
```
<type>: <description>
<type>:
<description>
```
- Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
+15 -17
View File
@@ -16,8 +16,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`:
- Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- The `<style>` tag should include `lang="scss" scoped`.
- Place source tag above template above style.
- style tag should contain `lang='scss' scoped`.
- `Linting`: Follow the ESLint configuration in the root.
- **File Structure:**
@@ -49,24 +49,22 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide
### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
1. Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps:
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Check: Use the following getter to verify if the feature is enabled for the current version:
3. **Render**: Use the result of the check to conditionally render the UI components.
```
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
@@ -26,7 +26,7 @@
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>: <description>
<type>[optional scope]: <description>
[optional body]
+1 -1
View File
@@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
# Project Overview
+5 -13
View File
@@ -1,13 +1,13 @@
{
"name": "harvester-ui-extension",
"version": "1.10.0-dev",
"version": "1.8.2",
"private": false,
"engines": {
"node": ">=24.0.0"
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.29.7",
"@rancher/shell": "3.0.13",
"@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",
@@ -15,12 +15,6 @@
"cache-loader": "^4.1.0",
"color": "4.2.3",
"ip": "2.0.1",
"@xterm/xterm": "6.0.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/addon-canvas": "0.7.0",
"node-polyfill-webpack-plugin": "^3.0.0",
"elkjs": "^0.11.0",
"vue-draggable-next": "^2.2.1",
@@ -48,17 +42,15 @@
"dev": "NODE_ENV=dev ./node_modules/.bin/vue-cli-service serve",
"build": "./node_modules/.bin/vue-cli-service build",
"clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/@rancher/shell/scripts/lint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/@rancher/shell/scripts/lint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"lint": "./node_modules/.bin/eslint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/.bin/eslint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
"parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name",
"commitlint": "commitlint --edit",
"prepare": "husky",
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh",
"prelint": "npm --prefix node_modules/@rancher/shell/eslint-legacy install eslint-plugin-jest@^24 --no-save --no-audit --no-fund --legacy-peer-deps --loglevel=error",
"prelint:fix": "npm --prefix node_modules/@rancher/shell/eslint-legacy install eslint-plugin-jest@^24 --no-save --no-audit --no-fund --legacy-peer-deps --loglevel=error"
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh"
},
"devDependencies": {
"@commitlint/load": "^19.8.1",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

-5
View File
@@ -271,9 +271,4 @@ export default {
.filter-label .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>
@@ -116,11 +116,6 @@ export default {
.vm-schedule-dropdown .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .vm-schedule-filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>
<style lang="scss" scoped>
@@ -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>
+8 -33
View File
@@ -55,12 +55,8 @@ export default {
!this.latestUpgrade.isUpgradeFailed;
},
canUpgrade() {
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
},
versionOptions() {
const versions = this.$store.getters['harvester/all'](HCI.VERSION) || [];
const versions = this.$store.getters['harvester/all'](HCI.VERSION);
return versions.map((V) => V.metadata.name);
},
@@ -158,23 +154,14 @@ export default {
:cluster="currentCluster.nameDisplay"
/>
</h1>
<span
v-clean-tooltip="{
content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'),
triggers: ['hover', 'focus', 'touch'],
}"
class="upgrade-btn-wrap"
:tabindex="canUpgrade ? -1 : 0"
<button
v-if="versionOptions.length && !isUpgradeInProgress"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<button
:disabled="!canUpgrade"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<t k="harvester.upgradePage.upgrade" />
</button>
</span>
<t k="harvester.upgradePage.upgrade" />
</button>
</header>
<ModalWithCard
@@ -309,18 +296,6 @@ export default {
line-height: 23px;
}
.upgrade-btn-wrap {
display: inline-flex;
button {
color: #000 !important;
}
button:hover {
color: #000 !important;
}
}
.currentVersion {
margin-top: 10px;
display: flex;
-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');
},
},
};
+63 -442
View File
@@ -1,6 +1,7 @@
<script>
import { allHash } from '@shell/utils/promise';
import debounce from 'lodash/debounce';
import Socket, {
EVENT_CONNECTED,
EVENT_CONNECTING,
@@ -21,98 +22,74 @@ export default {
data() {
return {
socket: null,
terminal: null,
textDecoder: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
onResize: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: [],
isDraining: false,
drainScheduled: false,
drainTimer: null,
showSearch: false,
searchQuery: '',
searchOptions: {
caseSensitive: false,
regex: false,
wholeWord: false,
incremental: true,
decorations: {
matchBackground: '#B45309',
matchBorder: '#FDBA74',
matchOverviewRuler: '#FDBA74',
activeMatchBackground: '#EA580C',
activeMatchBorder: '#FED7AA',
activeMatchColorOverviewRuler: '#FED7AA',
},
},
socket: null,
terminal: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: []
};
},
computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() {
return {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
cursorWidth: 2,
scrollback: 5000,
useStyle: true,
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 16,
lineHeight: 1.3,
fontSize: 12,
};
},
},
watch: {
queue: {
handler: debounce(async function(neu) {
if (neu.length === 0) {
return;
}
const msg = await Promise.all(neu);
(msg || []).forEach((m) => {
this.terminal.write(m);
});
this.queue = [];
}, 10),
deep: true
}
},
beforeUnmount() {
this.close();
},
watch: {
serialConsoleUrl() {
if (this.terminal) {
this.connect();
}
},
},
async mounted() {
this.textDecoder = new TextDecoder('utf-8');
await this.setupTerminal();
await this.connect();
this.onResize = debounce(() => this.fit(), 100);
window.addEventListener('resize', this.onResize);
this.scheduleFit();
},
methods: {
async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body'));
const xterm = await import(/* webpackChunkName: "xterm" */ '@xterm/xterm');
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm');
const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'),
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
});
const terminal = new xterm.Terminal({
theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim()
},
...this.xtermConfig,
@@ -120,48 +97,25 @@ export default {
this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon();
try {
this.webglAddon = new addons.webgl.WebGlAddon();
} catch (e) {
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
this.webglAddon = null;
}
terminal.loadAddon(this.fitAddon);
terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm);
// The webgl renderer can fail without throwing: the context may be lost
// mid-session, or it can attach but silently never paint, leaving the
// terminal's helper textarea at 0x0. Detect both cases and fall back to the
// canvas renderer so the terminal stays interactive.
try {
this.webglAddon = new addons.webgl.WebglAddon();
this.webglAddon.onContextLoss(() => {
if (!this.isUnmounted && this.terminal) {
this.fallbackToCanvas(terminal, addons);
}
});
if ( this.webglAddon ) {
terminal.loadAddon(this.webglAddon);
// Detect the silent "attaches but never paints" failure after a render frame.
requestAnimationFrame(() => {
if (this.isUnmounted || !this.terminal) {
return;
}
const textarea = this.$refs.xterm?.querySelector('.xterm-helper-textarea');
const hasPainted = textarea && textarea.getBoundingClientRect().height > 0;
if (this.webglAddon && !hasPainted) {
this.fallbackToCanvas(terminal, addons);
this.fit();
}
});
} catch (e) {
// Some browsers (e.g. Safari) don't support the webgl renderer, so don't use it.
this.fallbackToCanvas(terminal, addons);
}
this.fit();
this.flush();
this.scheduleDrainQueue();
terminal.onData((input) => {
const msg = this.str2ab(input);
@@ -169,132 +123,9 @@ export default {
this.write(msg);
});
terminal.attachCustomKeyEventHandler((event) => {
const key = event.key?.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'f') {
event.preventDefault();
this.openSearch();
return false;
}
if (key === 'escape' && this.showSearch) {
event.preventDefault();
this.closeSearch();
return false;
}
return true;
});
this.terminal = terminal;
},
openSearch() {
this.showSearch = true;
this.$nextTick(() => {
const input = this.$refs.searchInput;
if (input) {
input.focus();
input.select();
}
});
},
closeSearch() {
this.showSearch = false;
this.searchQuery = '';
this.clearSearchHighlights();
this.terminal?.focus();
},
clearSearchHighlights() {
this.searchAddon?.clearActiveDecoration();
this.searchAddon?.clearDecorations();
this.terminal?.clearSelection();
// Disposing the search decorations doesn't repaint the webgl/canvas
// layer, so force a refresh to remove the lingering highlight.
if (this.terminal) {
this.terminal.refresh(0, this.terminal.rows - 1);
}
},
findNext() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findNext(this.searchQuery, this.searchOptions);
},
findPrevious() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findPrevious(this.searchQuery, this.searchOptions);
},
onSearchInput() {
if (!this.searchQuery) {
this.clearSearchHighlights();
return;
}
this.findNext();
},
onSearchKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
this.findPrevious();
} else {
this.findNext();
}
}
if (event.key === 'Escape') {
event.preventDefault();
this.closeSearch();
}
},
scheduleFit() {
this.$nextTick(() => {
requestAnimationFrame(() => {
this.fit();
// Re-fit after layout settles to avoid undersized rows on first paint.
setTimeout(() => this.fit(), 120);
});
});
},
// Dispose the webgl renderer (if any) and switch to the canvas renderer.
// Used when webgl fails to construct, loses its context, or silently never paints.
fallbackToCanvas(terminal, addons) {
if (this.isUnmounted) {
return;
}
this.webglAddon?.dispose();
this.webglAddon = null;
if (!this.canvasAddon) {
this.canvasAddon = new addons.canvas.CanvasAddon();
terminal.loadAddon(this.canvasAddon);
}
this.fit();
},
str2ab(str) {
const enc = new TextEncoder();
@@ -309,131 +140,12 @@ export default {
}
},
enqueueMessage(messagePromise) {
this.queue.push(messagePromise);
this.scheduleDrainQueue();
},
scheduleDrainQueue() {
if (this.drainScheduled || this.isDraining || !this.terminal) {
return;
}
this.drainScheduled = true;
const shouldDrainImmediately = this.queue.length <= 3;
const runDrain = () => {
this.drainScheduled = false;
this.drainTimer = null;
this.drainQueue();
};
if (shouldDrainImmediately) {
runDrain();
return;
}
this.drainTimer = requestAnimationFrame(runDrain);
},
async decodeMessageData(data) {
if (typeof data === 'string') {
return data;
}
if (!this.textDecoder) {
this.textDecoder = new TextDecoder('utf-8');
}
if (data instanceof Blob) {
const buffer = await data.arrayBuffer();
return this.textDecoder.decode(new Uint8Array(buffer), { stream: true });
}
if (data instanceof ArrayBuffer) {
return this.textDecoder.decode(new Uint8Array(data), { stream: true });
}
if (ArrayBuffer.isView(data)) {
const view = data;
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
return this.textDecoder.decode(bytes, { stream: true });
}
return String(data || '');
},
async drainQueue() {
if (this.isDraining || !this.terminal) {
return;
}
this.isDraining = true;
try {
while (this.queue.length > 0) {
const pendingMessages = this.queue.splice(0, this.queue.length);
const settledMessages = await Promise.allSettled(pendingMessages);
const messages = settledMessages
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
const output = messages.filter(Boolean).join('');
if (output) {
await this.writeOutputInChunks(output);
}
if (this.queue.length > 0) {
await this.nextFrame();
}
}
} finally {
this.isDraining = false;
// If data arrived between the last while-check and releasing the lock,
// immediately schedule another drain so output never gets stuck.
if (this.queue.length > 0 && this.terminal) {
this.scheduleDrainQueue();
}
}
},
async writeOutputInChunks(output) {
if (!output) {
return;
}
const chunkSize = output.length > 20000 ? 16000 : output.length > 8000 ? 8000 : output.length;
if (chunkSize === output.length) {
this.terminal.write(output);
return;
}
for (let index = 0; index < output.length; index += chunkSize) {
this.terminal.write(output.slice(index, index + chunkSize));
if (index + chunkSize < output.length) {
await this.nextFrame();
}
}
},
nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
},
clear() {
this.terminal.clear();
},
getSocketUrl() {
return this.serialConsoleUrl;
return `${ this.value?.getSerialConsolePath }`;
},
async connect() {
@@ -469,7 +181,6 @@ export default {
this.fit();
this.flush();
}
this.scheduleFit();
if (this.firstTime) {
this.socket.send(this.str2ab('\n'));
@@ -484,7 +195,7 @@ export default {
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
this.enqueueMessage(this.decodeMessageData(e.detail.data));
this.queue.push(e.detail.data.text());
});
this.socket.connect();
@@ -507,19 +218,22 @@ export default {
}
this.fitAddon.fit();
const { rows, cols } = this.fitAddon.proposeDimensions();
if ( !this.isOpen ) {
return;
}
const message = JSON.stringify({
Width: cols,
Height: rows
});
this.socket.send(this.str2ab(message));
},
close() {
if (this.onResize) {
window.removeEventListener('resize', this.onResize);
this.onResize = null;
}
if (this.drainTimer) {
cancelAnimationFrame(this.drainTimer);
this.drainTimer = null;
}
if ( this.socket ) {
this.socket.disconnect();
}
@@ -534,44 +248,6 @@ export default {
<template>
<div class="harvester-shell-container">
<div
v-if="showSearch"
class="shell-search"
>
<input
ref="searchInput"
v-model="searchQuery"
type="text"
class="shell-search-input"
placeholder="Search in terminal"
@input="onSearchInput"
@keydown="onSearchKeydown"
>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Shift+Enter"
@click="findPrevious"
>
Prev
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Enter"
@click="findNext"
>
Next
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Esc"
@click="closeSearch"
>
Close
</button>
</div>
<div
ref="xterm"
class="shell-body"
@@ -581,73 +257,18 @@ export default {
</template>
<style lang="scss">
@import '../../../../node_modules/@xterm/xterm/css/xterm.css';
@import '../../../../node_modules/xterm/css/xterm.css';
body, #__nuxt, #__layout, MAIN {
height: 100%;
}
.harvester-shell-container {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
min-height: 0;
overflow: hidden;
.resize-observer {
display: none;
}
.shell-search {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
position: absolute;
top: 8px;
right: 10px;
z-index: 5;
padding: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--body-bg) 88%, transparent);
}
.shell-search-input {
min-width: 220px;
max-width: 420px;
flex: 0 1 320px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--body-bg);
color: var(--body-text);
padding: 6px 8px;
}
.shell-search-btn {
min-width: 64px;
}
.shell-body {
flex: 1 1 auto;
.shell-body, .terminal.xterm {
height: 100%;
width: 100%;
min-height: 0;
padding: 0 10px;
box-sizing: border-box;
}
.terminal.xterm,
.terminal.xterm .xterm-viewport,
.terminal.xterm .xterm-screen,
.terminal.xterm .xterm-scrollable-element {
width: 100%;
height: 100%;
}
.terminal.xterm .xterm-scrollable-element {
min-height: 100%;
}
}
</style>
+14 -21
View File
@@ -10,7 +10,6 @@ import { docLink } from '../utils/feature-flags';
const CATEGORY = {
ui: [
'branding',
HCI_SETTING.SERVER_FLAVOR,
'ui-source',
'ui-index',
]
@@ -54,7 +53,7 @@ export default {
};
},
computed: { ...mapGetters({ t: 'i18n/t', isHarvesterPrime: 'harvester-common/isHarvesterPrime' }) },
computed: { ...mapGetters({ t: 'i18n/t' }) },
watch: {
settings: {
@@ -232,21 +231,14 @@ export default {
v-if="setting.customized"
class="modified"
>
{{ t('advancedSettings.tags.modified.label') }}
Modified
</span>
<span
v-if="setting.experimental"
v-clean-tooltip="t('advancedSettings.tags.experimental.description')"
v-clean-tooltip="t('advancedSettings.experimental')"
class="experimental"
>
{{ t('advancedSettings.tags.experimental.label') }}
</span>
<span
v-if="setting.preview"
v-clean-tooltip="t('advancedSettings.tags.preview.description')"
class="preview"
>
{{ t('advancedSettings.tags.preview.label') }}
Experimental
</span>
</h1>
<h2 v-clean-html="t(setting.description, getDocLinkParams(setting) || {}, true)">
@@ -270,8 +262,7 @@ export default {
v-if="!setting.hide"
class="settings-value"
>
<pre v-if="setting.id === 'branding' && isHarvesterPrime">{{ $store.getters['harvester-common/privateLabel'] }}</pre>
<pre v-else-if="setting.kind === 'json'">{{ setting.json }}</pre>
<pre v-if="setting.kind === 'json'">{{ setting.json }}</pre>
<pre v-else-if="setting.kind === 'multiline'">{{ setting.data.value || setting.data.default }}</pre>
<pre v-else-if="setting.kind === 'enum'">{{ t(setting.enum) }}</pre>
<pre v-else-if="setting.kind === 'custom' && setting.custom">{{ setting.custom }}</pre>
@@ -361,19 +352,21 @@ export default {
flex: 1;
}
.modified,
.experimental,
.preview {
.modified {
margin-left: 10px;
border: 1px solid;
border: 1px solid var(--primary);
border-radius: 5px;
padding: 2px 10px;
font-size: 12px;
}
.modified { border-color: var(--primary); }
.experimental { border-color: var(--error); }
.preview { border-color: var(--warning); }
.experimental {
margin-left: 10px;
border: 1px solid var(--error);
border-radius: 5px;
padding: 2px 10px;
font-size: 12px;
}
.no-search-match {
text-align: center;
+5 -12
View File
@@ -55,21 +55,14 @@ export default {
const host = window.location.host;
const prefix = window.location.pathname.replace(this.$route.path, '');
const params = this.$route?.params;
const popupWidth = Math.max(800, screen.width - 200);
const popupHeight = Math.max(600, screen.height - 200);
const popupLeft = Math.max(0, window.screenX + Math.floor((window.outerWidth - popupWidth) / 2));
const popupTop = Math.max(0, window.screenY + Math.floor((window.outerHeight - popupHeight) / 2));
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=${ popupWidth },height=${ popupHeight },left=${ popupLeft },top=${ popupTop },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,649 +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 { VOLUME_MODE, ACCESS_MODE } from '../../config/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';
import StorageDefaultsModal from './StorageDefaultsModal.vue';
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
const DEFAULT_ACCESS_MODE = ACCESS_MODE?.READ_WRITE_MANY ?? 'ReadWriteMany';
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);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
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, { markOverridden = false, captureInherited = false } = {}) => {
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;
if (captureInherited) {
entry.inheritedFromProvider = true;
}
if (match.destination.volumeMode) {
entry.volumeMode = match.destination.volumeMode;
if (captureInherited) {
entry.inheritedVolumeMode = match.destination.volumeMode;
}
}
if (match.destination.accessMode) {
entry.accessModes = [match.destination.accessMode];
if (captureInherited) {
entry.inheritedAccessModes = [match.destination.accessMode];
}
}
if (markOverridden) {
entry.overridden = true;
}
}
});
};
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, { captureInherited: true });
};
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));
// The "Inherited from provider" hint only appears in the migration plan wizard
// (where mappings inherit from the provider default), not on the provider creation page.
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
const openStorageDefaults = (entry) => {
editingStorageEntry.value = entry;
showStorageDefaultsModal.value = true;
};
const closeStorageDefaults = () => {
showStorageDefaultsModal.value = false;
editingStorageEntry.value = null;
};
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
if (editingStorageEntry.value) {
editingStorageEntry.value.volumeMode = volumeMode;
editingStorageEntry.value.accessModes = accessModes;
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
const selectedAccessMode = accessModes?.[0];
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
}
};
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: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [DEFAULT_ACCESS_MODE],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
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: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [DEFAULT_ACCESS_MODE],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
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, { markOverridden: true });
} 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"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
>
<template #source-detail="{ entry }">
<span
v-if="entry.type || entry.capacity"
class="source-detail text-deemphasized"
>{{ formatStorageDetail(entry) }}</span>
</template>
</MappingColumn>
</div>
<StorageDefaultsModal
v-if="showStorageDefaultsModal && editingStorageEntry"
:storage-class-name="editingStorageEntry.target"
:provider-name="providerName"
:show-inherited="!!inheritedProviderName"
:volume-mode="editingStorageEntry.volumeMode"
:access-modes="editingStorageEntry.accessModes"
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
@apply="applyStorageDefaults"
@close="closeStorageDefaults"
/>
</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,573 +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,
vddkInitImage,
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;
vddkInitImage.value = '';
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 || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
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;
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
createdProvider.value.spec.settings = {
...(createdProvider.value.spec.settings || {}),
vddkInitImage: vddkImage,
};
} else if (createdProvider.value.spec.settings) {
delete createdProvider.value.spec.settings.vddkInitImage;
}
await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret;
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 spec = {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
};
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
spec.settings = { vddkInitImage: vddkImage };
}
const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec,
});
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 class="row">
<div class="col span-6">
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="vddkInitImage"
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
:disabled="isExistingProvider && !editMode"
/>
</div>
</div>
<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,252 +0,0 @@
<script setup>
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE } from '../../config/types';
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 },
// Storage-specific: show the volume/access mode defaults row with an Edit action.
showVolumeSettings: { type: Boolean, default: false },
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
inheritedProviderName: { type: String, default: '' },
});
const emit = defineEmits(['edit-defaults']);
// Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
return props.options;
}
return [
{
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
value: null,
kind: 'highlighted',
},
{
label: 'divider',
disabled: true,
kind: 'divider',
},
...props.options,
];
};
const formatModes = (entry) => {
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
const accessModes = (entry.accessModes || []).join(', ');
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
};
</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="optionsFor(entry)"
:placeholder="placeholder+'...'"
:searchable="true"
/>
</div>
</div>
<div
v-if="showVolumeSettings && entry.target"
class="storage-defaults-row"
>
<div class="storage-defaults">
<div class="storage-defaults-info">
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
<span
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
class="text-deemphasized storage-defaults-inherited"
>
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
</span>
</div>
<a
role="button"
class="storage-defaults-edit"
@click.prevent="emit('edit-defaults', entry)"
>
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
</a>
</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;
}
.storage-defaults-row {
width: 100%;
}
.storage-defaults {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 4px;
background-color: var(--body-bg);
.storage-defaults-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.storage-defaults-summary {
font-size: 13px;
line-height: 20px;
}
.storage-defaults-inherited {
font-size: 12px;
line-height: 18px;
}
.storage-defaults-edit {
flex-shrink: 0;
cursor: pointer;
}
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
</style>
@@ -1,674 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
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', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore();
const { t } = useI18n(store);
const vms = computed(() => props.selectedVms);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE;
const nameConflictError = computed(() => {
const name = (planName.value || '').trim();
if (!name) {
return '';
}
if (existingProviderNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
}
if (existingPlanNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
}
return '';
});
watch(planName, (val) => {
props.stepData.planName = val;
}, { immediate: true });
watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val;
});
watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true });
const formatNetworkTarget = (target = '') => {
if (target === 'pod') {
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
}
if (target === 'ignored') {
return t('harvester.addons.vmMigration.generic.ignored');
}
return target;
};
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => {
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 vmRows = 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 rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id,
name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os,
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
networkMapping: networkMappingSort,
storageMapping: storageMappingSort,
};
});
});
const vmHeaders = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
value: 'name',
sort: ['name'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
value: 'os',
sort: ['os'],
width: 140,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
value: 'resources',
sort: false,
width: 120,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 100,
},
{
name: 'networkMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
value: 'networkMapping',
sort: ['networkMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'storageMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
value: 'storageMapping',
sort: ['storageMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'actions',
label: ' ',
align: 'right',
sort: false,
width: 40,
},
];
const onEditSelection = () => emit('edit-selection');
const onEditMappings = () => emit('edit-mappings');
const onRemoveVm = (id) => emit('remove-vm', id);
const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: props.providerName,
namespace: NS,
},
destination: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: 'host',
namespace: NS,
},
};
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: NS },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NS },
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: NS },
spec: {
provider: providerRef,
map: {
network: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: networkMapName,
namespace: NS,
},
storage: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: storageMapName,
namespace: NS,
},
},
targetNamespace: targetNamespace.value,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.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 = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
} catch (e) {
namespaceOptions.value = [];
}
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {}
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
} catch (e) {}
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
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"
>
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary -->
<div class="migration-details">
<div class="section-header">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row">
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
<LabeledSelect
v-model:value="targetNamespace"
class="namespace-select"
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
:options="namespaceOptions"
required
/>
</div>
<div class="summary-row">
<div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div>
<div class="stats-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-value">{{ providerName }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value">{{ vms.length }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div>
</div>
</div>
<!-- Virtual Machines -->
<div class="vm-section">
<SortableTable
class="vm-sortable-table"
:rows="vmRows"
:headers="vmHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
>
<template #header-left>
<h4 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h4>
</template>
<template #header-right>
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
</a>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.name }}</span>
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resources }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:networkMapping="{ row }">
<template v-if="row.networkMappings.length">
<div
v-for="(m, i) in row.networkMappings"
:key="'net-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:storageMapping="{ row }">
<template v-if="row.storageMappings.length">
<div
v-for="(m, i) in row.storageMappings"
:key="'stor-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ m.target }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:actions="{ row }">
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
<rc-dropdown-trigger
variant="ghost"
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<rc-dropdown-item @click="onEditMappings">
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
</rc-dropdown-item>
<rc-dropdown-item
class="text-error"
@click="onRemoveVm(row.id)"
>
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>
<template #no-rows>
<tr class="no-rows">
<td
:colspan="vmHeaders.length"
class="text-center"
>
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
</td>
</tr>
</template>
</SortableTable>
</div>
<!-- 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;
}
.name-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
align-items: start;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
line-height: 20px;
margin-top: 12px;
}
.section-header {
display: flex;
flex-direction: column;
gap: 4px;
.section-description {
font-size: 14px;
}
}
.migration-mode-column {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-size: 14px;
.cold-migration-badge {
padding: 2px 10px;
border-radius: 4px;
opacity: 0.8;
font-weight: 600;
background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600;
.theme-light & {
background: rgba(168, 128, 26, 0.16);
}
}
}
.stats-column {
display: grid;
grid-template-columns: auto auto;
justify-content: start;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
}
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
}
.edit-selection-link {
cursor: pointer;
color: var(--link);
font-size: 14px;
}
.vm-sortable-table {
:deep(.fixed-header-actions) {
align-items: end;
}
:deep(table) {
table-layout: fixed;
}
:deep(td) {
vertical-align: middle;
overflow: hidden;
}
:deep(th:first-child),
:deep(td:first-child) {
padding-left: 16px;
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
.vm-name {
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
}
.mapping-cell {
.mapping-target {
margin-left: 4px;
white-space: nowrap;
}
}
.mapping-cell + .mapping-cell {
margin-top: 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,668 +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);
// Restored from stepData so friendly names survive step navigation / remount.
const networkMap = ref(props.stepData.networkMap || {});
const datastoreMap = ref(props.stepData.datastoreMap || {});
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 = ref([
{
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,
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
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();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
}
});
};
const selectAllVMs = () => {
allVMsSelected.value = true;
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
selectedVMs.value = discoveredVMs.value.slice();
};
// Moves currently-selected VMs to the first page(s) and re-checks them.
const sortSelectedToFront = () => {
if (selectedVMIds.value.size === 0) {
return;
}
tableRows.value.forEach((row) => {
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
});
headers.value = headers.value.map((h) => (
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
));
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
skipNextSelectionEvent = false;
return;
}
if (typeof table.changeSort === 'function') {
table.changeSort('vmName', false);
} else if (typeof table.setPage === 'function') {
table.setPage(1);
}
nextTick(() => {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
});
};
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;
}, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
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;
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
await nextTick();
sortSelectedToFront();
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"
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="selected-actions">
<span class="text-deemphasized">
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
</span>
<template v-if="selectedCount > 0">
<span class="text-deemphasized">|</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
</a>
</template>
</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-success' : '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;
}
.selected-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
a {
cursor: pointer;
&.disabled {
color: var(--muted);
cursor: default;
pointer-events: none;
}
}
}
}
.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,181 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import AppModal from '@shell/components/AppModal';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
const {
READ_WRITE_ONCE = 'ReadWriteOnce',
READ_ONLY_MANY = 'ReadOnlyMany',
READ_WRITE_MANY = 'ReadWriteMany',
READ_WRITE_ONCE_POD = 'ReadWriteOncePod'
} = ACCESS_MODE || {};
const ACCESS_MODE_OPTIONS = [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY, READ_WRITE_ONCE_POD];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
accessModes: { type: Array, default: () => ['ReadWriteMany'] },
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
inheritedAccessModes: { type: Array, default: () => ['ReadWriteMany'] },
});
const emit = defineEmits(['apply', 'close']);
const store = useStore();
const { t } = useI18n(store);
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
const localAccessMode = ref(props.accessModes?.[0] || READ_WRITE_MANY);
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
const apply = () => {
emit('apply', {
volumeMode: localVolumeMode.value,
accessModes: [localAccessMode.value],
});
emit('close');
};
// Repopulate the dropdowns with the provider default values; the user then applies.
const reset = () => {
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
localAccessMode.value = props.inheritedAccessModes?.[0] || READ_WRITE_MANY;
};
const cancel = () => {
emit('close');
};
</script>
<template>
<app-modal
class="storage-defaults-modal"
name="storageDefaultsDialog"
:width="620"
height="auto"
:click-to-close="false"
@close="cancel"
>
<Card
class="storage-defaults-card"
:show-highlight-border="false"
>
<template #title>
<h4 class="text-default-text">
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
</h4>
</template>
<template #body>
<p class="text-deemphasized description">
<template v-if="showInherited">
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
</template>
<template v-else>
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
</template>
</p>
<div class="settings-box">
<LabeledSelect
v-model:value="localVolumeMode"
class="volume-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
:options="volumeModeOptions"
:searchable="false"
:clearable="false"
/>
<LabeledSelect
v-model:value="localAccessMode"
class="access-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
:options="accessModeOptions"
:searchable="false"
:clearable="false"
/>
</div>
</template>
<template #actions>
<div class="buttons">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
</button>
<div class="right-buttons">
<button
v-if="showInherited"
class="btn role-secondary mr-10"
@click="reset"
>
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
</button>
<button
class="btn role-primary"
:disabled="!localAccessMode"
@click="apply"
>
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
</button>
</div>
</div>
</template>
</Card>
</app-modal>
</template>
<style lang="scss" scoped>
.storage-defaults-modal {
z-index: 45;
}
.storage-defaults-card {
margin: 0;
padding: 20px;
}
.description {
margin-bottom: 16px;
line-height: 20px;
}
.settings-box {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px;
border-radius: 6px;
background-color: var(--category-active);
}
.volume-mode {
max-width: 280px;
}
.access-mode {
max-width: 280px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right-buttons {
display: flex;
}
}
</style>
+1 -16
View File
@@ -65,23 +65,8 @@ const FEATURE_FLAGS = {
'vGPUAsPCIDevice',
'instanceManagerResourcesSetting',
'rwxNetworkSetting',
'createPVCWithDataVolume',
'clusterPodSecurityStandardSetting',
'createPVCWithDataVolume'
],
'v1.8.1': [],
'v1.9.0': [
'supportFilesystem',
'disableResourcePooling',
'expandOnlineEncryptedVolume',
'longhornV2HugepageSettings',
'staticIPForVM',
'fsFreezeDeadline',
'highPerformanceStorage',
],
'v1.9.1': [
'lvmDmThinOnly',
],
'v1.10.0': [],
};
const generateFeatureFlags = () => {
+19 -433
View File
@@ -47,30 +47,13 @@ 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';
import { setVendor } from '@shell/config/private-label';
import { createCssVars } from '@shell/utils/color';
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';
@@ -111,51 +94,15 @@ export function init($plugin, store) {
}
};
store.watch(
(_state, getters) => JSON.stringify([
!!getters['harvester-common/isHarvesterPrime'],
getters['prefs/theme'] === 'dark',
getters['harvester-common/privateLabel']
]),
(value) => {
const [isHarvesterPrime, isDark, privateLabel] = JSON.parse(value);
const primeLogo = isDark ? require('../assets/images/dark/suse-virtualization.svg') : require('../assets/images/suse-virtualization.svg');
setVendor(privateLabel || '');
let primaryColorStyle = document.getElementById('harvester-prime-primary-color');
// set harvester prime primary color style to #4dd192 (Cyan Green)
if (isHarvesterPrime) {
if (!primaryColorStyle) {
primaryColorStyle = document.createElement('style');
primaryColorStyle.id = 'harvester-prime-primary-color';
document.head.appendChild(primaryColorStyle);
primaryColorStyle.sheet.insertRule('body.theme-light, body.theme-dark {}', 0);
}
const colors = createCssVars('#4dd192', isDark ? 'dark' : 'light');
const rule = primaryColorStyle.sheet.cssRules[0];
Object.entries(colors).forEach(([property, color]) => rule.style.setProperty(property.trim(), color));
} else {
primaryColorStyle?.remove();
}
const primeLabelKey = privateLabel ? 'harvester.branding.primeLabel' : 'harvester.branding.primeEmptyLabel';
store.dispatch('setIsSingleProduct', {
productName: PRODUCT_NAME,
logo: isHarvesterPrime ? primeLogo : require(`@shell/assets/images/providers/harvester.svg`),
productNameKey: isHarvesterPrime ? primeLabelKey : 'harvester.productLabel',
getVersionInfo: (store) => store.getters[`${ PRODUCT_NAME }/byId`]?.(HCI.SETTING, 'server-version')?.value || 'unknown',
afterLoginRoute: home,
logoRoute: home,
supportCustomLogo: !isHarvesterPrime
});
},
{ immediate: true }
);
store.dispatch('setIsSingleProduct', {
productName: PRODUCT_NAME,
logo: require(`@shell/assets/images/providers/harvester.svg`),
productNameKey: 'harvester.productLabel',
getVersionInfo: (store) => store.getters[`${ PRODUCT_NAME }/byId`]?.(HCI.SETTING, 'server-version')?.value || 'unknown',
afterLoginRoute: home,
logoRoute: home,
supportCustomLogo: true
});
}
product({
@@ -290,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 }
@@ -320,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 }
@@ -350,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 }
@@ -379,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 }
@@ -541,7 +484,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_FLOW,
labelKey: 'harvester.logging.clusterFlow.label',
name: HCI.CLUSTER_FLOW,
namespaced: true,
@@ -565,7 +507,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_OUTPUT,
labelKey: 'harvester.logging.clusterOutput.label',
name: HCI.CLUSTER_OUTPUT,
namespaced: true,
@@ -589,7 +530,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.FLOW,
labelKey: 'harvester.logging.flow.label',
name: HCI.FLOW,
namespaced: true,
@@ -613,7 +553,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.OUTPUT,
labelKey: 'harvester.logging.output.label',
name: HCI.OUTPUT,
namespaced: true,
@@ -634,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,
@@ -692,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(
[
@@ -775,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 }
@@ -790,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 }
@@ -811,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 }
@@ -820,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 }
@@ -895,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: {
@@ -1296,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 }
@@ -1335,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 }
@@ -1344,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,
]
});
// ===========================================================================
}
-21
View File
@@ -39,21 +39,6 @@ export const VOLUME_TYPE = [{
value: 'cd-rom'
}];
// KubeVirt high-performance disk features
// https://kubevirt.io/user-guide/storage/disks_and_volumes/#high-performance-features
export const DISK_CACHE_MODE = ['', 'none', 'writeback', 'writethrough'];
export const DISK_IO_MODE = ['', 'native', 'threads'];
export const IO_THREADS_POLICY = ['', 'shared', 'auto', 'supplementalPool'];
// "Easy-to-consume" presets layered on top of the raw KubeVirt fields.
export const DISK_PERFORMANCE_PROFILE = {
DEFAULT: 'default',
HIGH: 'highPerformance',
CUSTOM: 'custom',
};
export const VOLUME_HOTPLUG_ACTION = {
INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE',
EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE',
@@ -94,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 = {
@@ -104,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',
@@ -33,11 +31,6 @@ export const HCI = {
CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status',
MIGRATION_STATE: 'harvesterhci.io/migrationState',
VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates',
// Default KubeVirt high-performance disk settings, applied when the volume is
// attached to a VM as a disk. See disks_and_volumes.md#high-performance-features.
DISK_CACHE_MODE: 'harvesterhci.io/disk-cache-mode',
DISK_IO_MODE: 'harvesterhci.io/disk-io-mode',
DISK_DEDICATED_IOTHREAD: 'harvesterhci.io/disk-dedicated-iothread',
IMAGE_NAME: 'harvesterhci.io/image-name',
INIT_IP: 'etcd.rke2.cattle.io/node-address',
NODE_SCHEDULABLE: 'kubevirt.io/schedulable',
@@ -86,10 +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',
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
};
+44 -62
View File
@@ -1,51 +1,47 @@
export const HCI_SETTING = {
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
SERVER_FLAVOR: 'server-flavor',
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 = {
@@ -69,7 +65,6 @@ export const HCI_ALLOWED_SETTINGS = {
kind: 'json', from: 'import', featureFlag: 'csiOnlineExpandValidation'
},
[HCI_SETTING.SERVER_VERSION]: { readOnly: true },
[HCI_SETTING.SERVER_FLAVOR]: { readOnly: true },
[HCI_SETTING.UPGRADE_CHECKER_ENABLED]: { kind: 'boolean' },
[HCI_SETTING.UPGRADE_CHECKER_URL]: { kind: 'url' },
[HCI_SETTING.HTTP_PROXY]: { kind: 'json', from: 'import' },
@@ -113,19 +108,9 @@ export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES]: { kind: 'number', featureFlag: 'kubeconfigDefaultTokenTTLMinutesSetting' },
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_ENABLED]: {
kind: 'boolean',
preview: true,
experimental: true,
featureFlag: 'longhornV2LVMSupport'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: {
kind: 'boolean',
preview: true,
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: {
kind: 'number',
preview: true,
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
[HCI_SETTING.UPGRADE_CONFIG]: {
kind: 'json',
@@ -145,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: 'center',
};
// 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',
};
-13
View File
@@ -15,13 +15,6 @@ export const VOLUME_MODE = {
FILE_SYSTEM: 'Filesystem'
};
export const ACCESS_MODE = {
READ_WRITE_MANY: 'ReadWriteMany',
READ_WRITE_ONCE: 'ReadWriteOnce',
READ_ONLY_MANY: 'ReadOnlyMany',
READ_WRITE_ONCE_POD: 'ReadWriteOncePod',
};
export const NETWORK_PROTOCOL = {
IPv4: 'IPv4',
IPv6: 'IPv6',
@@ -53,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({
@@ -214,14 +214,6 @@ export default {
this['diskRows'] = diskRows;
this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false });
// getInitConfig() seeds these on created(); refresh them here too so the
// VM-wide performance panel does not go stale when the spec changes.
const domain = neu?.spec?.template?.spec?.domain;
this['blockMultiQueue'] = domain?.devices?.blockMultiQueue || false;
this['ioThreadsPolicy'] = domain?.ioThreadsPolicy || '';
this['ioThreadCount'] = domain?.ioThreads?.supplementalPoolThreadCount || 2;
},
deep: true
}
@@ -262,9 +254,6 @@ export default {
:namespace="value.metadata.namespace"
:vm="value"
:resource-type="value.type"
:block-multi-queue="blockMultiQueue"
:io-threads-policy="ioThreadsPolicy"
:io-thread-count="ioThreadCount"
/>
</Tab>
@@ -401,17 +390,14 @@ export default {
:vmi-resource="vmi"
:vmim-resource="vmim"
/>
<div
<DashboardMetrics
v-if="showVmMetrics && liveMigrationProgressEnabled"
class="migration-metrics-scroll mb-30"
>
<DashboardMetrics
:detail-url="VM_MIGRATION_DETAIL_URL"
graph-height="960px"
:has-summary-and-detail="false"
:vars="graphVars"
/>
</div>
:detail-url="VM_MIGRATION_DETAIL_URL"
graph-height="640px"
:has-summary-and-detail="false"
:vars="graphVars"
class="mb-30"
/>
<Events
v-if="liveMigrationProgressEnabled"
:resource="vmi"
@@ -450,10 +436,3 @@ export default {
</Tabbed>
</div>
</template>
<style lang="scss" scoped>
.migration-metrics-scroll {
max-height: 640px;
overflow-y: auto;
}
</style>
@@ -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>
@@ -1,133 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { Checkbox } from '@components/Form/Checkbox';
import { escapeHtml } from '@shell/utils/string';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterEnableSriovGpuDevice',
emits: ['close'],
components: {
AsyncButton,
Card,
Checkbox,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return { skipMigConfiguration: this.resources[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true' };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
this.$emit('close');
},
async save(buttonCb) {
const actionResource = this.resources[0];
const prevEnabled = actionResource.spec.enabled;
const prevAnnotation = actionResource.metadata.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
try {
if (this.skipMigConfiguration) {
if (!actionResource.metadata.annotations) {
actionResource.metadata.annotations = {};
}
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = 'true';
} else {
if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
}
actionResource.spec.enabled = true;
await actionResource.save();
buttonCb(true);
this.close();
} catch (err) {
actionResource.spec.enabled = prevEnabled;
if (prevAnnotation !== undefined) {
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = prevAnnotation;
} else if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(actionResource.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.sriovgpu.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<Checkbox
v-model:value="skipMigConfiguration"
:label="t('harvester.sriovgpu.enable.skipMigConfiguration')"
/>
<p class="note mt-10">
{{ t('harvester.sriovgpu.enable.note') }}
</p>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
@click="save"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
.note {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
</style>
@@ -2,16 +2,13 @@
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { mapGetters } from 'vuex';
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
import { PVC } from '@shell/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI } from '@pkg/harvester/types';
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 { Checkbox } from '@components/Form/Checkbox';
export default {
name: 'HotplugVolumeModal',
@@ -19,7 +16,7 @@ export default {
emits: ['close'],
components: {
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
AsyncButton, Card, LabeledInput, LabeledSelect, Banner
},
props: {
@@ -31,19 +28,14 @@ export default {
async fetch() {
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
this.allStorageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
this.allVMs = await this.$store.dispatch('harvester/findAll', { type: HCI.VM });
},
data() {
return {
diskName: '',
volumeName: '',
shareable: false,
errors: [],
allPVCs: [],
allStorageClasses: [],
allVMs: [],
diskName: '',
volumeName: '',
errors: [],
allPVCs: [],
};
},
@@ -58,31 +50,6 @@ export default {
return this.resources[0];
},
// claim names already attached to the VM this modal was opened for
currentVMClaimNames() {
const volumes = this.actionResource?.spec?.template?.spec?.volumes || [];
return volumes.map((vol) => vol.persistentVolumeClaim?.claimName).filter((name) => !!name);
},
// claim names attached to other VMs in the same namespace
otherVMClaimNames() {
const out = [];
this.allVMs.forEach((vm) => {
if (vm.metadata.namespace !== this.actionResource?.metadata?.namespace || vm.id === this.actionResource?.id) {
return;
}
(vm.spec?.template?.spec?.volumes || []).forEach((vol) => {
if (vol.persistentVolumeClaim?.claimName) {
out.push(vol.persistentVolumeClaim.claimName);
}
});
});
return out;
},
volumeOption() {
return sortBy(
this.PVCs
@@ -94,14 +61,6 @@ export default {
if (pvc.isGoldenImageVolume) {
return false;
}
// a volume attached to this VM can never be attached to it again
if (this.currentVMClaimNames.includes(pvc.metadata.name)) {
return false;
}
// a volume attached to another VM can only be re-attached as a shareable disk
if (this.otherVMClaimNames.includes(pvc.metadata.name) && !this.isShareableCapablePVC(pvc)) {
return false;
}
return true;
})
@@ -114,62 +73,19 @@ export default {
'label'
);
},
selectedPVC() {
return this.PVCs.find((P) => P.metadata.name === this.volumeName);
},
isShareableCapable() {
return this.selectedPVC ? this.isShareableCapablePVC(this.selectedPVC) : false;
},
},
watch: {
isShareableCapable(neu) {
if (!neu) {
this.shareable = false;
}
},
},
methods: {
isShareableCapablePVC(pvc) {
const pvcSpec = pvc?.spec;
if (!pvcSpec) {
return false;
}
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
const storageClass = this.allStorageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
// fail closed: without a resolved StorageClass the provisioner
// requirement cannot be evaluated
if (!storageClass) {
return false;
}
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
},
close() {
this.diskName = '';
this.volumeName = '';
this.shareable = false;
this.$emit('close');
},
async save(buttonCb) {
if (this.actionResource) {
try {
const input = { volumeSourceName: this.volumeName, diskName: this.diskName };
if (this.isShareableCapable && this.shareable) {
input.shareable = true;
}
const res = await this.actionResource.doAction('addVolume', input, {}, false);
const res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false);
if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', {
@@ -225,19 +141,6 @@ export default {
class="mt-20"
required
/>
<Checkbox
v-if="isShareableCapable"
v-model:value="shareable"
class="mt-20"
type="checkbox"
label-key="harvester.virtualMachine.volume.shareable.label"
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
/>
<Banner
v-if="isShareableCapable && shareable"
color="warning"
:label="t('harvester.virtualMachine.volume.shareable.warning')"
/>
<Banner
v-for="(err, i) in errors"
:key="i"
+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"
-164
View File
@@ -1,164 +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 = {
rancher: {
hostname: '',
version: '',
bootstrapPassword: '',
},
};
export default {
name: 'HarvesterEnableK3k',
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 = merge({}, DEFAULT_VALUE);
}
return { valuesContentJson };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
buttonDisabled() {
const { rancher } = this.valuesContentJson;
return !(rancher?.hostname || '').trim() || !(rancher?.version || '').trim() || !(rancher?.bootstrapPassword || '').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.rancherK3k.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.rancher.hostname"
:required="true"
:label="t('harvester.addons.rancherK3k.hostname')"
placeholder="rancher.$vip.nip.io"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.version"
:required="true"
:label="t('harvester.addons.rancherK3k.version')"
placeholder="v1.14.0"
/>
</div>
</div>
<div class="row mb-15">
<div class="col span-12">
<LabeledInput
v-model:value="valuesContentJson.rancher.bootstrapPassword"
:required="true"
type="password"
:label="t('harvester.addons.rancherK3k.password')"
:tooltip="t('harvester.addons.rancherK3k.passwordTooltip')"
/>
</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,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>
@@ -1,167 +0,0 @@
<script>
import merge from 'lodash/merge';
import jsyaml from 'js-yaml';
import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
const DEFAULT_VALUE = {
certManagerVersion: 'v1.20.2',
rancher: {
hostname: '',
version: '',
bootstrapPassword: '',
},
k3kCluster: {
servers: 1,
version: 'v1.35.4-k3s1',
storageClassName: 'harvester-longhorn',
},
};
export default {
name: 'EditAddonRancherK3k',
components: { LabeledInput, RadioGroup },
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true
},
registerBeforeHook: {
type: Function,
required: true,
},
},
data() {
let valuesContentJson = {};
try {
valuesContentJson = merge({}, DEFAULT_VALUE, jsyaml.load(this.value.spec.valuesContent));
} catch (err) {
valuesContentJson = merge({}, DEFAULT_VALUE);
this.$store.dispatch('growl/fromError', {
title: this.$store.getters['i18n/t']('generic.notification.title.error'),
err: err.data || err,
}, { root: true });
}
return { valuesContentJson };
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.willSave, 'willSave');
}
},
methods: {
willSave() {
const errors = [];
if (!this.value.spec.enabled) {
return Promise.resolve();
}
if (!this.valuesContentJson.rancher.hostname) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherK3k.hostname') }, true));
}
if (!this.valuesContentJson.rancher.version) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherK3k.version') }, true));
}
if (!this.valuesContentJson.rancher.bootstrapPassword) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherK3k.password') }, true));
}
if (errors.length > 0) {
return Promise.reject(errors);
} else {
return Promise.resolve();
}
},
},
watch: {
valuesContentJson: {
handler(neu) {
this.value.spec['valuesContent'] = jsyaml.dump(neu);
},
deep: true,
immediate: true
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col span-12">
<RadioGroup
v-model:value="value.spec.enabled"
class="mb-20"
name="model"
:mode="mode"
:options="[true,false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
/>
</div>
</div>
<template v-if="value.spec.enabled">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.hostname"
label-key="harvester.addons.rancherK3k.hostname"
:required="true"
:mode="mode"
placeholder="rancher.$vip.nip.io"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.version"
label-key="harvester.addons.rancherK3k.version"
:required="true"
:mode="mode"
placeholder="v1.14.0"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.bootstrapPassword"
label-key="harvester.addons.rancherK3k.password"
:mode="mode"
:required="true"
type="password"
:tooltip="t('harvester.addons.rancherK3k.passwordTooltip')"
/>
</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
:deep() .radio-group {
display: flex;
.radio-container {
margin-right: 30px;
}
}
</style>
@@ -7,7 +7,6 @@ import NameNsDescription from '@shell/components/form/NameNsDescription';
import YamlEditor from '@shell/components/YamlEditor';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { HCI } from '@pkg/harvester/config/labels-annotations';
export default {
@@ -24,19 +23,14 @@ export default {
NameNsDescription,
},
mixins: [CreateEditView, FormValidation],
mixins: [CreateEditView],
inheritAttrs: false,
data() {
return {
config: this.value.data?.cloudInit || '',
type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user',
fvFormRuleSets: [{
path: 'metadata.name',
rules: ['required'],
translationKey: 'nameNsDescription.name.label',
}],
config: this.value.data?.cloudInit || '',
type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user',
};
},
@@ -84,7 +78,6 @@ export default {
:resource="value"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="fvFormIsValid"
@finish="save"
@cancel="done"
@error="e=>errors=e"
@@ -93,7 +86,6 @@ export default {
:value="value"
:mode="mode"
:namespaced="true"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
@@ -116,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>
@@ -1,5 +1,5 @@
<script>
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox';
@@ -10,10 +10,6 @@ import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params';
const {
READ_WRITE_ONCE, READ_ONLY_MANY, READ_WRITE_MANY, READ_WRITE_ONCE_POD
} = ACCESS_MODE;
export default {
name: 'CDISettings',
@@ -94,10 +90,10 @@ export default {
accessModeOptions() {
return [
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY },
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY },
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
];
},
@@ -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
@@ -7,12 +7,8 @@ import { allHash } from '@shell/utils/promise';
import { clone } from '@shell/utils/object';
import { HCI } from '../../../types';
import { NODE } from '@shell/config/types';
import { _CREATE, _CLONE } from '@shell/config/query-params';
import { LVM_TOPOLOGY_LABEL } from '../index.vue';
const STRIPED_TYPE = 'striped';
const DM_THIN_TYPE = 'dm-thin';
const DEFAULT_PARAMETERS = [
'type',
'vgName'
@@ -58,17 +54,8 @@ export default {
data() {
const node = (this.value.allowedTopologies?.[0]?.matchLabelExpressions || []).find((t) => t.key === LVM_TOPOLOGY_LABEL)?.values[0];
// striped is dropped once the LVM add-on reaches GA (v1.9.1); dm-thin is the only supported type.
const dmThinOnly = this.$store.getters['harvester-common/getFeatureEnabled']('lvmDmThinOnly');
const volumeGroupTypes = dmThinOnly ? [DM_THIN_TYPE] : [STRIPED_TYPE, DM_THIN_TYPE];
// Default new storage classes to dm-thin, and reset a cloned striped type that is no longer selectable.
if (dmThinOnly && [_CREATE, _CLONE].includes(this.realMode) && !volumeGroupTypes.includes(this.value.parameters.type)) {
this.value.parameters.type = DM_THIN_TYPE;
}
return {
volumeGroupTypes,
volumeGroupTypes: ['striped', 'dm-thin'],
node,
};
},
@@ -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: {
@@ -308,9 +301,6 @@ export default {
>
<Volume
v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode"
:namespace="value.metadata.namespace"
:existing-volume-disabled="true"
@@ -359,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
+7 -47
View File
@@ -8,7 +8,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions';
import DiskPerformanceOptions from './kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions';
import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import jsyaml from 'js-yaml';
@@ -27,11 +26,9 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { isInternalStorageClass } from '../utils/storage-class';
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
export default {
name: 'HarvesterVolume',
@@ -48,8 +45,7 @@ export default {
LabeledSelect,
LabeledInput,
NameNsDescription,
Conditions,
DiskPerformanceOptions
Conditions
},
mixins: [CreateEditView],
@@ -87,7 +83,7 @@ export default {
if (this.mode === _CREATE) {
// default volumeMode to Block
this.value.spec.volumeMode = VOLUME_MODE.BLOCK;
this.value.spec.accessModes = [READ_WRITE_MANY];
this.value.spec.accessModes = ['ReadWriteMany'];
}
const storage = this.value?.spec?.resources?.requests?.storage || null;
@@ -102,15 +98,7 @@ export default {
createWithDataVolume: false,
snapshots: [],
images: [],
GIBIBYTE,
// Default KubeVirt high-performance disk profile, stored as annotations and
// applied when this volume is later attached to a VM as a disk.
perf: {
cache: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_CACHE_MODE }"`) || '',
io: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_IO_MODE }"`) || '',
dedicatedIOThread: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD }"`) === 'true',
volumeMode: this.value?.spec?.volumeMode,
},
GIBIBYTE
};
},
@@ -172,7 +160,7 @@ export default {
},
accessModeOptions() {
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
},
imageOption() {
@@ -342,7 +330,6 @@ export default {
}
},
'value.spec.volumeMode'(neu) {
this.perf.volumeMode = neu;
if (neu === VOLUME_MODE.FILE_SYSTEM) {
this.setVolumeForVmAnnotation();
} else if (neu === VOLUME_MODE.BLOCK ) {
@@ -368,7 +355,7 @@ export default {
getAccessMode() {
if (!this.longhornV2LVMSupport) {
return [READ_WRITE_MANY];
return ['ReadWriteMany'];
}
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@@ -384,7 +371,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
},
buildDataVolumeObj() {
const storage = {
@@ -457,23 +444,6 @@ export default {
imageAnnotations = { ...this.value.metadata.annotations };
}
// Persist the default high-performance disk profile as annotations.
if (this.perf.cache) {
imageAnnotations[HCI_ANNOTATIONS.DISK_CACHE_MODE] = this.perf.cache;
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_CACHE_MODE];
}
if (this.perf.io) {
imageAnnotations[HCI_ANNOTATIONS.DISK_IO_MODE] = this.perf.io;
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_IO_MODE];
}
if (this.perf.dedicatedIOThread) {
imageAnnotations[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] = 'true';
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD];
}
const spec = {
...this.value.spec,
resources: { requests: { storage: this.storage } },
@@ -638,16 +608,6 @@ export default {
class="mb-20"
@update:value="update"
/>
<Banner
color="info"
:label="t('harvester.volume.performance.pvcTip')"
/>
<DiskPerformanceOptions
:value="perf"
:mode="mode"
@update="update"
/>
</Tab>
<Tab
v-if="!isCreate"
@@ -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>
+10 -62
View File
@@ -9,7 +9,7 @@ import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types';
import { set, remove } from '@shell/utils/object';
import { set } from '@shell/utils/object';
import ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types';
@@ -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,22 +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) || [];
const options = vlans.map((vlan) => ({
label: vlan.id,
value: vlan.id,
}));
return [{ label: this.t('generic.none'), value: '' }, ...options];
}
},
@@ -162,14 +143,6 @@ export default {
}
},
methods: {
onVlanChange(value) {
if (value === '') {
remove(this.value.spec, 'vlan');
} else {
set(this.value, 'spec.vlan', value);
}
},
async saveSubnet(buttonCb) {
const errors = [];
const name = this.value?.metadata?.name;
@@ -291,17 +264,6 @@ export default {
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.vlan ?? ''"
class="mb-20"
:options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')"
:mode="mode"
@update:value="onVlanChange"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
@@ -342,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>
@@ -68,6 +68,12 @@ export default {
},
watch: {
osType(neu) {
if (neu === 'windows') {
this.id = '';
}
},
value(neu) {
this.yamlScript = neu;
},
@@ -137,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,32 +232,3 @@ export default {
</template>
</ResourceTable>
</template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
// Match the enable/disable passthrough bulk-action buttons height to the .btn (40px) on the right.
:deep(.bulk .rc-button.btn-medium.bulk-action:not(.btn-sm)) {
min-height: 40px;
}
// Make the collapsed "Actions" dropdown button 40px too.
:deep(.bulk .rc-button.btn-medium.bulk-actions-dropdown:not(.btn-sm)) {
min-height: 40px;
}
// Lay the bulk row out with flex so the "N selected" label can wrap below the buttons.
:deep(.bulk) {
display: flex;
flex-wrap: wrap;
align-items: center;
}
:deep(.bulk .action-availability) {
flex-basis: 100%;
margin-left: 0;
margin-top: 6px;
}
</style>
@@ -5,7 +5,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import Banner from '@components/Banner/Banner.vue';
import remove from 'lodash/remove';
import { set } from '@shell/utils/object';
import { uniq } from '@shell/utils/array';
import { HCI } from '../../../types';
import DeviceList from './DeviceList';
import CompatibilityMatrix from '../CompatibilityMatrix';
@@ -53,29 +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);
});
// When off, read from spec; otherwise the spec name is a placeholder ('provisioned'),
// so read the real allocated device names from the deviceAllocationDetails annotation.
const hostDeviceNames = this.vm.isOff ? [
...vmDevices.map(({ name }) => name),
] : [
...Object.values(this.vm?.provisionedHostDevices || {}).reduce((acc, devices) => [...acc, ...devices], []),
];
vmDevices.forEach(({ name, deviceName }) => {
const checkName = (deviceName || '').split('/')?.[1];
uniq(hostDeviceNames).forEach((name) => {
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
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() {
@@ -86,6 +87,7 @@ export default {
selectedDevices: [],
pciDeviceSchema: this.$store.getters['harvester/schemaFor'](HCI.PCI_DEVICE),
showMatrix: false,
oldFormatDevices: [],
};
},
@@ -197,6 +199,11 @@ export default {
});
},
oldFormatDevicesHTML() {
return this.oldFormatDevices.map((device) => {
return `<li>${ device }</li>`;
}).join('');
},
},
methods: {
@@ -220,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>
@@ -1,321 +0,0 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import { _VIEW } from '@shell/config/query-params';
import { DISK_CACHE_MODE, DISK_IO_MODE, DISK_PERFORMANCE_PROFILE } from '../../../config/harvester-map';
const { DEFAULT, HIGH, CUSTOM } = DISK_PERFORMANCE_PROFILE;
export default {
name: 'DiskPerformanceOptions',
components: {
LabeledSelect, Checkbox, Banner
},
emits: ['update'],
props: {
// The disk row. This component reads/writes value.cache, value.io,
// value.dedicatedIOThread and (for the "High Performance" preset) value.bus.
value: {
type: Object,
default: () => ({})
},
mode: {
type: String,
default: 'create'
},
},
data() {
const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread);
return {
DISK_PERFORMANCE_PROFILE,
expanded: hasPerf,
profile: this.detectProfile(),
// Bus the disk used before the "High Performance" preset switched it to
// virtio, so returning to "Default" can put it back.
busBeforeHighProfile: null,
};
},
computed: {
isView() {
return this.mode === _VIEW;
},
storagePerformanceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage');
},
isCdRom() {
return this.value.type === 'cd-rom';
},
isCustom() {
return this.profile === CUSTOM;
},
isNativeIo() {
return this.value.io === 'native';
},
profileOptions() {
return [DEFAULT, HIGH, CUSTOM].map((value) => ({
label: this.t(`harvester.virtualMachine.volume.performance.profile.${ value }`),
value,
}));
},
cacheOptions() {
return DISK_CACHE_MODE.map((value) => ({
label: this.cacheLabel(value),
value,
// Native AIO only works with an uncached (O_DIRECT) disk, so while it is
// selected the only valid cache option is "none" — disable everything
// else, including "Default", to prevent an invalid combination.
disabled: this.isNativeIo && value !== 'none',
}));
},
ioOptions() {
return DISK_IO_MODE.map((value) => ({
label: value === '' ? this.t('harvester.virtualMachine.volume.performance.ioMode.default') : this.t(`harvester.virtualMachine.volume.performance.ioMode.${ value }`),
value,
}));
},
showFilesystemCacheWarning() {
return this.value.cache === 'none' && this.value.volumeMode === 'Filesystem';
},
showBusTip() {
const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread);
return hasPerf && this.value.bus && this.value.bus !== 'virtio';
},
},
watch: {
// Keep the profile selector in sync if the row is repopulated (e.g. editing an existing VM).
'value.cache'() {
this.profile = this.detectProfile();
},
'value.io'() {
this.profile = this.detectProfile();
},
'value.dedicatedIOThread'() {
this.profile = this.detectProfile();
},
},
methods: {
detectProfile() {
const { cache, io, dedicatedIOThread } = this.value;
if (!cache && !io && !dedicatedIOThread) {
return DEFAULT;
}
if (cache === 'none' && io === 'native' && dedicatedIOThread) {
return HIGH;
}
return CUSTOM;
},
cacheLabel(value) {
if (value === '') {
return this.t('harvester.virtualMachine.volume.performance.cacheMode.default');
}
return this.t(`harvester.virtualMachine.volume.performance.cacheMode.${ value }`);
},
onProfileChange(profile) {
const previous = this.profile;
this.profile = profile;
if (profile === DEFAULT) {
this.value.cache = '';
this.value.io = '';
this.value.dedicatedIOThread = false;
// "Default" means no overrides, so also undo the virtio bus that the
// "High Performance" preset applied. Skip it if the user has since
// picked a different bus themselves — that choice is theirs to keep.
if (this.busBeforeHighProfile !== null) {
if (this.value.bus === 'virtio') {
this.value.bus = this.busBeforeHighProfile;
}
this.busBeforeHighProfile = null;
}
} else if (profile === HIGH) {
this.value.cache = 'none';
this.value.io = 'native';
this.value.dedicatedIOThread = true;
if (previous !== HIGH) {
this.busBeforeHighProfile = this.value.bus || '';
}
this.value.bus = 'virtio';
}
// CUSTOM keeps whatever is currently set, including the bus.
this.update();
},
onIoChange(io) {
this.value.io = io;
// Native AIO requires an uncached disk; force cache to "none" to keep the
// combo valid — including when cache is still on its "Default" ('') value,
// which libvirt would otherwise reject at domain start.
if (io === 'native' && this.value.cache !== 'none') {
this.value.cache = 'none';
}
this.update();
},
update() {
this.$emit('update');
},
},
};
</script>
<template>
<div
v-if="!isCdRom && storagePerformanceEnabled"
class="disk-performance"
>
<button
v-if="!isView"
type="button"
class="btn btn-sm role-link expand-toggle"
:aria-expanded="expanded"
:aria-label="expanded ? t('harvester.virtualMachine.volume.performance.collapse') : t('harvester.virtualMachine.volume.performance.expand')"
@click.prevent="expanded = !expanded"
>
<i
class="icon"
:class="expanded ? 'icon-chevron-down' : 'icon-chevron-right'"
/>
{{ t('harvester.virtualMachine.volume.performance.title') }}
</button>
<div
v-if="expanded || isView"
class="perf-body mt-10"
>
<div class="row mb-20">
<div
class="col span-6"
data-testid="input-disk-perf-profile"
>
<LabeledSelect
:value="profile"
:label="t('harvester.virtualMachine.volume.performance.profile.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.profile.tip')"
:options="profileOptions"
:mode="mode"
@update:value="onProfileChange"
/>
</div>
</div>
<Banner
v-if="profile === DISK_PERFORMANCE_PROFILE.HIGH"
color="info"
:label="t('harvester.virtualMachine.volume.performance.highProfileTip')"
/>
<div
v-if="isCustom || isView"
class="row mb-20"
>
<div
class="col span-6"
data-testid="input-disk-cache"
>
<LabeledSelect
v-model:value="value.cache"
:label="t('harvester.virtualMachine.volume.performance.cacheMode.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.cacheMode.tip')"
:options="cacheOptions"
:mode="mode"
@update:value="update"
/>
</div>
<div
class="col span-6"
data-testid="input-disk-io"
>
<LabeledSelect
:value="value.io"
:label="t('harvester.virtualMachine.volume.performance.ioMode.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.ioMode.tip')"
:options="ioOptions"
:mode="mode"
@update:value="onIoChange"
/>
</div>
</div>
<div
v-if="isCustom || isView"
class="row mb-10"
>
<div
class="col span-12"
data-testid="input-disk-dedicated-iothread"
>
<Checkbox
v-model:value="value.dedicatedIOThread"
:label="t('harvester.virtualMachine.volume.performance.dedicatedIOThread.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.dedicatedIOThread.tip')"
:mode="mode"
@update:value="update"
/>
</div>
</div>
<Banner
v-if="isNativeIo"
color="info"
:label="t('harvester.virtualMachine.volume.performance.ioMode.nativeRequiresNoCacheTip')"
/>
<Banner
v-if="showFilesystemCacheWarning"
color="warning"
:label="t('harvester.virtualMachine.volume.performance.cacheMode.filesystemWarning')"
/>
<Banner
v-if="showBusTip"
color="warning"
:label="t('harvester.virtualMachine.volume.performance.busTip')"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.disk-performance {
border-top: 1px solid var(--border);
margin-top: 10px;
padding-top: 10px;
.expand-toggle {
padding: 0;
font-weight: 600;
.icon {
margin-right: 4px;
}
}
}
</style>
@@ -1,191 +0,0 @@
<script>
import InfoBox from '@shell/components/InfoBox';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import { _VIEW } from '@shell/config/query-params';
import { IO_THREADS_POLICY } from '../../../config/harvester-map';
export default {
name: 'VMPerformanceOptions',
components: {
InfoBox, LabeledSelect, LabeledInput, Checkbox, Banner
},
emits: ['update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'],
props: {
blockMultiQueue: {
type: Boolean,
default: false
},
ioThreadsPolicy: {
type: String,
default: ''
},
ioThreadCount: {
type: Number,
default: 2
},
mode: {
type: String,
default: 'create'
},
// Whether the current disk set contains a virtio disk (VM context only).
hasVirtioDisk: {
type: Boolean,
default: true
},
// Whether any disk requests a dedicated I/O thread (drives the auto-enable hint).
hasDedicatedIothread: {
type: Boolean,
default: false
},
},
data() {
// These are specialist tuning options, so keep the panel collapsed unless the
// VM already has something configured.
return { expanded: !!(this.blockMultiQueue || this.ioThreadsPolicy) };
},
computed: {
isView() {
return this.mode === _VIEW;
},
storagePerformanceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage');
},
ioThreadsPolicyOptions() {
return IO_THREADS_POLICY.map((value) => ({
label: value === '' ? this.t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.default') : this.t(`harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.${ value }`),
value,
}));
},
},
watch: {
// Block Multi-Queue only applies to virtio disks. If the last virtio disk is
// removed while it is enabled, clear it so the VM is not left with an invalid
// setting the disabled checkbox can no longer surface.
hasVirtioDisk(neu) {
if (!neu && this.blockMultiQueue) {
this.$emit('update:blockMultiQueue', false);
}
},
},
};
</script>
<template>
<InfoBox
v-if="storagePerformanceEnabled && (!isView || blockMultiQueue || !!ioThreadsPolicy)"
class="vm-perf mt-10"
>
<button
v-if="!isView"
type="button"
class="btn btn-sm role-link expand-toggle"
:aria-expanded="expanded"
:aria-label="expanded ? t('harvester.virtualMachine.volume.diskPerformance.collapse') : t('harvester.virtualMachine.volume.diskPerformance.expand')"
@click.prevent="expanded = !expanded"
>
<i
class="icon"
:class="expanded ? 'icon-chevron-down' : 'icon-chevron-right'"
/>
{{ t('harvester.virtualMachine.volume.diskPerformance.title') }}
</button>
<h3
v-else
class="mb-5"
>
{{ t('harvester.virtualMachine.volume.diskPerformance.title') }}
</h3>
<template v-if="expanded || isView">
<p class="text-muted mb-15 mt-10">
{{ t('harvester.virtualMachine.volume.diskPerformance.description') }}
</p>
<div class="row mb-10">
<div
class="col span-12"
data-testid="input-vm-block-multi-queue"
>
<Checkbox
:value="blockMultiQueue"
:label="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.tip')"
:mode="mode"
:disabled="!hasVirtioDisk"
@update:value="$emit('update:blockMultiQueue', $event)"
/>
</div>
</div>
<Banner
v-if="!hasVirtioDisk && !isView"
color="info"
:label="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.noVirtioTip')"
/>
<div class="row mb-10">
<div
class="col span-6"
data-testid="input-vm-iothreads-policy"
>
<LabeledSelect
:value="ioThreadsPolicy"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.tip')"
:options="ioThreadsPolicyOptions"
:mode="mode"
@update:value="$emit('update:ioThreadsPolicy', $event)"
/>
</div>
<div
v-if="ioThreadsPolicy === 'supplementalPool'"
class="col span-6"
data-testid="input-vm-iothread-count"
>
<LabeledInput
type="number"
min="1"
:value="ioThreadCount"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadCount.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.ioThreadCount.tip')"
:mode="mode"
@update:value="$emit('update:ioThreadCount', Number($event))"
/>
</div>
</div>
<Banner
v-if="hasDedicatedIothread && !ioThreadsPolicy"
color="info"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.autoEnabledTip')"
/>
</template>
</InfoBox>
</template>
<style lang="scss" scoped>
.vm-perf {
.expand-toggle {
padding: 0;
font-weight: 600;
.icon {
margin-right: 4px;
}
}
}
</style>
@@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import DiskPerformanceOptions from '../DiskPerformanceOptions.vue';
const mountOptions = (value, { featureEnabled = true } = {}) => ({
props: { value, mode: _EDIT },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => featureEnabled,
'i18n/t': (key) => key,
'i18n/exists': () => true,
}
},
t: (key) => key,
},
}
});
const hardDisk = (overrides = {}) => ({
type: 'disk', bus: 'virtio', cache: '', io: '', dedicatedIOThread: false, ...overrides
});
describe('component: DiskPerformanceOptions', () => {
it('is hidden when the highPerformanceStorage feature is disabled', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk(), { featureEnabled: false }));
expect(wrapper.find('.disk-performance').exists()).toBe(false);
});
it('renders for a hard disk when the feature is enabled', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk()));
expect(wrapper.find('.disk-performance').exists()).toBe(true);
});
it('applies the High Performance preset and emits update', () => {
const value = hardDisk();
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
expect(value.cache).toBe('none');
expect(value.io).toBe('native');
expect(value.dedicatedIOThread).toBe(true);
expect(value.bus).toBe('virtio');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('clears all overrides when switching back to Default', () => {
const value = hardDisk({
cache: 'none', io: 'native', dedicatedIOThread: true
});
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('default');
expect(value.cache).toBe('');
expect(value.io).toBe('');
expect(value.dedicatedIOThread).toBe(false);
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('restores the original bus when leaving High Performance for Default', () => {
const value = hardDisk({ bus: 'sata' });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
expect(value.bus).toBe('virtio');
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('sata');
});
it('keeps a bus the user picked themselves after High Performance', () => {
const value = hardDisk({ bus: 'sata' });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
value.bus = 'scsi';
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('scsi');
});
it('leaves the bus alone for a disk that never used the High Performance preset', () => {
const value = hardDisk({
bus: 'sata', cache: 'writeback', io: 'threads'
});
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('sata');
});
it.each([
['a cached state', 'writeback'],
['the Default cache', ''],
])('forces cache to none when Native I/O is selected from %s', (_label, cache) => {
const value = hardDisk({ cache });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onIoChange('native');
expect(value.io).toBe('native');
expect(value.cache).toBe('none');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('disables every cache option except none while Native I/O is active', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ io: 'native' })));
const disabledByValue = wrapper.vm.cacheOptions.reduce((acc, opt) => {
acc[opt.value] = opt.disabled;
return acc;
}, {});
expect(disabledByValue['none']).toBe(false);
expect(disabledByValue['']).toBe(true);
expect(disabledByValue['writeback']).toBe(true);
expect(disabledByValue['writethrough']).toBe(true);
});
});
@@ -7,27 +7,24 @@ import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ModalWithCard from '@shell/components/ModalWithCard';
import VMPerformanceOptions from './VMPerformanceOptions';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PVC, STORAGE_CLASS } from '@shell/config/types';
import { clone } from '@shell/utils/object';
import { ucFirst, randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array';
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm';
const { READ_WRITE_MANY } = ACCESS_MODE;
export default {
emits: ['update:value', 'update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'],
emits: ['update:value'],
components: {
Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard, VMPerformanceOptions
Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard
},
props: {
@@ -72,28 +69,12 @@ export default {
customAccessMode: {
type: String,
default: READ_WRITE_MANY
default: 'ReadWriteMany'
},
resourceType: {
type: String,
default: ''
},
// KubeVirt high-performance VM-level features
blockMultiQueue: {
type: Boolean,
default: false
},
ioThreadsPolicy: {
type: String,
default: ''
},
ioThreadCount: {
type: Number,
default: 2
}
},
@@ -164,14 +145,6 @@ export default {
isLHV2VolExpansionFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
},
hasVirtioDisk() {
return this.rows.some((R) => R.type === 'disk' && R.bus === 'virtio');
},
hasDedicatedIOThread() {
return this.rows.some((R) => R.dedicatedIOThread);
},
},
watch: {
@@ -502,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') }}
@@ -533,18 +507,6 @@ export default {
</button>
</div>
<VMPerformanceOptions
:block-multi-queue="blockMultiQueue"
:io-threads-policy="ioThreadsPolicy"
:io-thread-count="ioThreadCount"
:has-virtio-disk="hasVirtioDisk"
:has-dedicated-iothread="hasDedicatedIOThread"
:mode="mode"
@update:block-multi-queue="$emit('update:blockMultiQueue', $event)"
@update:io-threads-policy="$emit('update:ioThreadsPolicy', $event)"
@update:io-thread-count="$emit('update:ioThreadCount', $event)"
/>
<ModalWithCard
v-if="isOpen"
name="deleteTip"
@@ -4,7 +4,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { Banner } from '@components/Banner';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default {
name: 'HarvesterEditContainer',
@@ -12,7 +11,7 @@ export default {
emits: ['update'],
components: {
LabeledInput, LabeledSelect, InputOrDisplay, Banner, DiskPerformanceOptions
LabeledInput, LabeledSelect, InputOrDisplay, Banner
},
props: {
@@ -154,11 +153,5 @@ export default {
class="mb-20"
:label="value.volumeBackups.error.message"
/>
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div>
</template>
@@ -5,16 +5,13 @@ import LabelValue from '@shell/components/LabelValue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay';
import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import { sortBy } from '@shell/utils/sort';
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
import { PVC } from '@shell/config/types';
import { _CREATE } from '@shell/config/query-params';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI } from '../../../../types';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { GIBIBYTE } from '../../../../utils/unit';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default {
name: 'HarvesterEditExisting',
@@ -22,7 +19,7 @@ export default {
emits: ['update'],
components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox, DiskPerformanceOptions
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
},
props: {
@@ -97,30 +94,6 @@ export default {
return this.allPVCs.find( (P) => P.metadata.name === this.value.volumeName );
},
storageClasses() {
return this.$store.getters['harvester/all'](STORAGE_CLASS) || [];
},
isShareableCapable() {
const pvcSpec = this.pvcResource?.spec;
if (!pvcSpec) {
return false;
}
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
const storageClass = this.storageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
// fail closed: without a resolved StorageClass the provisioner
// requirement cannot be evaluated
if (!storageClass) {
return false;
}
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
},
volumeOption() {
return sortBy(
this.allPVCs
@@ -172,15 +145,6 @@ export default {
this.value.size = pvcResource.spec.resources.requests.storage;
this.value.storageClassName = pvcResource.spec.storageClassName;
this.value.volumeMode = pvcResource.spec.volumeMode;
this.value.shareable = false;
// Adopt the volume's default high-performance profile (if any) as a starting point.
const ann = pvcResource.metadata?.annotations || {};
this.value.cache = ann[HCI_ANNOTATIONS.DISK_CACHE_MODE] || '';
this.value.io = ann[HCI_ANNOTATIONS.DISK_IO_MODE] || '';
this.value.dedicatedIOThread = ann[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] === 'true';
this.update();
},
@@ -191,13 +155,6 @@ export default {
}
},
isShareableCapable(neu) {
if (!neu && this.value.shareable) {
this.value.shareable = false;
this.update();
}
},
pvcResource: {
handler(pvc) {
if (!this.value.volumeName && pvc?.metadata?.name) {
@@ -346,26 +303,6 @@ export default {
/>
</InputOrDisplay>
</div>
<div
v-if="isShareableCapable"
data-testid="input-hee-shareable"
class="col span-6"
>
<Checkbox
v-model:value="value.shareable"
class="check"
type="checkbox"
label-key="harvester.virtualMachine.volume.shareable.label"
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
:mode="mode"
@update:value="update"
/>
<Banner
v-if="value.shareable"
color="warning"
:label="t('harvester.virtualMachine.volume.shareable.warning')"
/>
</div>
<div
v-if="value.volumeBackups"
class="col span-6"
@@ -388,11 +325,5 @@ export default {
class="mb-20"
:label="value.volumeBackups.error.message"
/>
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div>
</template>
@@ -15,7 +15,6 @@ import LabelValue from '@shell/components/LabelValue';
import { ucFirst } from '@shell/utils/string';
import { GIBIBYTE } from '../../../../utils/unit';
import { EMPTY_IMAGE } from '../../../../utils/vm';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default {
name: 'HarvesterEditVMImage',
@@ -23,7 +22,7 @@ export default {
emits: ['update'],
components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, DiskPerformanceOptions
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
},
props: {
@@ -483,11 +482,5 @@ export default {
color="error"
:label="t('harvester.virtualMachine.volume.vmImageVolumeTip', {diskSize: diskSize, imageVirtualSize: imageVirtualSize})"
/>
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div>
</template>
@@ -9,15 +9,12 @@ import { formatSi, parseSi } from '@shell/utils/units';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { _VIEW } from '@shell/config/query-params';
import LabelValue from '@shell/components/LabelValue';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
import { ucFirst } from '@shell/utils/string';
import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../../../../utils/unit';
import { isInternalStorageClass } from '../../../../utils/storage-class';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
import { VOLUME_MODE } from '@pkg/harvester/config/types';
export default {
name: 'HarvesterEditVolume',
@@ -25,7 +22,7 @@ export default {
emits: ['update'],
components: {
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue, DiskPerformanceOptions
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue
},
props: {
@@ -208,7 +205,7 @@ export default {
getAccessMode(storageClassName) {
if (!this.longhornV2LVMSupport) {
return READ_WRITE_MANY;
return 'ReadWriteMany';
}
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
@@ -219,7 +216,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
return readWriteOnce ? 'ReadWriteOnce' : 'ReadWriteMany';
},
update() {
@@ -363,11 +360,6 @@ export default {
</InputOrDisplay>
</div>
</div>
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
<div class="row mb-20">
<div
v-if="value.volumeEncryptionFeatureEnabled && isView"
@@ -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,39 +91,22 @@ export default {
const hostname = this.value.spec.template.spec.hostname || '';
// Display name can contain arbitrary strings. There is no XSS risk because the value is
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
// this field. See harvester/harvester#10423 for details.
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return {
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',
@@ -237,9 +218,6 @@ export default {
usbPassthroughEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough');
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@@ -311,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() {
@@ -329,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));
}
@@ -353,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;
@@ -378,7 +345,6 @@ export default {
clear(this.errors);
this.validateCPUMemory();
this.validateWindowsSysprep();
// block create VM flow if has validation errors
if (this.errors.length) {
@@ -406,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;
@@ -487,20 +435,6 @@ export default {
await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS);
} catch (e) {
// Reset the generated secret name(s) so a retried create (e.g. after correcting an
// invalid/duplicate VM name) always regenerates them from the current name instead of
// reusing names derived from this failed attempt. Only do this for create: in edit mode
// secretName/sysprep.secretName may reference an existing secret loaded from the VM,
// which must not be discarded. See harvester/harvester#11174.
if (this.isCreate) {
this.secretName = '';
this.secretNamePrefixUsed = '';
if (this.sysprepSecretNamePrefixUsed !== '') {
this.sysprep.secretName = '';
this.sysprepSecretNamePrefixUsed = '';
}
}
this.errors.push(...exceptionToErrorsArray(e));
buttonCb(false);
}
@@ -588,7 +522,6 @@ export default {
onTabChanged({ tab }) {
if (tab.name === 'advanced') {
this.$refs.yamlEditor?.refresh();
this.$refs.sysprepConfig?.refresh();
}
},
@@ -677,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"
@@ -772,9 +678,6 @@ export default {
>
<Volume
v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode"
:custom-volume-mode="customVolumeMode"
:namespace="value.metadata.namespace"
@@ -880,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" />
@@ -915,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" />
@@ -936,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" />
@@ -957,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">
@@ -1069,6 +959,7 @@ export default {
:user-script="userScript"
:mode="mode"
:os-type="osType"
:view-code="isWindows"
:namespace="value.metadata.namespace"
:network-script="networkScript"
@updateUserData="updateUserData"
@@ -1076,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"
@@ -63,7 +63,6 @@ export default {
RANCHER_TYPES,
fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }],
canEditPSPBindings: true,
isQuotasValid: true,
};
},
computed: {
@@ -191,13 +190,15 @@ export default {
this['membershipUpdate'] = update;
},
validateQuotas(isValid) {
this.isQuotasValid = isValid;
},
onQuotasInput({ projectLimit, nsLimit } = {}) {
this.value.spec.resourceQuota = { ...this.value.spec.resourceQuota, limit: projectLimit };
this.value.spec.namespaceDefaultResourceQuota = { ...this.value.spec.namespaceDefaultResourceQuota, limit: nsLimit };
removeQuota(key) {
['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => {
if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) {
delete this.value?.spec[specProp]?.limit[key];
}
if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) {
delete this.value?.spec[specProp]?.usedLimit[key];
}
});
}
},
};
@@ -211,7 +212,7 @@ export default {
:resource="value"
:subtypes="[]"
:can-yaml="false"
:validation-passed="fvFormIsValid && isQuotasValid"
:validation-passed="fvFormIsValid"
@error="e=>errors=e"
@finish="save"
@cancel="done"
@@ -266,11 +267,10 @@ export default {
:weight="9"
>
<ResourceQuota
:value="value"
v-model:value="value"
:mode="canEditTabElements"
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
@input="onQuotasInput"
@validationChanged="validateQuotas"
@remove="removeQuota"
/>
</Tab>
<Tab
@@ -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>
+33 -68
View File
@@ -1,11 +1,11 @@
<script>
import PercentageBar from '@shell/components/PercentageBar';
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { METRIC, NODE } from '@shell/config/types';
import { parseSi } from '@shell/utils/units';
export default {
name: 'HarvesterCpuUsed',
components: { PercentageBar },
components: { ConsumptionGauge },
props: {
value: {
@@ -74,89 +74,54 @@ export default {
return 0;
}
},
reservedPercentage() {
return this.cpuTotal ? (this.reserved * 100) / this.cpuTotal : 0;
},
usedPercentage() {
return this.cpuTotal ? (this.used * 100) / this.cpuTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.numberFormatter(this.reserved || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.numberFormatter(this.used || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
},
methods: {
numberFormatter(value) {
return Number.isInteger(value) ? value : value.toFixed(2);
},
},
}
};
</script>
<template>
<div>
<div class="consumption-gauge">
<div class="numbers">
<span class="mr-5">
<ConsumptionGauge
:capacity="cpuTotal"
:used="reserved"
:units="cpuUnits"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
</template>
</ConsumptionGauge>
<div
v-if="showUsed"
class="consumption-gauge mt-10"
class="mt-10"
>
<div class="numbers">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
<ConsumptionGauge
:capacity="cpuTotal"
:used="used"
:units="cpuUnits"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div>
</div>
</template>
<style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data {
white-space: nowrap;
}
@@ -1,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 }"`);
@@ -132,28 +110,16 @@ export default {
</script>
<template>
<div
v-if="showIP"
class="ip-list"
>
<div
<div v-if="showIP">
<span
v-for="{ ip, name, isCustom } in ips"
:key="`${ip}-${name}`"
class="ip-item"
>
<CopyToClipboardText
v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name"
:text="ip"
:plain="isCustom"
/>
</div>
</span>
</div>
</template>
<style lang="scss" scoped>
.ip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
@@ -1,11 +1,11 @@
<script>
import PercentageBar from '@shell/components/PercentageBar';
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { METRIC, NODE } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit';
export default {
name: 'HarvesterMemoryUsed',
components: { PercentageBar },
components: { ConsumptionGauge },
props: {
value: {
@@ -76,30 +76,6 @@ export default {
return 0;
}
},
reservedPercentage() {
return this.memoryTotal ? (this.reserved * 100) / this.memoryTotal : 0;
},
usedPercentage() {
return this.memoryTotal ? (this.used * 100) / this.memoryTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.reserved || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.used || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
},
methods: {
@@ -120,53 +96,50 @@ export default {
<template>
<div>
<div class="consumption-gauge">
<div class="numbers">
<span class="mr-10">
<ConsumptionGauge
:capacity="memoryTotal"
:used="reserved"
:units="memoryUnits"
:number-formatter="memoryFormatter"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
</template>
</ConsumptionGauge>
<div
v-if="showUsed"
class="consumption-gauge mt-10"
class="mt-10"
>
<div class="numbers">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
<ConsumptionGauge
:capacity="memoryTotal"
:used="used"
:units="memoryUnits"
:number-formatter="memoryFormatter"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div>
</div>
</template>
<style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data {
white-space: nowrap;
}
@@ -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,12 +1,12 @@
<script>
import PercentageBar from '@shell/components/PercentageBar';
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { LONGHORN } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit';
export default {
name: 'HarvesterStorageUsed',
components: { PercentageBar },
components: { ConsumptionGauge },
props: {
value: {
@@ -120,14 +120,6 @@ export default {
unit: this.allocatedUnits,
};
},
usedPercentage() {
return this.storageStats.maximum ? (this.storageStats.used * 100) / this.storageStats.maximum : 0;
},
allocatedPercentage() {
return this.storageStats.total ? (this.storageStats.scheduled * 100) / this.storageStats.total : 0;
},
},
methods: {
@@ -152,54 +144,53 @@ export default {
<div>
<div
v-if="showAllocated"
class="consumption-gauge"
>
<div class="numbers">
<span class="mr-10">
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="allocatedPercentage"
show-percentage
/>
</div>
<ConsumptionGauge
:capacity="storageStats.total"
:used="storageStats.scheduled"
:units="allocatedUnits"
:number-formatter="formatter"
:resource-name="resourceName"
>
<template #title="{formattedPercentage}">
<span>
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div>
<div
class="consumption-gauge"
:class="{ 'mt-10': showAllocated }"
<ConsumptionGauge
:capacity="storageStats.maximum"
:used="storageStats.used"
:units="usedUnits"
:number-formatter="formatter"
:resource-name="showAllocated ? '' : resourceName"
:class="{
'mt-10': showAllocated,
}"
>
<div class="numbers">
<template #title="{formattedPercentage}">
<span>
{{ t('node.detail.glance.consumptionGauge.used') }}
</span>
<span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span>
</div>
<div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div>
</template>
</ConsumptionGauge>
</div>
</template>
<style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data {
white-space: nowrap;
}

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