Compare commits

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

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

* ci: simplify CI_BRANCH expression comment

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

---------

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

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

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



* refactor: copilot review



---------


(cherry picked from commit a6bd7ec00e)

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


(cherry picked from commit 790cade04d)

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



* refactor: customize empty table rows message



* refactor: fixed the table column



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



* style: remove unneeded overflow css



---------


(cherry picked from commit 2903e86c02)

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



* refactor: wordings



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

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

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



---------


(cherry picked from commit 2d747e435a)

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


(cherry picked from commit a178302f73)

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

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

Related issue: harvester/harvester#10417


(cherry picked from commit 7a84e08f99)

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



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

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



---------


(cherry picked from commit 3d39626b93)

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

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



* refactor: add noopener noreferrer rel



---------


(cherry picked from commit 1c3b40b631)

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


(cherry picked from commit 87baf75301)

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

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



* refactor: modify duration column width



* refactor: copilot review comment



---------


(cherry picked from commit a10d651827)

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

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

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

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

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

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

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

Fixes harvester/harvester#11174




* refactor: copilot review



* refactor: remove console



---------



(cherry picked from commit 429d9e18ad)

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



* refactor: review comment



---------


(cherry picked from commit dc166d8fc9)

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



* refactor: not pre-select target namespace



* refactor: ignore option



* refactor: copilot review



---------


(cherry picked from commit 3ff4534294)

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



* refactor: copilot review



---------


(cherry picked from commit d4223ad2ae)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-16 11:46:21 +08:00
mergify[bot]andAndy Lee 07f2b02a9d feat: add migration plan duration time column (#1005) (#1006)
* feat: add migration plan duration time column



* feat: allow duration column to be sorted



---------


(cherry picked from commit 44297ada16)

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

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


(cherry picked from commit 2cefd7cb78)

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



* refactor: copilot review



* refactor: copilot review



---------


(cherry picked from commit fb6ffa3c0f)

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



* refactor: copilot review



---------


(cherry picked from commit e0436bff28)

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

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



* fix: rename advanced to configurations



* fix: fixed column width



* refactor: review comment



* revert: power on background color



---------


(cherry picked from commit 41cf44d231)

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

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



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



* feat(forklift): UI based on the FIGMA



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



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



* feat(forklift): Add the succeded state



* Merge the new APIs



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



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



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



* feat(forklift): Added pagination UI



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



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



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



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



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



* feat(forklift): Remove test flags



* feat(forklift): Changed based on review



* feat(forklift): fixed title and button



* feat(forklift): Design changes



* feat(forklift): Changed to Wizard



* feat(forklift): added the provider wizard



* feat(forklift): Fixed some UI changes



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



* feat(forklift): update size



* feat(forklift): small fixes



* feat(forklift): small fixes after Copilot



* feat(forklift): small fixes after Copilot



* feat(forklift): reverted settings.json changes



* feat(forklift): small fixes after Copilot



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



* feat(forklift): Fixed the type for VMware



* feat(forklift): small changes before rebase



* feat: Added new API for the vms



* feat: Fix some labels



* feat: Added some final fixes to structure



* feat: added loading to provider steps



* feat: added comments from copilot review



---------


(cherry picked from commit 560643d299)

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

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

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

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



* feat: Allow user to customize snapshot/backup deadline

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



---------


(cherry picked from commit d98db6ba49)

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



* feat: add feature flag



---------


(cherry picked from commit 531ed03796)

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

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-08 13:50:17 +08:00
58 changed files with 1900 additions and 3731 deletions
+3 -7
View File
@@ -8,16 +8,12 @@
- [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
### Related Issue
### 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
### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison -->
### Test screenshot or video (Required)
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
@@ -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
@@ -29,36 +28,8 @@ jobs:
packages: write
id-token: write
with:
<<<<<<< HEAD
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"
=======
CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
>>>>>>> 719c33f (ci: fix CI_BRANCH ternary so PR builds skip upload gate (#1062))
+1 -1
View File
@@ -36,7 +36,7 @@ module.exports = {
'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, '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: [
+9 -17
View File
@@ -1,13 +1,13 @@
{
"name": "harvester-ui-extension",
"version": "1.10.0-dev",
"version": "1.9.0-rc3",
"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.3",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
@@ -15,19 +15,13 @@
"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",
"yaml": "^2.5.1"
},
"resolutions": {
"@types/node": "25.9.6",
"@types/node": "25.9.5",
"cronstrue": "2.59.0",
"d3-color": "3.1.0",
"ejs": "3.1.10",
@@ -35,12 +29,12 @@
"glob": "7.2.3",
"glob-parent": "6.0.2",
"json5": "2.2.3",
"@types/lodash": "4.17.25",
"@types/lodash": "4.17.24",
"merge": "2.1.1",
"node-forge": "1.4.0",
"nth-check": "2.1.1",
"qs": "6.16.0",
"roarr": "7.21.7",
"qs": "6.15.3",
"roarr": "7.21.6",
"semver": "7.8.5",
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
},
@@ -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>
+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;
+1 -5
View File
@@ -55,10 +55,6 @@ 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 }`;
@@ -67,7 +63,7 @@ export default {
window.open(
url,
'_blank',
`toolbars=0,width=${ popupWidth },height=${ popupHeight },left=${ popupLeft },top=${ popupTop },noreferrer`
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
});
},
@@ -7,15 +7,10 @@ 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: '' },
@@ -40,10 +35,6 @@ 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;
@@ -114,7 +105,7 @@ const applyNetworkMapTargets = (mapSpec) => {
});
};
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
const applyStorageMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
@@ -126,30 +117,6 @@ const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInheri
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;
}
}
});
};
@@ -167,39 +134,12 @@ const applyDefaultStorageMap = () => {
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
applyStorageMapTargets(defaultMap?.spec?.map);
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
// 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;
@@ -262,19 +202,13 @@ const buildStorageEntries = () => {
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 }`,
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
@@ -304,19 +238,13 @@ const buildNetworkEntriesFromProvider = (networksData) => {
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 }`,
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
@@ -533,7 +461,7 @@ const init = async() => {
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
applyStorageMapTargets(props.existingStorageMap.spec.map);
} else {
applyDefaultStorageMap();
}
@@ -583,9 +511,6 @@ init();
: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
@@ -595,19 +520,6 @@ init();
</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>
@@ -3,27 +3,20 @@ 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: '' },
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
});
const 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) => {
@@ -45,13 +38,6 @@ const optionsFor = (entry) => {
...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>
@@ -101,29 +87,6 @@ const formatModes = (entry) => {
/>
</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>
@@ -210,41 +173,6 @@ const formatModes = (entry) => {
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;
@@ -273,6 +273,19 @@ const startMigrationAction = async() => {
await plan.save();
const planOwnerRef = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
networkMap.metadata.ownerReferences = [planOwnerRef];
await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
@@ -25,9 +25,8 @@ 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 networkMap = ref({});
const datastoreMap = ref({});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const errors = ref([]);
@@ -95,7 +94,7 @@ const showSelectAllBanner = computed(() => {
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = ref([
const headers = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
@@ -140,7 +139,7 @@ const headers = ref([
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
]);
];
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
@@ -191,7 +190,6 @@ const buildTableRows = () => {
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 || '-',
@@ -241,10 +239,6 @@ const clearSelection = () => {
if (table) {
table.clearSelection();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
}
});
};
@@ -255,49 +249,6 @@ const selectAllVMs = () => {
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,
() => {
@@ -367,9 +318,6 @@ const fetchVMs = async() => {
return map;
}, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
@@ -426,10 +374,6 @@ const init = async() => {
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;
}
@@ -497,6 +441,7 @@ init();
:row-actions="false"
:groupable="false"
:paging="true"
:rows-per-page="20"
key-field="_key"
@selection="onSelect"
>
@@ -505,20 +450,7 @@ init();
<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>
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
</div>
</template>
<template #cell:vmName="{ row }">
@@ -534,7 +466,7 @@ init();
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:network="{ row }">
@@ -601,23 +533,6 @@ init();
.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 {
@@ -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>
-4
View File
@@ -77,10 +77,6 @@ const FEATURE_FLAGS = {
'staticIPForVM',
'fsFreezeDeadline',
],
'v1.9.1': [
'lvmDmThinOnly',
],
'v1.10.0': [],
};
const generateFeatureFlags = () => {
+9 -47
View File
@@ -57,8 +57,6 @@ import {
} 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';
@@ -111,51 +109,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({
+1 -5
View File
@@ -3,7 +3,6 @@ export const HCI_SETTING = {
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',
@@ -69,7 +68,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,17 +111,15 @@ 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' },
-7
View File
@@ -15,13 +15,6 @@ export const VOLUME_MODE = {
FILE_SYSTEM: 'Filesystem'
};
export const ACCESS_MODE = {
READ_WRITE_MANY: 'ReadWriteMany',
READ_WRITE_ONCE: 'ReadWriteOnce',
READ_ONLY_MANY: 'ReadOnlyMany',
READ_WRITE_ONCE_POD: 'ReadWriteOncePod',
};
export const NETWORK_PROTOCOL = {
IPv4: 'IPv4',
IPv6: 'IPv6',
@@ -390,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"
@@ -439,10 +436,3 @@ export default {
</Tabbed>
</div>
</template>
<style lang="scss" scoped>
.migration-metrics-scroll {
max-height: 640px;
overflow-y: auto;
}
</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"
-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,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)"
/>
@@ -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' },
];
},
@@ -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,
};
},
@@ -26,11 +26,9 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { isInternalStorageClass } from '../utils/storage-class';
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
export default {
name: 'HarvesterVolume',
@@ -85,7 +83,7 @@ export default {
if (this.mode === _CREATE) {
// default volumeMode to Block
this.value.spec.volumeMode = VOLUME_MODE.BLOCK;
this.value.spec.accessModes = [READ_WRITE_MANY];
this.value.spec.accessModes = ['ReadWriteMany'];
}
const storage = this.value?.spec?.resources?.requests?.storage || null;
@@ -162,7 +160,7 @@ export default {
},
accessModeOptions() {
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
},
imageOption() {
@@ -357,7 +355,7 @@ export default {
getAccessMode() {
if (!this.longhornV2LVMSupport) {
return [READ_WRITE_MANY];
return ['ReadWriteMany'];
}
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@@ -373,7 +371,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
},
buildDataVolumeObj() {
const storage = {
+3 -14
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';
@@ -142,12 +142,10 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
const options = vlans.map((vlan) => ({
return vlans.map((vlan) => ({
label: vlan.id,
value: vlan.id,
}));
return [{ label: this.t('generic.none'), value: '' }, ...options];
}
},
@@ -162,14 +160,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;
@@ -293,13 +283,12 @@ export default {
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.vlan ?? ''"
v-model:value="value.spec.vlan"
class="mb-20"
:options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')"
:mode="mode"
@update:value="onVlanChange"
/>
</div>
</div>
@@ -68,6 +68,12 @@ export default {
},
watch: {
osType(neu) {
if (neu === 'windows') {
this.id = '';
}
},
value(neu) {
this.yamlScript = neu;
},
@@ -246,27 +246,4 @@ export default {
.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';
@@ -61,15 +60,7 @@ export default {
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], []),
];
uniq(hostDeviceNames).forEach((name) => {
vmDevices.forEach(({ name }) => {
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
selectedDevices.push(name);
}
@@ -16,12 +16,10 @@ import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm';
const { READ_WRITE_MANY } = ACCESS_MODE;
export default {
emits: ['update:value'],
@@ -71,7 +69,7 @@ export default {
customAccessMode: {
type: String,
default: READ_WRITE_MANY
default: 'ReadWriteMany'
},
resourceType: {
@@ -5,12 +5,10 @@ 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';
@@ -21,7 +19,7 @@ export default {
emits: ['update'],
components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
},
props: {
@@ -96,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
@@ -171,7 +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;
this.update();
},
@@ -182,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) {
@@ -337,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"
@@ -14,9 +14,7 @@ import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storagec
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../../../../utils/unit';
import { isInternalStorageClass } from '../../../../utils/storage-class';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
import { VOLUME_MODE } from '@pkg/harvester/config/types';
export default {
name: 'HarvesterEditVolume',
@@ -207,7 +205,7 @@ export default {
getAccessMode(storageClassName) {
if (!this.longhornV2LVMSupport) {
return READ_WRITE_MANY;
return 'ReadWriteMany';
}
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
@@ -218,7 +216,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
return readWriteOnce ? 'ReadWriteOnce' : 'ReadWriteMany';
},
update() {
@@ -1062,6 +1062,7 @@ export default {
</div>
<CloudConfig
v-if="!isWindows"
ref="yamlEditor"
:user-script="userScript"
:mode="mode"
@@ -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
@@ -132,28 +132,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>
+4 -43
View File
@@ -84,8 +84,6 @@ dialog:
harvester:
branding:
primeLabel: '{vendor}'
primeEmptyLabel: ''
logos:
tip: 'Upload a logo to replace the Harvester logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.'
favicon:
@@ -800,10 +798,6 @@ harvester:
setFirst: Set as root volume
saveVolume: Update Volume
encryption: Encryption
shareable:
label: Shareable
tip: Allow multiple virtual machines to attach and write to this volume simultaneously.
warning: Multiple virtual machines can write to this volume at the same time. The guest workloads must coordinate access to the shared block device, otherwise concurrent writes will corrupt the data. Workloads that fence through SCSI-3 Persistent Reservations (e.g. Windows Server Failover Clustering) also require persistent reservation support from the storage backend. Host cache and hypervisor disk locking are disabled, and backup or snapshot operations are rejected while the volume is shareable.
vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
lockTooltip:
all: All volumes are encrypted.
@@ -837,13 +831,13 @@ harvester:
title: "Network Data:"
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
sysprep:
title: Windows Unattended & Sysprep Configuration
title: Windows Sysprep Configuration
description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
createNew: Create new...
createNewTitle: Create Windows Unattended & Sysprep Template
createNewTitle: Create Windows Sysprep Template
xmlContent: autounattend.xml content
secret:
label: Windows Unattended & Sysprep Template
label: Windows Sysprep Template
validation:
invalidXml: Invalid XML syntax
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
@@ -1834,8 +1828,6 @@ harvester:
'rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the Harvester cluster and route them to many kinds of servers based on flows.
'rancher-monitoring': rancher-monitoring is an add-on to collect Harvester cluster and virtual machine metrics, view them on the embedded dashboard, and send alert(s) to remote servers.
'rancher-vcluster': rancher-vcluster deploys a virtual cluster (vcluster) with Rancher installed.
'k3k': k3k is an add-on to deploy the k3k controller.
'rancher-k3k': rancher-k3k deploys a k3k virtual cluster with Rancher installed.
'harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations.
'harvester-system/harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations.
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
@@ -1930,7 +1922,6 @@ harvester:
refreshNow: Refresh now
availableVms: Available Virtual Machines
selected: selected
clearAll: Clear All
saveSelection: Save Selection and Continue
selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page."
@@ -1991,19 +1982,6 @@ harvester:
description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template
storageDefaults:
title: 'Storage defaults — {name}'
description: 'Set the volume mode and access modes applied to every datastore in this storage map that maps to {name}.'
descriptionInherited: 'Applies to every datastore in this storage map that maps to {name}. The provider default for {provider} stays unchanged.'
summary: 'Volume mode: {volumeMode} · Access mode: {accessModes}'
inherited: 'Inherited from provider {provider}'
volumeMode: Volume Mode
accessMode: Access Mode
edit: Edit
cancel: Cancel
apply: Apply
resetToProviderDefault: Reset to Provider Default
applyOverride: Apply Override
reviewMigration:
title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
@@ -2140,14 +2118,6 @@ harvester:
hostname: Hostname
rancherVersion: Rancher Version
password: Bootstrap Password
rancherK3k:
accessRancher: Access the Rancher Dashboard
hostname: Hostname
version: Rancher Version
password: Bootstrap Password
passwordTooltip: The initial password in Rancher for the first time login.
enable:
title: Enable K3k
switchFailed: "{action} {name} failed!"
nvidiaDriverToolkit:
titles:
@@ -2429,15 +2399,7 @@ typeDescription:
harvester: "Branding allows administrators to globally re-brand the UI by customizing the Harvester product name, logos and color scheme."
advancedSettings:
tags:
modified:
label: Modified
experimental:
label: Experimental
description: 'Experimental: The feature is incomplete but its essential functionality is operational. The feature is not recommended for use in production environments.'
preview:
label: Technical Preview
description: 'Technical Preview: The feature is almost complete and its functionality is not expected to change significantly. Explore the feature extensively before using in production environments.'
experimental: 'Experimental features allow users to test and evaluate early-access functionality prior to official supported releases'
descriptions:
'harv-vlan': Default Network Interface name of the VLAN network.
'harv-backup-target': Custom backup target to store virtual machine backups.
@@ -2447,7 +2409,6 @@ advancedSettings:
'harv-containerd-registry': Containerd Registry Configuration to connect private registries.
'harv-log-level': Configure Harvester server log level. Defaults to Info.
'harv-server-version': Harvester server version.
'harv-server-flavor': "Indicates the Harvester edition: Prime or Community."
'harv-upgrade-checker-enabled': Specifies whether to enable Harvester upgrade check or not. Default is True.
'harv-upgrade-checker-url': Default Harvester upgrade check url. Only used when the <code>upgrade-checker-enabled</code> is equal to True.
'harv-ui-source': Configure how to load the UI source. The `External` option is used to load the UI from a ui-index URL, while the `Bundled` option loads the UI from the local Harvester server.
+1 -1
View File
@@ -72,7 +72,7 @@ export default {
/>
<a
v-if="['rancher-vcluster', 'rancher-k3k'].includes(scope.row.metadata.name) && scope.row.spec.enabled"
v-if="scope.row.metadata.name === 'rancher-vcluster' && scope.row.spec.enabled"
v-tooltip="t('harvester.addons.rancherVcluster.accessRancher')"
class="ml-5"
rel="nofollow noopener noreferrer"
-12
View File
@@ -38,10 +38,6 @@ export const SSH_EXISTING_TYPE = {
export default {
methods: {
hasCloudConfigComment(userScript) {
if (typeof userScript === 'string' && /^\s*#cloud-config(\s|$)/.test(userScript)) {
return true;
}
// Check that userData contains: #cloud-config
const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({});
const items = userDataDoc?.contents?.items || [];
@@ -56,14 +52,6 @@ export default {
exist = true;
}
if (userDataDoc?.contents?.commentBefore === 'cloud-config' || userDataDoc?.contents?.commentBefore?.includes('cloud-config\n')) {
exist = true;
}
if (userDataDoc?.contents?.comment === 'cloud-config' || userDataDoc?.contents?.comment?.includes('cloud-config\n')) {
exist = true;
}
items.map((item) => {
const key = item.key;
+50 -111
View File
@@ -25,9 +25,7 @@ import { HCI } from '../../types';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE, ACCESS_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY } = ACCESS_MODE;
import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
@@ -278,7 +276,7 @@ export default {
},
customAccessMode() {
return this.storageClassSetting.accessModes || READ_WRITE_MANY;
return this.storageClassSetting.accessModes || 'ReadWriteMany';
},
isWindows() {
@@ -506,7 +504,7 @@ export default {
id: randomStr(5),
source: SOURCE_TYPE.IMAGE,
name: 'disk-0',
accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX
accessMode: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX
bus,
volumeName: '',
size,
@@ -572,7 +570,7 @@ export default {
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
source = SOURCE_TYPE.ATTACH_VOLUME;
accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY;
accessMode = pvcResource?.spec?.accessModes?.[0] || 'ReadWriteMany';
size = pvcResource?.spec?.resources?.requests?.storage || '10Gi';
storageClassName = pvcResource?.spec?.storageClassName;
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
@@ -618,7 +616,6 @@ export default {
type,
storageClassName,
hotpluggable,
shareable: DISK.shareable || false,
volumeStatus,
dataSource,
namespace,
@@ -792,41 +789,18 @@ export default {
}
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const annotations = vm.metadata.annotations;
const desired = {};
Object.keys(vm.metadata.annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
delete vm.metadata.annotations[key];
}
});
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
desired[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
vm.metadata.annotations[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
}
});
const current = {};
Object.keys(annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
current[key] = annotations[key];
}
});
// Skip mutation when already in sync to avoid triggering a reactive update loop.
const desiredKeys = Object.keys(desired);
const currentKeys = Object.keys(current);
const isSame = desiredKeys.length === currentKeys.length &&
desiredKeys.every((key) => current[key] === desired[key]);
if (isSame) {
return;
}
currentKeys.forEach((key) => {
delete annotations[key];
});
Object.entries(desired).forEach(([key, value]) => {
annotations[key] = value;
});
},
setCPUAndMemory() {
@@ -941,43 +915,37 @@ export default {
}
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
disks.push({
name: 'cloudinitdisk',
disk: { bus: 'virtio' }
});
if (!this.isWindows) {
disks.push({
name: 'cloudinitdisk',
disk: { bus: 'virtio' }
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
};
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
};
if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData;
} else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData;
} else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
}
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
}
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
}
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
const mergedDisks = this.mergeDeviceList(specDisks, disks);
// `shareable` is an attachment-level opt-in, remove the field
// inherited from the old spec when the disk row no longer requests it
mergedDisks.forEach((disk) => {
if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) {
delete disk.shareable;
}
});
let spec = {
...this.spec,
runStrategy: this.runStrategy,
@@ -1174,16 +1142,6 @@ export default {
},
getInitUserData(config) {
// Windows guests don't use qemu-guest-agent via systemd (VMDP installs
// the QGA as a Windows service), and the Linux `runcmd` recipe would
// fail on Cloudbase-Init. Return an empty string so `this.userData`
// stays falsy on Windows until the user actually enters cloud-config
// content — this prevents emitting a spurious cloudinitdisk volume
// and Secret for every Windows VM (Copilot review comment on #984).
if (config.osType === 'windows') {
return '';
}
const _QGA_JSON = this.getMatchQGA(config.osType);
const out = jsyaml.dump(_QGA_JSON);
@@ -1211,10 +1169,9 @@ export default {
userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config });
const userDataYaml = userDataDoc.toString();
const userDataValue = userDataDoc.toJSON();
if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) {
// Empty userData should not create cloud-init content like `null`.
if (userDataYaml === '{}\n') {
// When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned.
return undefined;
}
@@ -1263,10 +1220,6 @@ export default {
out.cdrom = { bus: R.bus };
}
if (R.shareable) {
out.shareable = true;
}
out.bootOrder = index + 1;
return out;
@@ -1317,9 +1270,8 @@ export default {
case SOURCE_TYPE.IMAGE: {
const image = this.images.find( (I) => R.image === I.id);
out.spec.storageClassName = R.realName ? R.storageClassName : (R.storageClassName || image?.storageClassName);
if (image) {
out.spec.storageClassName = image.storageClassName;
out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: image.id };
} else {
out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: '' };
@@ -1392,22 +1344,13 @@ export default {
*/
deleteYamlDocProp(doc, paths) {
try {
const items = doc.getIn([])?.items;
const firstKey = items?.[0]?.key;
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
const isFirstProp = firstKey?.source === paths[paths.length - 1];
const item = doc.getIn([])?.items[0];
const key = item?.key;
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
const isMatchProp = key.source === paths[paths.length - 1];
if (firstKey && hasCloudConfigComment && isFirstProp) {
const comment = firstKey.commentBefore;
doc.deleteIn(paths);
// Move the comment to the new first key; if no keys remain the comment is lost.
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
if (newFirstKey) {
newFirstKey.commentBefore = comment;
}
if (key && hasCloudConfigComment && isMatchProp) {
// Comments are mounted on the next node and we should not delete the node containing cloud-config
} else {
doc.deleteIn(paths);
}
@@ -1458,6 +1401,7 @@ export default {
if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages);
} else {
userDataDoc.setIn(['packages'], []); // It needs to be set empty first, as it is possible that cloud-init comments are mounted on this node
this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']);
}
@@ -1472,7 +1416,7 @@ export default {
},
deleteQGA(config) {
const { osType, userDataDoc } = config;
const { osType, userDataDoc, deletePackage = false } = config;
const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || '';
@@ -1481,15 +1425,6 @@ export default {
const packages = userDataJSON?.packages || [];
const runcmd = userDataJSON?.runcmd || [];
// Special handling of OS types.
let deletePackage = config.deletePackage ?? false;
switch (osType) {
case 'windows':
deletePackage = true;
break;
}
if (Array.isArray(packages) && deletePackage) {
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
@@ -1515,6 +1450,7 @@ export default {
if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages);
} else {
userDataDoc.setIn(['packages'], []);
this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']);
}
@@ -1547,7 +1483,7 @@ export default {
},
async saveSecret(vm) {
if (!vm?.spec || !this.secretName) {
if (!vm?.spec || !this.secretName || this.isWindows) {
return true;
}
@@ -1968,6 +1904,8 @@ export default {
isWindows(val) {
if (val) {
this['sshKey'] = [];
this['userScript'] = undefined;
this['networkScript'] = undefined;
this['installAgent'] = false;
}
},
@@ -2027,8 +1965,9 @@ export default {
osType(neu, old) {
this.installAgent = old === 'windows' ? true : this.installAgent;
this.userScript = this.getUserData({ installAgent: this.installAgent, osType: neu });
const out = old === 'windows' ? this.getInitUserData({ osType: neu }) : this.getUserData({ installAgent: this.installAgent, osType: neu });
this['userScript'] = out;
this.refreshYamlEditor();
},
-5
View File
@@ -69,11 +69,6 @@ export default class HarvesterResource extends SteveModel {
}
}
// Disable Resources/ Extras cards in resource details pages
get resourcesCardRows() {
return [];
}
cleanForSave(data, _forNew) {
return data;
}
@@ -5,7 +5,6 @@ import SYSTEM_NAMESPACES from '@shell/config/system-namespaces';
import { get } from '@shell/utils/object';
import { NAMESPACE } from '@shell/config/types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester';
import { FORKLIFT_NAMESPACE } from '@pkg/harvester/config/harvester-map';
import { HCI } from '../../types';
const OBSCURE_NAMESPACE_PREFIX = [
@@ -94,10 +93,6 @@ export default class HciNamespace extends namespace {
}
get isSystem() {
if (this.metadata?.name === FORKLIFT_NAMESPACE) {
return false;
}
const systemNamespaces = ['fleet-default'];
if (systemNamespaces.includes(this.metadata.name)) {
+6 -18
View File
@@ -5,14 +5,12 @@ import HarvesterResource from './harvester';
import { HCI } from '../types';
const HARVESTER_NVIDIA_DRIVER_TOOLKIT = 'harvester-system/nvidia-driver-toolkit';
const RANCHER_VCLUSTER = 'harvester-system/rancher-vcluster';
const RANCHER_K3K_ADDON = 'rancher-k3k/rancher-k3k';
export default class HciAddonConfig extends HarvesterResource {
get availableActions() {
const out = super._availableActions;
if (this.id === RANCHER_VCLUSTER || this.id === RANCHER_K3K_ADDON) {
if (this.id === 'harvester-system/rancher-vcluster') {
const rancherDashboard = {
action: 'goToRancher',
enabled: this.spec.enabled,
@@ -59,15 +57,6 @@ export default class HciAddonConfig extends HarvesterResource {
return;
}
if (!this.spec.enabled && this.id === RANCHER_K3K_ADDON) {
this.$dispatch('promptModal', {
resources: [this],
component: 'HarvesterEnableK3k',
});
return;
}
this.spec.enabled = !this.spec.enabled;
await this.save();
} catch (err) {
@@ -80,19 +69,18 @@ export default class HciAddonConfig extends HarvesterResource {
}
goToRancher() {
const valuesContent = jsyaml.load(this.spec.valuesContent);
window.open(
this.rancherHostname,
`https://${ valuesContent.hostname }`,
'_blank',
);
}
get rancherHostname() {
const valuesContent = jsyaml.load(this.spec.valuesContent) || {};
const valuesContent = jsyaml.load(this.spec.valuesContent);
// rancher-k3k nests the hostname under `rancher`, rancher-vcluster keeps it at the top level
const hostname = this.id === RANCHER_K3K_ADDON ? valuesContent.rancher?.hostname : valuesContent.hostname;
return `https://${ hostname }`;
return `https://${ valuesContent.hostname }`;
}
get stateColor() {
@@ -1315,18 +1315,6 @@ export default class VirtVm extends HarvesterResource {
}
}
get provisionedHostDevices() {
// Rancher-provisioned VMs store hostDevices.name as a placeholder ('provisioned'); the
// real allocated PCI device names live in this annotation, keyed by resourceName.
try {
const deviceAllocationDetails = JSON.parse(this.metadata?.annotations[HCI_ANNOTATIONS.VM_DEVICE_ALLOCATION_DETAILS] || '{}');
return deviceAllocationDetails?.hostdevices || {};
} catch (error) {
return {};
}
}
get schedulingVMBackupFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('schedulingVMBackup');
}
@@ -1,14 +0,0 @@
import ManagementSetting from '@shell/models/management.cattle.io.setting';
import { HCI_SETTING } from '../../config/settings';
export default class HarvesterManagementSetting extends ManagementSetting {
get _availableActions() {
const actions = super._availableActions;
if (this.$rootGetters['isStandaloneHarvester'] && this.id === HCI_SETTING.UI_PL) {
return actions.filter((action) => !['goToClone', 'promptRemove'].includes(action.action));
}
return actions;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "harvester",
"description": "Rancher UI Extension for Harvester",
"version": "1.10.0-dev",
"version": "1.9.0-rc3",
"private": false,
"rancher": {
"annotations": {
+2 -14
View File
@@ -63,7 +63,6 @@ export default {
return {
vendor: getVendor(),
uiPLSetting: {},
privateLabelDraft: null,
uiLogoDarkSetting: {},
uiLogoDark: '',
uiLogoLightSetting: {},
@@ -82,17 +81,6 @@ export default {
};
},
computed: {
isHarvesterPrime() {
return !!this.$store.getters['harvester-common/isHarvesterPrime'];
},
privateLabel: {
get() {
return this.privateLabelDraft ?? (this.isHarvesterPrime ? this.$store.getters['harvester-common/privateLabel'] : this.uiPLSetting.value);
},
set(value) {
this.privateLabelDraft = value;
}
},
mode() {
const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.SETTING);
@@ -124,7 +112,7 @@ export default {
this.errors.push(e);
},
async save(btnCB) {
this.uiPLSetting.value = (this.privateLabel || '').replaceAll(/[\<>&=#()"]/gm, '');
this.uiPLSetting.value = this.uiPLSetting.value.replaceAll(/[\<>&=#()"]/gm, '');
if (this.customizeLogo) {
this.uiLogoLightSetting.value = this.uiLogoLight;
this.uiLogoDarkSetting.value = this.uiLogoDark;
@@ -181,7 +169,7 @@ export default {
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="privateLabel"
v-model:value="uiPLSetting.value"
:label="t('branding.uiPL.label')"
:mode="mode"
:maxlength="100"
@@ -1,9 +1,10 @@
<script>
import Loading from '@shell/components/Loading';
import { HCI } from '../../../../../types';
import SerialConsole from '../../../../../components/SerialConsole';
export default {
components: { SerialConsole },
components: { SerialConsole, Loading },
async fetch() {
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
@@ -37,44 +38,16 @@ export default {
</script>
<template>
<div class="serial-console-page">
<SerialConsole
ref="serialConsole"
:value="vmi || {}"
/>
</div>
<Loading v-if="$fetchState.pending" />
<SerialConsole
v-else
ref="serialConsole"
v-model:value="vmi"
/>
</template>
<style lang="scss">
html,
body,
#__nuxt,
#__layout,
main {
<style lang="scss" scoped>
body, #__nuxt, #__layout, main {
height: 100%;
}
.serial-console-page {
position: fixed;
inset: 0;
min-height: 0;
background: var(--body-bg);
color: var(--terminal-text);
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25));
}
.harvester-shell-container {
height: 100%;
background: var(--terminal-bg);
}
}
</style>
@@ -5,7 +5,6 @@ import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
@@ -164,7 +163,7 @@ const onFinish = async(buttonCb) => {
buttonCb(true);
currentRouter().push(providerListLocation);
} catch (err) {
errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
errors.value = [err instanceof Error ? err.message : String(err)];
buttonCb(false);
}
};
@@ -234,7 +233,7 @@ const init = async() => {
// Maps may not exist yet
}
} catch (err) {
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })];
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
}
initialLoading.value = false;
@@ -68,35 +68,29 @@ export default defineComponent({
return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || '';
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => resource?.metadata?.name)
.filter(Boolean);
.map((resource) => ({
name: resource?.metadata?.name,
namespace: resource?.metadata?.namespace || namespace,
}));
const matchedNetworkMap = networkMapRef ? existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMapRef.name && resource?.metadata?.namespace === networkMapRef.namespace) : null;
const matchedStorageMap = storageMapRef ? existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMapRef.name && resource?.metadata?.namespace === storageMapRef.namespace) : null;
const matchedNetworkMap = existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => this.ownedByPlan(resource, plan));
const matchedStorageMap = existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => this.ownedByPlan(resource, plan));
return {
planName,
namespace,
migrations,
networkMap: matchedNetworkMap ? {
namespace: matchedNetworkMap?.metadata?.namespace || networkMapRef?.namespace || namespace,
namespace: matchedNetworkMap?.metadata?.namespace || namespace,
name: matchedNetworkMap?.metadata?.name,
} : (networkMapRef ? {
namespace: networkMapRef.namespace,
name: networkMapRef.name,
} : null),
} : null,
storageMap: matchedStorageMap ? {
namespace: matchedStorageMap?.metadata?.namespace || storageMapRef?.namespace || namespace,
namespace: matchedStorageMap?.metadata?.namespace || namespace,
name: matchedStorageMap?.metadata?.name,
} : (storageMapRef ? {
namespace: storageMapRef.namespace,
name: storageMapRef.name,
} : null),
} : null,
};
});
},
@@ -119,20 +113,6 @@ export default defineComponent({
methods: {
resourceNames,
getPlanMapRef(plan, mapType) {
const ref = plan?.spec?.map?.[mapType];
const name = ref?.name;
if (!name) {
return null;
}
return {
name,
namespace: ref?.namespace || plan?.metadata?.namespace || '',
};
},
/**
* Determine whether a related resource (migration / network map / storage map)
* is owned by the given plan by inspecting its `metadata.ownerReferences`.
@@ -157,8 +137,9 @@ export default defineComponent({
const existing = this.existingRelatedResources;
const targets = new Map();
const addTarget = (type, name, namespace) => {
const ns = namespace || '';
const addTarget = (type, resource) => {
const name = resource?.metadata?.name;
const ns = resource?.metadata?.namespace;
if (!name) {
return;
@@ -172,20 +153,17 @@ export default defineComponent({
};
for (const plan of this.value || []) {
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
existing[HCI.FORKLIFT_NETWORK_MAP]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_NETWORK_MAP, resource));
if (networkMapRef) {
addTarget(HCI.FORKLIFT_NETWORK_MAP, networkMapRef.name, networkMapRef.namespace);
}
if (storageMapRef) {
addTarget(HCI.FORKLIFT_STORAGE_MAP, storageMapRef.name, storageMapRef.namespace);
}
existing[HCI.FORKLIFT_STORAGE_MAP]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_STORAGE_MAP, resource));
existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource?.metadata?.name, resource?.metadata?.namespace));
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource));
}
return [...targets.values()];
@@ -278,11 +256,11 @@ export default defineComponent({
<div>• {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
<template v-if="summary.migrations.length">
<div
v-for="migrationName in summary.migrations"
:key="migrationName"
v-for="migration in summary.migrations"
:key="`${ migration.namespace }/${ migration.name }`"
>
<div class="ml-20">
- {{ migrationName }}
- {{ migration.name }}
</div>
</div>
</template>
-19
View File
@@ -1,6 +1,4 @@
import Parse from 'url-parse';
import { MANAGEMENT } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester';
import { featureEnabled, getVersion } from '../utils/feature-flags';
@@ -66,23 +64,6 @@ const mutations = {
};
const getters = {
isHarvesterPrime: (_state, _getters, _rootState, rootGetters) => {
const setting = rootGetters['harvester/byId']?.(HCI.SETTING, 'server-flavor');
return (setting?.value || setting?.default) === 'prime';
},
privateLabel: (_state, getters, _rootState, rootGetters) => {
const setting = rootGetters['management/byId']?.(MANAGEMENT.SETTING, SETTING.PL);
const value = setting?.value || '';
if (getters.isHarvesterPrime && value === (setting?.default || 'Harvester')) {
return '';
}
return value;
},
getBundleId(state) {
return state.latestBundleId;
},
+10 -25
View File
@@ -1,5 +1,3 @@
import { FAVORITE_TYPES } from '@shell/store/prefs';
/**
* Dynamically toggles SideNav entries based on the enabled status of a specific Addon.
*
@@ -26,17 +24,13 @@ export function registerAddonSideNav(store, productName, {
const kickSideNav = () => {
const TRIGGER = 'ui.refresh.trigger';
// Toggle the trigger a few times so an early kick (fired before the SideNav
// has mounted on first login) is retried once the component is listening.
// Use the prefs/load mutation (in-memory only)
[0, 600, 1500].forEach((delay) => {
setTimeout(() => {
const base = (store.getters['prefs/get'](FAVORITE_TYPES) || []).filter((t) => t !== TRIGGER);
store.dispatch('type-map/addFavorite', TRIGGER);
store.commit('prefs/load', { key: FAVORITE_TYPES, value: [...base, TRIGGER] });
setTimeout(() => store.commit('prefs/load', { key: FAVORITE_TYPES, value: [...base] }), 300);
}, delay);
});
// SideNav component seem to ignore rapid state changes.
// Wait 600ms to ensure the toggle event triggers a re-render.
setTimeout(() => {
store.dispatch('type-map/removeFavorite', TRIGGER);
}, 600);
};
const hasAccessibleSchema = (t) => {
@@ -98,25 +92,16 @@ export function registerAddonSideNav(store, productName, {
// Store is ready. Stop polling.
clearInterval(waitForStore);
// Watch the addon's enabled status together with the schema availability
// of the gated types. Schemas (e.g. forklift CRDs) can load after the
// addon is already enabled, so the watcher must also re-run when they
// become accessible; otherwise the menu never updates until a refresh.
// Watch the specific addon resource for changes to its enabled status.
store.watch(
(state, getters) => {
const addons = getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName);
const isEnabled = addon?.spec?.enabled === true;
const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true;
return `${ isEnabled }:${ schemaReady }`;
return addon?.spec?.enabled === true;
},
() => {
const addons = store.getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName);
setMenuVisibility(addon?.spec?.enabled === true);
(isEnabled) => {
setMenuVisibility(isEnabled);
},
{ immediate: true, deep: true }
);
+4 -17
View File
@@ -73,21 +73,8 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) {
export function buildStorageMapEntries(entries = []) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => {
const destination = { storageClass: entry.target };
if (entry.volumeMode) {
destination.volumeMode = entry.volumeMode;
}
// Forklift StorageMap destination expects a single `accessMode` value.
if (Array.isArray(entry.accessModes) && entry.accessModes.length) {
destination.accessMode = entry.accessModes[0];
}
return {
source: { name: entry.name, id: entry.id },
destination,
};
});
.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
}));
}
+1577 -1646
View File
File diff suppressed because it is too large Load Diff