Compare commits

..
Author SHA1 Message Date
Andy Lee 28aa1061d5 ci: build UI artifact
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-08 15:45:07 +08:00
Marcelo Fukumoto a4299a9597 feat: added comments from copilot review
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:15 +02:00
Marcelo Fukumoto 107e44bd4b feat: added loading to provider steps
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:15 +02:00
Marcelo Fukumoto a11369ad6d feat: Added some final fixes to structure
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto f270a7b1f8 feat: Fix some labels
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto 0d9b976dd2 feat: Added new API for the vms
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto 77d80051c9 feat(forklift): small changes before rebase
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto fbcc235fa7 feat(forklift): Fixed the type for VMware
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto 9ddfaefc39 feat(forklift): Fix the way it is registered
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto a180372564 feat(forklift): small fixes after Copilot
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto b4210037e9 feat(forklift): reverted settings.json changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto 97e4b7271c feat(forklift): small fixes after Copilot
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:12 +02:00
Marcelo Fukumoto 2deb0aa254 feat(forklift): small fixes after Copilot
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:12 +02:00
Marcelo Fukumoto 5c7dd1953d feat(forklift): small fixes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:12 +02:00
Marcelo Fukumoto 3ecb6bfc4a feat(forklift): update size
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto d67813eb39 feat(forklift): Removed wrong configs used for testing
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto 1d26c48117 feat(forklift): Fixed some UI changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto de7598b7af feat(forklift): added the provider wizard
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto e703f34879 feat(forklift): Changed to Wizard
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:10 +02:00
Marcelo Fukumoto 7c8f172012 feat(forklift): Design changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:48 +02:00
Marcelo Fukumoto 6e17a84581 feat(forklift): fixed title and button
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:48 +02:00
Marcelo Fukumoto d075871afc feat(forklift): Changed based on review
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:48 +02:00
Marcelo Fukumoto 4d230ebd42 feat(forklift): Remove test flags
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto c887186e1a feat(forklift): Added better handling on the saving and testing
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto dc6ae60b9d feat(forklift): Added overflow hidden to mapping target list
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto 5d8d7ec986 feat(forklift): fixed to fix the icon on extension
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto bf01a9dc43 feat(forklift): route and router doesnt work properly on extensions
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto 4480b2cd8f feat(forklift): FIxed the routing for the extension
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto b84167a4da feat(forklift): Added pagination UI
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto f7a1dec58f feat(forklift): Change to use the proper provider instead of a hardcoded
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto a0d145ee60 feat(forklift): Added icons, critical state, fix grids, action button
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto 740ca94841 feat(forklift): Applied fix to select the proper data on network. Added condition to show critical
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto d4f59a04be Merge the new APIs
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto d195c5dd18 feat(forklift): Add the succeded state
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto 341f7734a9 feat(forklift): New changes added based on new figma
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 63d726aa91 feat(forklift): Fixed config based on new figma changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 2259910be1 feat(forklift): UI based on the FIGMA
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 7d8d74ce17 feat: add migration and plan detail pages with progress tracking
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 9a20ad5edd feat: forklift poc first commit
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:43 +02:00
104 changed files with 2414 additions and 6764 deletions
+6 -15
View File
@@ -1,23 +1,14 @@
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary ### Summary
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
### PR Checklists
### PR Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
- Are backend engineers aware of UI changes ? - Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is @ - [ ] Yes, the backend owner is:
- [ ] No, frontend-only.
### Related Issue ### Related Issue #
<!-- Link the issue as harvester/harvester#<issue_number> --> <!-- Define findings related to the feature or bug issue. -->
harvester/harvester#<issue_number>
### Test Steps
<!-- Provide the test steps for reviewer to verify the issue -->
1. Go to ... page
### Test screenshot or video ### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison --> <!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
@@ -125,13 +125,6 @@ jobs:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'yarn' 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 - id: ci-build-pkg
name: Build pkg name: Build pkg
run: ./scripts/ci-build-pkg.sh harvester run: ./scripts/ci-build-pkg.sh harvester
@@ -58,12 +58,6 @@ jobs:
- name: Setup Nodejs with yarn install - name: Setup Nodejs with yarn install
uses: ./.github/actions/setup 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 - name: Build and push UI image
run: | run: |
publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}" publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}"
@@ -1,17 +1,18 @@
name: Build Standalone on PR Merge name: Build Standalone on PR Merge
on: on:
workflow_dispatch: # manually trigger on Github Actions only support PR branch pushed to harvester/harvester-ui-extension
push: push:
branches: branches:
- main - main
- 'release-harvester-v*' - 'release-harvester-v*'
- '*-dev' - '*-dev'
- forklift-poc
pull_request: pull_request:
branches: branches:
- main - main
- 'release-harvester-v*' - 'release-harvester-v*'
- '*-dev' - '*-dev'
- forklift-poc
types: types:
- merged - merged
@@ -30,35 +31,3 @@ jobs:
id-token: write id-token: write
with: with:
CI_BRANCH: ${{github.ref_name}} CI_BRANCH: ${{github.ref_name}}
comment-on-pr:
name: Comment build result on PR
# Only for manual runs so the existing push / pull_request flows are untouched.
if: github.event_name == 'workflow_dispatch'
needs:
- build
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Add build artifact comment to the PR opened from this branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
# Match scripts/version: sanitize the branch and map main -> latest.
if [ "$BRANCH" = "main" ]; then
DIR="latest"
else
DIR=$(printf '%s' "$BRANCH" | sed -E 's/[^a-zA-Z0-9.-]+/-/g')
fi
URL="https://releases.rancher.com/harvester-ui/dashboard/${DIR}/index.html"
PR=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$BRANCH" --json number --jq '.[0].number // empty')
if [ -z "$PR" ]; then
echo "No open PR found for branch $BRANCH, skipping comment."
exit 0
fi
BODY=$(printf '🚀 **UI Artifact Build Status:** `SUCCESS` ✅\n> ui-index: %s\n> ui-source: external' "$URL")
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$BODY"
+20 -20
View File
@@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content. > This `./AGENTS.md` file is generated by running `yarn agents:generate`.
# Project Overview # Project Overview
@@ -28,7 +28,9 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
- Run `yarn lint:fix` before commits. - Run `yarn lint:fix` before commits.
- Use conventional commit format: - Use conventional commit format:
``` ```
<type>: <description> <type>:
<description>
``` ```
- Follow existing naming conventions (PascalCase for components, camelCase for functions). - Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint. - After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
@@ -128,8 +130,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`: - `Vue.js`:
- Composition API components are preferred over Options API. - Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components. - Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`. - Place source tag above template above style.
- The `<style>` tag should include `lang="scss" scoped`. - style tag should contain `lang='scss' scoped`.
- `Linting`: Follow the ESLint configuration in the root. - `Linting`: Follow the ESLint configuration in the root.
- **File Structure:** - **File Structure:**
@@ -161,26 +163,24 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide ## Harvester UI Extension Development Guide
### Backward Compatibility 1. Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps: To add a feature in a new release, follow these steps:
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration. Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
```js Check: Use the following getter to verify if the feature is enabled for the current version:
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
3. **Render**: Use the result of the check to conditionally render the UI components. ```
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
## E2E Tests (Cypress) ## E2E Tests (Cypress)
@@ -228,7 +228,7 @@ For the Renovate config, see `.github/renovate.json`.
### Commit Message Format ### Commit Message Format
All commit messages must follow the conventional commit format: All commit messages must follow the conventional commit format:
``` ```
<type>: <description> <type>[optional scope]: <description>
[optional body] [optional body]
-1
View File
@@ -1 +0,0 @@
./AGENTS.md
-1
View File
@@ -1 +0,0 @@
./AGENTS.md
+1 -1
View File
@@ -25,7 +25,7 @@ For Harvester UI extension installation instructions, please refer to the page *
## Development Setup ## Development Setup
Ensure **Node.js v24 or later** is installed for development and debugging. Ensure **Node.js v20 or later** is installed for development and debugging.
### Standalone Mode ### Standalone Mode
+1 -2
View File
@@ -32,11 +32,10 @@ module.exports = {
'subject-empty': [2, 'never'], 'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'], 'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'], 'subject-max-length': [0, 'never'],
'header-max-length': [0, 'always', 100],
'body-leading-blank': [2, 'always'], 'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100], 'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'], 'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [0, 'always', 100], 'footer-max-line-length': [2, 'always', 100],
}, },
// Ignore merge commits and revert commits // Ignore merge commits and revert commits
ignores: [ ignores: [
+3 -1
View File
@@ -6,7 +6,9 @@
- Run `yarn lint:fix` before commits. - Run `yarn lint:fix` before commits.
- Use conventional commit format: - Use conventional commit format:
``` ```
<type>: <description> <type>:
<description>
``` ```
- Follow existing naming conventions (PascalCase for components, camelCase for functions). - Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint. - After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
+15 -17
View File
@@ -16,8 +16,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`: - `Vue.js`:
- Composition API components are preferred over Options API. - Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components. - Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`. - Place source tag above template above style.
- The `<style>` tag should include `lang="scss" scoped`. - style tag should contain `lang='scss' scoped`.
- `Linting`: Follow the ESLint configuration in the root. - `Linting`: Follow the ESLint configuration in the root.
- **File Structure:** - **File Structure:**
@@ -49,24 +49,22 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide ## Harvester UI Extension Development Guide
### Backward Compatibility 1. Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps: To add a feature in a new release, follow these steps:
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration. Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
```js Check: Use the following getter to verify if the feature is enabled for the current version:
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
3. **Render**: Use the result of the check to conditionally render the UI components. ```
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
@@ -26,7 +26,7 @@
### Commit Message Format ### Commit Message Format
All commit messages must follow the conventional commit format: All commit messages must follow the conventional commit format:
``` ```
<type>: <description> <type>[optional scope]: <description>
[optional body] [optional body]
+1 -1
View File
@@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content. > This `./AGENTS.md` file is generated by running `yarn agents:generate`.
# Project Overview # Project Overview
+9 -17
View File
@@ -1,13 +1,13 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.10.0-dev", "version": "1.9.0-dev",
"private": false, "private": false,
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-class-static-block": "7.29.7", "@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/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1", "@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5", "@vue-flow/core": "^1.33.5",
@@ -15,19 +15,13 @@
"cache-loader": "^4.1.0", "cache-loader": "^4.1.0",
"color": "4.2.3", "color": "4.2.3",
"ip": "2.0.1", "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", "node-polyfill-webpack-plugin": "^3.0.0",
"elkjs": "^0.11.0", "elkjs": "^0.11.0",
"vue-draggable-next": "^2.2.1", "vue-draggable-next": "^2.2.1",
"yaml": "^2.5.1" "yaml": "^2.5.1"
}, },
"resolutions": { "resolutions": {
"@types/node": "25.9.6", "@types/node": "25.9.4",
"cronstrue": "2.59.0", "cronstrue": "2.59.0",
"d3-color": "3.1.0", "d3-color": "3.1.0",
"ejs": "3.1.10", "ejs": "3.1.10",
@@ -35,12 +29,12 @@
"glob": "7.2.3", "glob": "7.2.3",
"glob-parent": "6.0.2", "glob-parent": "6.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"@types/lodash": "4.17.25", "@types/lodash": "4.17.24",
"merge": "2.1.1", "merge": "2.1.1",
"node-forge": "1.4.0", "node-forge": "1.4.0",
"nth-check": "2.1.1", "nth-check": "2.1.1",
"qs": "6.16.0", "qs": "6.15.3",
"roarr": "7.21.7", "roarr": "7.21.6",
"semver": "7.8.5", "semver": "7.8.5",
"@vue/cli-service/html-webpack-plugin": "^5.0.0" "@vue/cli-service/html-webpack-plugin": "^5.0.0"
}, },
@@ -48,17 +42,15 @@
"dev": "NODE_ENV=dev ./node_modules/.bin/vue-cli-service serve", "dev": "NODE_ENV=dev ./node_modules/.bin/vue-cli-service serve",
"build": "./node_modules/.bin/vue-cli-service build", "build": "./node_modules/.bin/vue-cli-service build",
"clean": "./node_modules/@rancher/shell/scripts/clean", "clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/@rancher/shell/scripts/lint --max-warnings 0 --ext .js,.ts,.vue .", "lint": "./node_modules/.bin/eslint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/@rancher/shell/scripts/lint --fix --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", "build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs", "serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish", "publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
"parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name", "parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name",
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"prepare": "husky", "prepare": "husky",
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh", "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"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/load": "^19.8.1", "@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 { .filter-label .v-popper__arrow-container {
display: none; display: none;
} }
.fixed-header-actions .middle .filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style> </style>
@@ -116,11 +116,6 @@ export default {
.vm-schedule-dropdown .v-popper__arrow-container { .vm-schedule-dropdown .v-popper__arrow-container {
display: none; display: none;
} }
.fixed-header-actions .middle .vm-schedule-filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
+8 -33
View File
@@ -55,12 +55,8 @@ export default {
!this.latestUpgrade.isUpgradeFailed; !this.latestUpgrade.isUpgradeFailed;
}, },
canUpgrade() {
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
},
versionOptions() { versionOptions() {
const versions = this.$store.getters['harvester/all'](HCI.VERSION) || []; const versions = this.$store.getters['harvester/all'](HCI.VERSION);
return versions.map((V) => V.metadata.name); return versions.map((V) => V.metadata.name);
}, },
@@ -158,23 +154,14 @@ export default {
:cluster="currentCluster.nameDisplay" :cluster="currentCluster.nameDisplay"
/> />
</h1> </h1>
<span <button
v-clean-tooltip="{ v-if="versionOptions.length && !isUpgradeInProgress"
content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'), type="button"
triggers: ['hover', 'focus', 'touch'], class="btn bg-warning btn-sm"
}" @click="open"
class="upgrade-btn-wrap"
:tabindex="canUpgrade ? -1 : 0"
> >
<button <t k="harvester.upgradePage.upgrade" />
:disabled="!canUpgrade" </button>
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<t k="harvester.upgradePage.upgrade" />
</button>
</span>
</header> </header>
<ModalWithCard <ModalWithCard
@@ -309,18 +296,6 @@ export default {
line-height: 23px; line-height: 23px;
} }
.upgrade-btn-wrap {
display: inline-flex;
button {
color: #000 !important;
}
button:hover {
color: #000 !important;
}
}
.currentVersion { .currentVersion {
margin-top: 10px; margin-top: 10px;
display: flex; display: flex;
+63 -442
View File
@@ -1,6 +1,7 @@
<script> <script>
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import Socket, { import Socket, {
EVENT_CONNECTED, EVENT_CONNECTED,
EVENT_CONNECTING, EVENT_CONNECTING,
@@ -21,98 +22,74 @@ export default {
data() { data() {
return { return {
socket: null, socket: null,
terminal: null, terminal: null,
textDecoder: null, fitAddon: null,
fitAddon: null, searchAddon: null,
searchAddon: null, webglAddon: null,
webglAddon: null, isOpen: false,
onResize: null, isOpening: false,
isOpen: false, backlog: [],
isOpening: false, firstTime: true,
backlog: [], queue: []
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',
},
},
}; };
}, },
computed: { computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() { xtermConfig() {
return { return {
allowProposedApi: true, allowProposedApi: true,
cursorBlink: true, cursorBlink: true,
cursorStyle: 'bar',
cursorWidth: 2,
scrollback: 5000,
useStyle: true, useStyle: true,
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12,
fontSize: 16,
lineHeight: 1.3,
}; };
}, },
}, },
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() { beforeUnmount() {
this.close(); this.close();
}, },
watch: {
serialConsoleUrl() {
if (this.terminal) {
this.connect();
}
},
},
async mounted() { async mounted() {
this.textDecoder = new TextDecoder('utf-8');
await this.setupTerminal(); await this.setupTerminal();
await this.connect(); await this.connect();
this.onResize = debounce(() => this.fit(), 100);
window.addEventListener('resize', this.onResize);
this.scheduleFit();
}, },
methods: { methods: {
async setupTerminal() { async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body')); 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({ const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'), fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'), webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'), weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'), search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
}); });
const terminal = new xterm.Terminal({ const terminal = new xterm.Terminal({
theme: { theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(), background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-text').trim(), cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim() foreground: docStyle.getPropertyValue('--terminal-text').trim()
}, },
...this.xtermConfig, ...this.xtermConfig,
@@ -120,48 +97,25 @@ export default {
this.fitAddon = new addons.fit.FitAddon(); this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon(); 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.fitAddon);
terminal.loadAddon(this.searchAddon); terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon()); terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm); terminal.open(this.$refs.xterm);
// The webgl renderer can fail without throwing: the context may be lost if ( this.webglAddon ) {
// 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);
}
});
terminal.loadAddon(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.fit();
this.flush(); this.flush();
this.scheduleDrainQueue();
terminal.onData((input) => { terminal.onData((input) => {
const msg = this.str2ab(input); const msg = this.str2ab(input);
@@ -169,132 +123,9 @@ export default {
this.write(msg); 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; 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) { str2ab(str) {
const enc = new TextEncoder(); 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() { clear() {
this.terminal.clear(); this.terminal.clear();
}, },
getSocketUrl() { getSocketUrl() {
return this.serialConsoleUrl; return `${ this.value?.getSerialConsolePath }`;
}, },
async connect() { async connect() {
@@ -469,7 +181,6 @@ export default {
this.fit(); this.fit();
this.flush(); this.flush();
} }
this.scheduleFit();
if (this.firstTime) { if (this.firstTime) {
this.socket.send(this.str2ab('\n')); this.socket.send(this.str2ab('\n'));
@@ -484,7 +195,7 @@ export default {
}); });
this.socket.addEventListener(EVENT_MESSAGE, (e) => { this.socket.addEventListener(EVENT_MESSAGE, (e) => {
this.enqueueMessage(this.decodeMessageData(e.detail.data)); this.queue.push(e.detail.data.text());
}); });
this.socket.connect(); this.socket.connect();
@@ -507,19 +218,22 @@ export default {
} }
this.fitAddon.fit(); 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() { close() {
if (this.onResize) {
window.removeEventListener('resize', this.onResize);
this.onResize = null;
}
if (this.drainTimer) {
cancelAnimationFrame(this.drainTimer);
this.drainTimer = null;
}
if ( this.socket ) { if ( this.socket ) {
this.socket.disconnect(); this.socket.disconnect();
} }
@@ -534,44 +248,6 @@ export default {
<template> <template>
<div class="harvester-shell-container"> <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 <div
ref="xterm" ref="xterm"
class="shell-body" class="shell-body"
@@ -581,73 +257,18 @@ export default {
</template> </template>
<style lang="scss"> <style lang="scss">
@import '../../../../node_modules/@xterm/xterm/css/xterm.css'; @import '../../../../node_modules/xterm/css/xterm.css';
body, #__nuxt, #__layout, MAIN { body, #__nuxt, #__layout, MAIN {
height: 100%; height: 100%;
} }
.harvester-shell-container { .harvester-shell-container {
display: flex;
flex-direction: column;
position: relative;
height: 100%; height: 100%;
min-height: 0;
overflow: hidden; overflow: hidden;
.resize-observer { .shell-body, .terminal.xterm {
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;
height: 100%; 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> </style>
+14 -21
View File
@@ -10,7 +10,6 @@ import { docLink } from '../utils/feature-flags';
const CATEGORY = { const CATEGORY = {
ui: [ ui: [
'branding', 'branding',
HCI_SETTING.SERVER_FLAVOR,
'ui-source', 'ui-source',
'ui-index', 'ui-index',
] ]
@@ -54,7 +53,7 @@ export default {
}; };
}, },
computed: { ...mapGetters({ t: 'i18n/t', isHarvesterPrime: 'harvester-common/isHarvesterPrime' }) }, computed: { ...mapGetters({ t: 'i18n/t' }) },
watch: { watch: {
settings: { settings: {
@@ -232,21 +231,14 @@ export default {
v-if="setting.customized" v-if="setting.customized"
class="modified" class="modified"
> >
{{ t('advancedSettings.tags.modified.label') }} Modified
</span> </span>
<span <span
v-if="setting.experimental" v-if="setting.experimental"
v-clean-tooltip="t('advancedSettings.tags.experimental.description')" v-clean-tooltip="t('advancedSettings.experimental')"
class="experimental" class="experimental"
> >
{{ t('advancedSettings.tags.experimental.label') }} Experimental
</span>
<span
v-if="setting.preview"
v-clean-tooltip="t('advancedSettings.tags.preview.description')"
class="preview"
>
{{ t('advancedSettings.tags.preview.label') }}
</span> </span>
</h1> </h1>
<h2 v-clean-html="t(setting.description, getDocLinkParams(setting) || {}, true)"> <h2 v-clean-html="t(setting.description, getDocLinkParams(setting) || {}, true)">
@@ -270,8 +262,7 @@ export default {
v-if="!setting.hide" v-if="!setting.hide"
class="settings-value" class="settings-value"
> >
<pre v-if="setting.id === 'branding' && isHarvesterPrime">{{ $store.getters['harvester-common/privateLabel'] }}</pre> <pre v-if="setting.kind === 'json'">{{ setting.json }}</pre>
<pre v-else-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 === '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 === 'enum'">{{ t(setting.enum) }}</pre>
<pre v-else-if="setting.kind === 'custom' && setting.custom">{{ setting.custom }}</pre> <pre v-else-if="setting.kind === 'custom' && setting.custom">{{ setting.custom }}</pre>
@@ -361,19 +352,21 @@ export default {
flex: 1; flex: 1;
} }
.modified, .modified {
.experimental,
.preview {
margin-left: 10px; margin-left: 10px;
border: 1px solid; border: 1px solid var(--primary);
border-radius: 5px; border-radius: 5px;
padding: 2px 10px; padding: 2px 10px;
font-size: 12px; font-size: 12px;
} }
.modified { border-color: var(--primary); } .experimental {
.experimental { border-color: var(--error); } margin-left: 10px;
.preview { border-color: var(--warning); } border: 1px solid var(--error);
border-radius: 5px;
padding: 2px 10px;
font-size: 12px;
}
.no-search-match { .no-search-match {
text-align: center; text-align: center;
+1 -5
View File
@@ -55,10 +55,6 @@ export default {
const host = window.location.host; const host = window.location.host;
const prefix = window.location.pathname.replace(this.$route.path, ''); const prefix = window.location.pathname.replace(this.$route.path, '');
const params = this.$route?.params; 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 }`; const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
@@ -67,7 +63,7 @@ export default {
window.open( window.open(
url, url,
'_blank', '_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,9 @@ import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string'; import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift'; import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue'; 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({ const props = defineProps({
providerName: { type: String, default: '' }, providerName: { type: String, default: '' },
@@ -40,10 +34,6 @@ const allStorageMaps = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
const { networkEntries, storageEntries } = toRefs(props.stepData); const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE; const NAMESPACE = FORKLIFT_NAMESPACE;
@@ -66,22 +56,10 @@ const harvesterNetworkOptions = computed(() => {
}); });
const storageClassOptions = computed(() => { const storageClassOptions = computed(() => {
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => { const options = storageClasses.value.map((sc) => ({
const value = sc.name; label: sc.metadata?.name || sc.id,
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value; value: sc.metadata?.name || sc.id,
let disabled = false; }));
if (isInternalStorageClass(value)) {
label += ` (${ t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value,
disabled,
};
});
if (!options.find((o) => o.value === 'harvester-longhorn')) { if (!options.find((o) => o.value === 'harvester-longhorn')) {
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' }); options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
@@ -114,7 +92,7 @@ const applyNetworkMapTargets = (mapSpec) => {
}); });
}; };
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => { const applyStorageMapTargets = (mapSpec) => {
if (!mapSpec) { if (!mapSpec) {
return; return;
} }
@@ -126,30 +104,6 @@ const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInheri
if (match?.destination?.storageClass) { if (match?.destination?.storageClass) {
entry.target = 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 +121,12 @@ const applyDefaultStorageMap = () => {
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default` (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 allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target)); const 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(() => { const canSave = computed(() => {
if (props.useAllProviderData) { if (props.useAllProviderData) {
return true; return true;
@@ -262,19 +189,13 @@ const buildStorageEntries = () => {
if (ds && ds.id) { if (ds && ds.id) {
if (!datastoreMap[ds.id]) { if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = { datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'), name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id, id: ds.id,
type: ds.type || '', type: ds.type || '',
capacity: 0, capacity: 0,
target: '', target: '',
volumeMode: DEFAULT_VOLUME_MODE, usedBy: [],
accessModes: [DEFAULT_ACCESS_MODE], _key: `stor-${ ds.id }`,
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id }`,
}; };
} }
@@ -304,19 +225,13 @@ const buildNetworkEntriesFromProvider = (networksData) => {
const buildStorageEntriesFromProvider = (datastoresData) => { const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({ storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id, name: ds.name || ds.id,
id: ds.id || '', id: ds.id || '',
type: ds.type || '', type: ds.type || '',
capacity: ds.capacity || 0, capacity: ds.capacity || 0,
target: '', target: '',
volumeMode: DEFAULT_VOLUME_MODE, usedBy: [],
accessModes: [DEFAULT_ACCESS_MODE], _key: `stor-${ ds.id || ds.name }`,
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
})); }));
}; };
@@ -533,7 +448,7 @@ const init = async() => {
if (!hasExistingStorageTargets) { if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) { if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true }); applyStorageMapTargets(props.existingStorageMap.spec.map);
} else { } else {
applyDefaultStorageMap(); applyDefaultStorageMap();
} }
@@ -583,9 +498,6 @@ init();
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')" :placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData" :show-used-by="!useAllProviderData"
:clearable="useAllProviderData" :clearable="useAllProviderData"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
> >
<template #source-detail="{ entry }"> <template #source-detail="{ entry }">
<span <span
@@ -595,19 +507,6 @@ init();
</template> </template>
</MappingColumn> </MappingColumn>
</div> </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> </div>
</template> </template>
@@ -42,7 +42,6 @@ const {
username, username,
password, password,
skipTlsVerify, skipTlsVerify,
vddkInitImage,
testPassed, testPassed,
testResult, testResult,
testError, testError,
@@ -98,7 +97,6 @@ watch(selectedProvider, (val) => {
username.value = ''; username.value = '';
password.value = ''; password.value = '';
skipTlsVerify.value = false; skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null; createdProvider.value = null;
createdSecret.value = null; createdSecret.value = null;
} else { } else {
@@ -109,7 +107,6 @@ watch(selectedProvider, (val) => {
if (provider) { if (provider) {
providerName.value = provider.metadata.name; providerName.value = provider.metadata.name;
url.value = provider.spec?.url || ''; url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret; const secretRef = provider.spec?.secret;
@@ -242,18 +239,6 @@ const testConnection = async(buttonCb) => {
if (props.editMode && createdProvider.value) { if (props.editMode && createdProvider.value) {
try { try {
createdProvider.value.spec.url = url.value; createdProvider.value.spec.url = url.value;
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
createdProvider.value.spec.settings = {
...(createdProvider.value.spec.settings || {}),
vddkInitImage: vddkImage,
};
} else if (createdProvider.value.spec.settings) {
delete createdProvider.value.spec.settings.vddkInitImage;
}
await createdProvider.value.save(); await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret; const secretRef = createdProvider.value.spec?.secret;
@@ -298,28 +283,20 @@ const testConnection = async(buttonCb) => {
const namespace = FORKLIFT_NAMESPACE; const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`; const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const spec = {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
};
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
spec.settings = { vddkInitImage: vddkImage };
}
const provider = await store.dispatch(`${ inStore }/create`, { const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER, type: HCI.FORKLIFT_PROVIDER,
metadata: { metadata: {
name: providerName.value, name: providerName.value,
namespace, namespace,
}, },
spec, spec: {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
}
}); });
await provider.save(); await provider.save();
@@ -425,7 +402,6 @@ defineExpose({ testConnection, clickTestButton });
<div v-if="!createOnly && !editMode"> <div v-if="!createOnly && !editMode">
<LabeledSelect <LabeledSelect
v-model:value="selectedProvider" v-model:value="selectedProvider"
required
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')" :label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions" :options="providerOptions"
:reduce="(opt) => opt.value" :reduce="(opt) => opt.value"
@@ -443,28 +419,17 @@ defineExpose({ testConnection, clickTestButton });
/> />
</div> </div>
<div class="row"> <div>
<div class="col span-6"> <LabeledInput
<LabeledInput v-model:value="url"
v-model:value="url" :label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')" :placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')" :disabled="isExistingProvider && !editMode"
:disabled="isExistingProvider && !editMode" required
required />
/> <p class="text-deemphasized mt-5">
<p class="text-deemphasized mt-5"> {{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }} </p>
</p>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="vddkInitImage"
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
:disabled="isExistingProvider && !editMode"
/>
</div>
</div> </div>
<div class="row"> <div class="row">
@@ -1,33 +1,25 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard'; import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE } from '../../config/types';
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const props = defineProps({ const props = defineProps({
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, default: '' }, description: { type: String, default: '' },
entries: { type: Array, default: () => [] }, entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false }, showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false }, clearable: { type: Boolean, default: false },
// Storage-specific: show the volume/access mode defaults row with an Edit action.
showVolumeSettings: { type: Boolean, default: false },
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
inheritedProviderName: { type: String, default: '' },
}); });
const emit = defineEmits(['edit-defaults']); const selectOptions = computed(() => {
if (!props.clearable) {
// Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
return props.options; return props.options;
} }
@@ -42,16 +34,14 @@ const optionsFor = (entry) => {
disabled: true, disabled: true,
kind: 'divider', kind: 'divider',
}, },
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options, ...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> </script>
<template> <template>
@@ -95,35 +85,12 @@ const formatModes = (entry) => {
<div class="mapping-target"> <div class="mapping-target">
<LabeledSelect <LabeledSelect
v-model:value="entry.target" v-model:value="entry.target"
:options="optionsFor(entry)" :options="selectOptions"
:placeholder="placeholder+'...'" :placeholder="placeholder+'...'"
:searchable="true" :searchable="true"
/> />
</div> </div>
</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"> <div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by"> <span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b> {{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
@@ -210,41 +177,6 @@ const formatModes = (entry) => {
line-height: 20px; 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 { .bg-light-gray {
background-color: var(--category-active) !important; background-color: var(--category-active) !important;
border: 0; border: 0;
@@ -2,14 +2,11 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState'; import { RcItemCard } from '@components/RcItemCard';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { import {
@@ -24,70 +21,29 @@ const props = defineProps({
stepData: { type: Object, required: true }, stepData: { type: Object, required: true },
}); });
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']); const emit = defineEmits(['ready']);
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const vms = computed(() => props.selectedVms); const vms = ref([]);
const networkMappings = ref([]); const networkMappings = ref([]);
const storageMappings = ref([]); const storageMappings = ref([]);
const planName = ref(''); const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
// Restore persisted state // Restore persisted state
planName.value = props.stepData.planName; planName.value = props.stepData.planName;
targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE; const NAMESPACE = FORKLIFT_NAMESPACE;
const TARGET_NAMESPACE = 'default';
const nameConflictError = computed(() => {
const name = (planName.value || '').trim();
if (!name) {
return '';
}
if (existingProviderNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
}
if (existingPlanNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
}
return '';
});
watch(planName, (val) => { watch(planName, (val) => {
props.stepData.planName = val; props.stepData.planName = val;
emit('ready', !!val);
}, { immediate: true }); }, { immediate: true });
watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val;
});
watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true });
const formatNetworkTarget = (target = '') => {
if (target === 'pod') {
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
}
if (target === 'ignored') {
return t('harvester.addons.vmMigration.generic.ignored');
}
return target;
};
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0)); const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => { const totalMemoryGB = computed(() => {
@@ -108,7 +64,7 @@ const totalStorageGB = computed(() => {
return bytesToGB(totalBytes); return bytesToGB(totalBytes);
}); });
const vmRows = computed(() => { const vmCards = computed(() => {
return vms.value.map((vm) => { return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0; const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
@@ -122,93 +78,23 @@ const vmRows = computed(() => {
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-'; const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-'; const os = vm.guestName || vm.guestOS || vm.os || '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id)); const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id)); const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
return { return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id, id: vm.id,
name: vm.name || vm.id, name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os, os,
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }), cpus,
resourcesSub: `${ memGB } • ${ diskDisplay }`, memGB,
powerState, diskDisplay,
powerStateClass,
networkMappings: vmNetworkMappings, networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings, storageMappings: vmStorageMappings,
networkMapping: networkMappingSort,
storageMapping: storageMappingSort,
}; };
}); });
}); });
const vmHeaders = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
value: 'name',
sort: ['name'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
value: 'os',
sort: ['os'],
width: 140,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
value: 'resources',
sort: false,
width: 120,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 100,
},
{
name: 'networkMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
value: 'networkMapping',
sort: ['networkMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'storageMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
value: 'storageMapping',
sort: ['storageMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'actions',
label: ' ',
align: 'right',
sort: false,
width: 40,
},
];
const onEditSelection = () => emit('edit-selection');
const onEditMappings = () => emit('edit-mappings');
const onRemoveVm = (id) => emit('remove-vm', id);
const startMigrationAction = async() => { const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
@@ -217,13 +103,13 @@ const startMigrationAction = async() => {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider', kind: 'Provider',
name: props.providerName, name: props.providerName,
namespace: NS, namespace: NAMESPACE,
}, },
destination: { destination: {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider', kind: 'Provider',
name: 'host', name: 'host',
namespace: NS, namespace: NAMESPACE,
}, },
}; };
@@ -232,15 +118,15 @@ const startMigrationAction = async() => {
const networkMap = await store.dispatch(`${ inStore }/create`, { const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP, type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NS }, metadata: { name: networkMapName, namespace: NAMESPACE },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef }, spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
}); });
await networkMap.save(); await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, { const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP, type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NS }, metadata: { name: storageMapName, namespace: NAMESPACE },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef }, spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
}); });
@@ -248,7 +134,7 @@ const startMigrationAction = async() => {
const plan = await store.dispatch(`${ inStore }/create`, { const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN, type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NS }, metadata: { name: planName.value, namespace: NAMESPACE },
spec: { spec: {
provider: providerRef, provider: providerRef,
map: { map: {
@@ -256,16 +142,16 @@ const startMigrationAction = async() => {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap', kind: 'NetworkMap',
name: networkMapName, name: networkMapName,
namespace: NS, namespace: NAMESPACE,
}, },
storage: { storage: {
apiVersion: FORKLIFT_API_VERSION, apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap', kind: 'StorageMap',
name: storageMapName, name: storageMapName,
namespace: NS, namespace: NAMESPACE,
}, },
}, },
targetNamespace: targetNamespace.value, targetNamespace: TARGET_NAMESPACE,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })), vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false, warm: false,
}, },
@@ -273,37 +159,26 @@ const startMigrationAction = async() => {
await plan.save(); 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 // 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). // lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration(); await plan.startMigration();
}; };
const init = async() => { const init = () => {
const inStore = store.getters['currentProduct'].inStore; vms.value = props.selectedVms;
try {
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
} catch (e) {
namespaceOptions.value = [];
}
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {}
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
} catch (e) {}
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
if (props.mappingEntries) { if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({ networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
@@ -336,178 +211,116 @@ defineExpose({ startMigration: startMigrationAction });
<div <div
class="review-migration-content" class="review-migration-content"
> >
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary --> <!-- Migration Details Summary -->
<div class="migration-details"> <div class="migration-details">
<div class="section-header"> <h3 class="section-title m-0">
<h3 class="section-title m-0"> {{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }} </h3>
</h3> <div class="span-9">
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row">
<LabeledInput <LabeledInput
v-model:value="planName" v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')" :label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')" :placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required required
/> />
<LabeledSelect
v-model:value="targetNamespace"
class="namespace-select"
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
:options="namespaceOptions"
required
/>
</div> </div>
<div class="details-grid span-9">
<div class="summary-row"> <div class="detail-column">
<div class="migration-mode-column"> <div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span> <span class="detail-value detail-value-large">{{ vms.length }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span> </div>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div> </div>
<div class="detail-column">
<div class="stats-column"> <div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
<span class="detail-value">{{ providerName }}</span> </div>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span> <div class="detail-item">
<span class="detail-value">{{ vms.length }}</span> <span class="detail-value">{{ providerName }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span> </div>
<span class="detail-value">{{ totalVCpu }}</span> </div>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span> <div class="detail-column">
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span> <div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span> </div>
<div class="detail-item">
<span class="detail-value">{{ TARGET_NAMESPACE }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div>
</div>
<div class="detail-column grid-column-2">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Virtual Machines --> <!-- Virtual Machines -->
<div class="vm-section"> <div class="vm-section">
<SortableTable <h3 class="section-title m-0">
class="vm-sortable-table" {{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
:rows="vmRows" </h3>
:headers="vmHeaders"
:search="false" <div class="vm-cards-grid">
:table-actions="false" <RcItemCard
:row-actions="false" v-for="vm in vmCards"
:groupable="false" :id="vm.id"
:paging="true" :key="vm.id"
key-field="_key" :variant="'small'"
> :header="{ title: { text: vm.name } }"
<template #header-left> >
<h4 class="section-title m-0"> <template #item-card-content>
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }}) <div class="vm-card-content">
</h4> <div class="vm-card-specs">
</template> <span class="vm-os text-deemphasized">{{ vm.os }}</span>
<template #header-right> <span class="vm-resources">
<a <i
href="#" class="icon icon-disk"
class="edit-selection-link" aria-hidden="true"
@click.prevent="onEditSelection" />
> {{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }} </span>
</a> </div>
</template> <MappingsCell
<template #cell:vmName="{ row }"> :network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
<div class="vm-name-cell"> :storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
<span class="vm-name">{{ row.name }}</span> />
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resources }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:networkMapping="{ row }">
<template v-if="row.networkMappings.length">
<div
v-for="(m, i) in row.networkMappings"
:key="'net-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
</div> </div>
</template> </template>
<span </RcItemCard>
v-else </div>
class="text-deemphasized"
>-</span>
</template>
<template #cell:storageMapping="{ row }">
<template v-if="row.storageMappings.length">
<div
v-for="(m, i) in row.storageMappings"
:key="'stor-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ m.target }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:actions="{ row }">
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
<rc-dropdown-trigger
variant="ghost"
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<rc-dropdown-item @click="onEditMappings">
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
</rc-dropdown-item>
<rc-dropdown-item
class="text-error"
@click="onRemoveVm(row.id)"
>
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>
<template #no-rows>
<tr class="no-rows">
<td
:colspan="vmHeaders.length"
class="text-center"
>
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
</td>
</tr>
</template>
</SortableTable>
</div> </div>
<!-- Cold Migration Warning -->
<Banner
color="warning"
class="m-0"
>
<div>
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br>
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
</div>
</Banner>
<!-- Error banner --> <!-- Error banner -->
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
@@ -534,122 +347,74 @@ defineExpose({ startMigration: startMigrationAction });
font-weight: 600; font-weight: 600;
} }
.name-row { .details-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
align-items: start; gap: 12px clamp(64px, 8vw, 128px);
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
line-height: 20px; line-height: 20px;
margin-top: 12px;
} }
.section-header { .detail-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
.section-description { &.grid-column-2 {
font-size: 14px; grid-column: span 2;
} }
} }
.migration-mode-column { .detail-item {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-start; justify-content: space-between;
gap: 8px;
font-size: 14px;
.cold-migration-badge { .detail-label {
padding: 2px 10px; font-size: 14px;
border-radius: 4px; color: var(--muted);
opacity: 0.8; }
font-weight: 600;
background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600;
.theme-light & { .detail-value {
background: rgba(168, 128, 26, 0.16); font-weight: 600;
font-size: 14px;
&.detail-value-large {
font-size: 24px;
} }
} }
} }
.stats-column { .vm-cards-grid {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
justify-content: start; gap: 16px;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
} }
.detail-label { .vm-card-content {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
}
.edit-selection-link {
cursor: pointer;
color: var(--link);
font-size: 14px;
}
.vm-sortable-table {
:deep(.fixed-header-actions) {
align-items: end;
}
:deep(table) {
table-layout: fixed;
}
:deep(td) {
vertical-align: middle;
overflow: hidden;
}
:deep(th:first-child),
:deep(td:first-child) {
padding-left: 16px;
}
}
.vm-name-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
.vm-name { line-height: 20px;
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
} }
.mapping-cell { .vm-card-specs {
.mapping-target { display: flex;
margin-left: 4px; flex-direction: row;
white-space: nowrap; gap: 16px;
} align-items: center;
}
.mapping-cell + .mapping-cell { .vm-os {
margin-top: 8px; font-size: 14px;
}
.vm-resources {
font-size: 14px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
} }
.banner-title { .banner-title {
@@ -25,9 +25,8 @@ const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]); const selectedVMs = ref([]);
const loading = ref(true); const loading = ref(true);
// Restored from stepData so friendly names survive step navigation / remount. const networkMap = ref({});
const networkMap = ref(props.stepData.networkMap || {}); const datastoreMap = ref({});
const datastoreMap = ref(props.stepData.datastoreMap || {});
const sortableTableRef = ref(null); const sortableTableRef = ref(null);
const allVMsSelected = ref(false); const allVMsSelected = ref(false);
const errors = ref([]); const errors = ref([]);
@@ -95,7 +94,7 @@ const showSelectAllBanner = computed(() => {
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead')); const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = ref([ const headers = [
{ {
name: 'vmName', name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName', labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
@@ -108,21 +107,18 @@ const headers = ref([
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os', labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os', value: 'os',
sort: ['os'], sort: ['os'],
width: 180,
}, },
{ {
name: 'resources', name: 'resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources', labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources', value: 'resources',
sort: false, sort: false,
width: 140,
}, },
{ {
name: 'powerState', name: 'powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState', labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState', value: 'powerState',
sort: ['powerState'], sort: ['powerState'],
width: 130,
}, },
{ {
name: 'network', name: 'network',
@@ -130,7 +126,6 @@ const headers = ref([
value: 'network', value: 'network',
sort: ['network'], sort: ['network'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'), subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
}, },
{ {
name: 'datastore', name: 'datastore',
@@ -138,9 +133,8 @@ const headers = ref([
value: 'datastore', value: 'datastore',
sort: ['datastore'], sort: ['datastore'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'), subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
}, },
]); ];
const buildTableRows = () => { const buildTableRows = () => {
return discoveredVMs.value.map((vm) => { return discoveredVMs.value.map((vm) => {
@@ -191,7 +185,6 @@ const buildTableRows = () => {
return { return {
_original: vm, _original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name, _key: vm.id || vm.vmId || vm.metadata?.name,
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
vmName: vm.name || vm.metadata?.name || '-', vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-', vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-', os: vm.guestName || vm.guestOS || vm.os || '-',
@@ -241,10 +234,6 @@ const clearSelection = () => {
if (table) { if (table) {
table.clearSelection(); table.clearSelection();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
} }
}); });
}; };
@@ -255,49 +244,6 @@ const selectAllVMs = () => {
selectedVMs.value = discoveredVMs.value.slice(); 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( watch(
() => sortableTableRef.value?.page, () => sortableTableRef.value?.page,
() => { () => {
@@ -367,9 +313,6 @@ const fetchVMs = async() => {
return map; return map;
}, {}); }, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
lastFetchedAt.value = Date.now(); lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows(); tableRows.value = buildTableRows();
}; };
@@ -426,10 +369,6 @@ const init = async() => {
tableRows.value = buildTableRows(); tableRows.value = buildTableRows();
loading.value = false; loading.value = false;
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
await nextTick();
sortSelectedToFront();
return; return;
} }
@@ -497,6 +436,7 @@ init();
:row-actions="false" :row-actions="false"
:groupable="false" :groupable="false"
:paging="true" :paging="true"
:rows-per-page="20"
key-field="_key" key-field="_key"
@selection="onSelect" @selection="onSelect"
> >
@@ -505,20 +445,7 @@ init();
<h3 class="m-0"> <h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }} {{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3> </h3>
<span class="selected-actions"> <span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
<span class="text-deemphasized">
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
</span>
<template v-if="selectedCount > 0">
<span class="text-deemphasized">|</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
</a>
</template>
</span>
</div> </div>
</template> </template>
<template #cell:vmName="{ row }"> <template #cell:vmName="{ row }">
@@ -534,7 +461,7 @@ init();
<template #cell:powerState="{ row }"> <template #cell:powerState="{ row }">
<BadgeState <BadgeState
:label="row.powerState" :label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'" :color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/> />
</template> </template>
<template #cell:network="{ row }"> <template #cell:network="{ row }">
@@ -601,23 +528,6 @@ init();
.text-deemphasized { .text-deemphasized {
font-size: 13px; 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 { .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>
+1 -11
View File
@@ -73,17 +73,7 @@ const FEATURE_FLAGS = {
'supportFilesystem', 'supportFilesystem',
'disableResourcePooling', 'disableResourcePooling',
'expandOnlineEncryptedVolume', 'expandOnlineEncryptedVolume',
'longhornV2HugepageSettings', 'longhornV2HugepageSettings'
'staticIPForVM',
'fsFreezeDeadline',
],
'v1.9.1': [
'lvmDmThinOnly',
'highPerformanceStorage',
],
'v1.10.0': [
'maintenanceModeConditions',
'maintenanceModeDrainTimeoutSetting',
], ],
}; };
+18 -61
View File
@@ -57,8 +57,6 @@ import {
} from './table-headers'; } from './table-headers';
import { ADD_ONS } from './harvester-map'; import { ADD_ONS } from './harvester-map';
import { registerAddonSideNav } from '../utils/dynamic-nav'; 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 TEMPLATE = HCI.VM_VERSION;
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring'; const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
@@ -111,51 +109,15 @@ export function init($plugin, store) {
} }
}; };
store.watch( store.dispatch('setIsSingleProduct', {
(_state, getters) => JSON.stringify([ productName: PRODUCT_NAME,
!!getters['harvester-common/isHarvesterPrime'], logo: require(`@shell/assets/images/providers/harvester.svg`),
getters['prefs/theme'] === 'dark', productNameKey: 'harvester.productLabel',
getters['harvester-common/privateLabel'] getVersionInfo: (store) => store.getters[`${ PRODUCT_NAME }/byId`]?.(HCI.SETTING, 'server-version')?.value || 'unknown',
]), afterLoginRoute: home,
(value) => { logoRoute: home,
const [isHarvesterPrime, isDark, privateLabel] = JSON.parse(value); supportCustomLogo: true
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 }
);
} }
product({ product({
@@ -1416,7 +1378,7 @@ export function init($plugin, store) {
virtualType({ virtualType({
name: HCI.FORKLIFT_NETWORK_MAP, name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.vmMigration.labels.networkMap', labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'vmMigration::Configurations', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@@ -1443,7 +1405,7 @@ export function init($plugin, store) {
virtualType({ virtualType({
name: HCI.FORKLIFT_STORAGE_MAP, name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.vmMigration.labels.storageMap', labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'vmMigration::Configurations', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@@ -1471,7 +1433,7 @@ export function init($plugin, store) {
virtualType({ virtualType({
name: HCI.FORKLIFT_PLAN, name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.vmMigration.labels.plan', labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'vmMigration::Configurations', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@@ -1497,7 +1459,7 @@ export function init($plugin, store) {
virtualType({ virtualType({
name: HCI.FORKLIFT_MIGRATION, name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.vmMigration.labels.migration', labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'vmMigration::Configurations', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@@ -1517,16 +1479,11 @@ export function init($plugin, store) {
} }
}); });
// Register the dashboard entry directly — it's a virtual type with no schema,
// so it cannot go through registerAddonSideNav (which filters by schema).
basicType(['forklift-create'], 'vmMigration');
// Enable SideNav based on Forklift Addon Status // Enable SideNav based on Forklift Addon Status
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
// is registered with requireSchema:false to still be gated by the addon status.
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
requireSchema: false,
types: ['forklift-create']
});
registerAddonSideNav(store, PRODUCT_NAME, { registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR, addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS, resourceType: HCI.ADD_ONS,
@@ -1538,7 +1495,7 @@ export function init($plugin, store) {
registerAddonSideNav(store, PRODUCT_NAME, { registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR, addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS, resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration::Configurations', navGroup: 'vmMigration::Advanced',
types: [ types: [
HCI.FORKLIFT_NETWORK_MAP, HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP, HCI.FORKLIFT_STORAGE_MAP,
-15
View File
@@ -39,21 +39,6 @@ export const VOLUME_TYPE = [{
value: 'cd-rom' value: 'cd-rom'
}]; }];
// KubeVirt high-performance disk features
// https://kubevirt.io/user-guide/storage/disks_and_volumes/#high-performance-features
export const DISK_CACHE_MODE = ['', 'none', 'writeback', 'writethrough'];
export const DISK_IO_MODE = ['', 'native', 'threads'];
export const IO_THREADS_POLICY = ['', 'shared', 'auto', 'supplementalPool'];
// "Easy-to-consume" presets layered on top of the raw KubeVirt fields.
export const DISK_PERFORMANCE_PROFILE = {
DEFAULT: 'default',
HIGH: 'highPerformance',
CUSTOM: 'custom',
};
export const VOLUME_HOTPLUG_ACTION = { export const VOLUME_HOTPLUG_ACTION = {
INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE', INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE',
EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE', EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE',
+1 -9
View File
@@ -16,13 +16,12 @@ export const HCI = {
CREATOR: 'harvesterhci.io/creator', CREATOR: 'harvesterhci.io/creator',
OS: 'harvesterhci.io/os', OS: 'harvesterhci.io/os',
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage', GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
NETWORK_TYPE: 'network.harvesterhci.io/type', NETWORK_TYPE: 'network.harvesterhci.io/type',
VM_NAME: 'harvesterhci.io/vmName', VM_NAME: 'harvesterhci.io/vmName',
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix', VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
VM_DISPLAY_NAME: 'harvesterhci.io/vmDisplayName', VM_DISPLAY_NAME: 'harvesterhci.io/vmDisplayName',
VM_RESERVED_MEMORY: 'harvesterhci.io/reservedMemory', VM_RESERVED_MEMORY: 'harvesterhci.io/reservedMemory',
MAINTENANCE_STATUS: 'harvesterhci.io/maintain-status', // Deprecated >= v1.10.0 MAINTENANCE_STATUS: 'harvesterhci.io/maintain-status',
HOST_CUSTOM_NAME: 'harvesterhci.io/host-custom-name', HOST_CUSTOM_NAME: 'harvesterhci.io/host-custom-name',
HOST_CONSOLE_URL: 'harvesterhci.io/host-console-url', HOST_CONSOLE_URL: 'harvesterhci.io/host-console-url',
RESTORE_NAME: 'restore.harvesterhci.io/name', RESTORE_NAME: 'restore.harvesterhci.io/name',
@@ -33,11 +32,6 @@ export const HCI = {
CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status', CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status',
MIGRATION_STATE: 'harvesterhci.io/migrationState', MIGRATION_STATE: 'harvesterhci.io/migrationState',
VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates', VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates',
// Default KubeVirt high-performance disk settings, applied when the volume is
// attached to a VM as a disk. See disks_and_volumes.md#high-performance-features.
DISK_CACHE_MODE: 'harvesterhci.io/disk-cache-mode',
DISK_IO_MODE: 'harvesterhci.io/disk-io-mode',
DISK_DEDICATED_IOTHREAD: 'harvesterhci.io/disk-dedicated-iothread',
IMAGE_NAME: 'harvesterhci.io/image-name', IMAGE_NAME: 'harvesterhci.io/image-name',
INIT_IP: 'etcd.rke2.cattle.io/node-address', INIT_IP: 'etcd.rke2.cattle.io/node-address',
NODE_SCHEDULABLE: 'kubevirt.io/schedulable', NODE_SCHEDULABLE: 'kubevirt.io/schedulable',
@@ -86,10 +80,8 @@ export const HCI = {
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes', VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass', VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address', MAC_ADDRESS: 'harvesterhci.io/mac-address',
STATIC_IP: 'static-ip.harvesterhci.io',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map', NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind', CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks', CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep', WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
}; };
+2 -10
View File
@@ -3,7 +3,6 @@ export const HCI_SETTING = {
CONTAINERD_REGISTRY: 'containerd-registry', CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level', LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version', SERVER_VERSION: 'server-version',
SERVER_FLAVOR: 'server-flavor',
UI_INDEX: 'ui-index', UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled', UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url', UPGRADE_CHECKER_URL: 'upgrade-checker-url',
@@ -45,8 +44,7 @@ export const HCI_SETTING = {
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio', MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration', KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources', INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard', CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
MAINTENANCE_MODE_DRAIN_TIMEOUT: 'maintenance-mode-drain-timeout'
}; };
export const HCI_ALLOWED_SETTINGS = { export const HCI_ALLOWED_SETTINGS = {
@@ -70,7 +68,6 @@ export const HCI_ALLOWED_SETTINGS = {
kind: 'json', from: 'import', featureFlag: 'csiOnlineExpandValidation' kind: 'json', from: 'import', featureFlag: 'csiOnlineExpandValidation'
}, },
[HCI_SETTING.SERVER_VERSION]: { readOnly: true }, [HCI_SETTING.SERVER_VERSION]: { readOnly: true },
[HCI_SETTING.SERVER_FLAVOR]: { readOnly: true },
[HCI_SETTING.UPGRADE_CHECKER_ENABLED]: { kind: 'boolean' }, [HCI_SETTING.UPGRADE_CHECKER_ENABLED]: { kind: 'boolean' },
[HCI_SETTING.UPGRADE_CHECKER_URL]: { kind: 'url' }, [HCI_SETTING.UPGRADE_CHECKER_URL]: { kind: 'url' },
[HCI_SETTING.HTTP_PROXY]: { kind: 'json', from: 'import' }, [HCI_SETTING.HTTP_PROXY]: { kind: 'json', from: 'import' },
@@ -114,17 +111,15 @@ export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES]: { kind: 'number', featureFlag: 'kubeconfigDefaultTokenTTLMinutesSetting' }, [HCI_SETTING.KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES]: { kind: 'number', featureFlag: 'kubeconfigDefaultTokenTTLMinutesSetting' },
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_ENABLED]: { [HCI_SETTING.LONGHORN_V2_DATA_ENGINE_ENABLED]: {
kind: 'boolean', kind: 'boolean',
preview: true, experimental: true,
featureFlag: 'longhornV2LVMSupport' featureFlag: 'longhornV2LVMSupport'
}, },
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: { [HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: {
kind: 'boolean', kind: 'boolean',
preview: true,
featureFlag: 'longhornV2HugepageSettings' featureFlag: 'longhornV2HugepageSettings'
}, },
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: { [HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: {
kind: 'number', kind: 'number',
preview: true,
featureFlag: 'longhornV2HugepageSettings' featureFlag: 'longhornV2HugepageSettings'
}, },
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' }, [HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
@@ -149,9 +144,6 @@ export const HCI_ALLOWED_SETTINGS = {
}, },
[HCI_SETTING.CLUSTER_POD_SECURITY_STANDARD]: { [HCI_SETTING.CLUSTER_POD_SECURITY_STANDARD]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'clusterPodSecurityStandardSetting' kind: 'json', from: 'import', canReset: true, featureFlag: 'clusterPodSecurityStandardSetting'
},
[HCI_SETTING.MAINTENANCE_MODE_DRAIN_TIMEOUT]: {
kind: 'number', canReset: true, featureFlag: 'maintenanceModeDrainTimeoutSetting'
} }
}; };
+1 -1
View File
@@ -286,7 +286,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount', labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length', value: 'spec.vms.length',
sort: 'spec.vms.length', sort: 'spec.vms.length',
align: 'center', align: 'left',
}; };
// Plan reference column in forklift.konveyor.io.migration list page // Plan reference column in forklift.konveyor.io.migration list page
-7
View File
@@ -15,13 +15,6 @@ export const VOLUME_MODE = {
FILE_SYSTEM: 'Filesystem' 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 = { export const NETWORK_PROTOCOL = {
IPv4: 'IPv4', IPv4: 'IPv4',
IPv6: 'IPv6', IPv6: 'IPv6',
@@ -15,7 +15,6 @@ import OverviewKeypairs from '../kubevirt.io.virtualmachine/VirtualMachineTabs/V
import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume'; import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume';
import Network from '../../edit/kubevirt.io.virtualmachine/VirtualMachineNetwork'; import Network from '../../edit/kubevirt.io.virtualmachine/VirtualMachineNetwork';
import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig'; import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import { getBootDevices } from '../../utils/vm';
const UNDEFINED = 'n/a'; const UNDEFINED = 'n/a';
export default { export default {
@@ -75,8 +74,18 @@ export default {
return image?.spec?.displayName || '-'; return image?.spec?.displayName || '-';
}, },
bootDevices() { disks() {
return getBootDevices(this?.spec?.template?.spec?.domain?.devices); const disks = this?.spec?.template?.spec?.domain?.devices?.disks || [];
return disks.filter((disk) => {
return !!disk.bootOrder;
}).sort((a, b) => {
if (a.bootOrder < b.bootOrder) {
return -1;
}
return 1;
});
}, },
cdroms() { cdroms() {
@@ -92,10 +101,8 @@ export default {
getDeviceType(o) { getDeviceType(o) {
if (o.disk) { if (o.disk) {
return 'Disk'; return 'Disk';
} else if (o.cdrom) {
return 'CD-ROM';
} else { } else {
return 'Network'; return 'CD-ROM';
} }
}, },
isEmpty(o) { isEmpty(o) {
@@ -177,10 +184,10 @@ export default {
<div> <div>
<ul> <ul>
<li <li
v-for="(device, i) in bootDevices" v-for="(disk, i) in disks"
:key="i" :key="i"
> >
{{ device.bootOrder }}. {{ device.name }} ({{ getDeviceType(device) }}) {{ disk.bootOrder }}. {{ disk.name }} ({{ getDeviceType(disk) }})
</li> </li>
</ul> </ul>
</div> </div>
@@ -226,7 +233,6 @@ export default {
> >
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
:network-boot-first="networkBootFirst"
:mode="mode" :mode="mode"
/> />
</Tab> </Tab>
@@ -15,7 +15,6 @@ import OverviewKeypairs from '../kubevirt.io.virtualmachine/VirtualMachineTabs/V
import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume'; import Volume from '../../edit/kubevirt.io.virtualmachine/VirtualMachineVolume';
import Network from '../../edit/kubevirt.io.virtualmachine/VirtualMachineNetwork'; import Network from '../../edit/kubevirt.io.virtualmachine/VirtualMachineNetwork';
import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig'; import CloudConfig from '../../edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import { getBootDevices } from '../../utils/vm';
const UNDEFINED = 'n/a'; const UNDEFINED = 'n/a';
export default { export default {
@@ -75,8 +74,18 @@ export default {
return image?.spec?.displayName || '-'; return image?.spec?.displayName || '-';
}, },
bootDevices() { disks() {
return getBootDevices(this?.spec?.template?.spec?.domain?.devices); const disks = this?.spec?.template?.spec?.domain?.devices?.disks || [];
return disks.filter((disk) => {
return !!disk.bootOrder;
}).sort((a, b) => {
if (a.bootOrder < b.bootOrder) {
return -1;
}
return 1;
});
}, },
cdroms() { cdroms() {
@@ -92,10 +101,8 @@ export default {
getDeviceType(o) { getDeviceType(o) {
if (o.disk) { if (o.disk) {
return 'Disk'; return 'Disk';
} else if (o.cdrom) {
return 'CD-ROM';
} else { } else {
return 'Network'; return 'CD-ROM';
} }
}, },
isEmpty(o) { isEmpty(o) {
@@ -177,10 +184,10 @@ export default {
<div> <div>
<ul> <ul>
<li <li
v-for="(device, i) in bootDevices" v-for="(disk, i) in disks"
:key="i" :key="i"
> >
{{ device.bootOrder }}. {{ device.name }} ({{ getDeviceType(device) }}) {{ disk.bootOrder }}. {{ disk.name }} ({{ getDeviceType(disk) }})
</li> </li>
</ul> </ul>
</div> </div>
@@ -227,7 +234,6 @@ export default {
> >
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
:network-boot-first="networkBootFirst"
:mode="mode" :mode="mode"
/> />
</Tab> </Tab>
+2 -3
View File
@@ -7,7 +7,6 @@ import { MiniMap } from '@vue-flow/minimap';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { NETWORK_ATTACHMENT } from '@shell/config/types'; import { NETWORK_ATTACHMENT } from '@shell/config/types';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue'; import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { HCI } from '../types'; import { HCI } from '../types';
import { NETWORK_TYPE } from '../config/types'; import { NETWORK_TYPE } from '../config/types';
@@ -455,10 +454,10 @@ export default {
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name; const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
const clusterNetwork = const clusterNetwork =
nad?.metadata?.labels?.[HCI_ANNOTATIONS.CLUSTER_NETWORK] || 'mgmt'; nad?.metadata?.labels?.[HCI.CLUSTER_NETWORK] || 'mgmt';
const nadType = const nadType =
nad?.metadata?.labels?.[HCI_ANNOTATIONS.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY; nad?.metadata?.labels?.[HCI.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
const overlayNodeId = `overlay-${ provider }`; const overlayNodeId = `overlay-${ provider }`;
nodes.push({ nodes.push({
@@ -6,7 +6,6 @@ import HarvesterIpAddress from '../../../formatters/HarvesterIpAddress';
import VMConsoleBar from '../../../components/VMConsoleBar'; import VMConsoleBar from '../../../components/VMConsoleBar';
import { HCI } from '../../../types'; import { HCI } from '../../../types';
import { getVmCPUMemoryValues } from '../../../utils/cpuMemory'; import { getVmCPUMemoryValues } from '../../../utils/cpuMemory';
import { getBootDevices } from '../../../utils/vm';
const UNDEFINED = 'n/a'; const UNDEFINED = 'n/a';
@@ -70,8 +69,18 @@ export default {
return image?.spec?.displayName || 'N/A'; return image?.spec?.displayName || 'N/A';
}, },
bootDevices() { disks() {
return getBootDevices(this.value?.spec?.template?.spec?.domain?.devices); const disks = this.value?.spec?.template?.spec?.domain?.devices?.disks || [];
return disks.filter((disk) => {
return !!disk.bootOrder;
}).sort((a, b) => {
if (a.bootOrder < b.bootOrder) {
return -1;
}
return 1;
});
}, },
cdroms() { cdroms() {
@@ -113,10 +122,8 @@ export default {
getDeviceType(o) { getDeviceType(o) {
if (o.disk) { if (o.disk) {
return 'Disk'; return 'Disk';
} else if (o.cdrom) {
return 'CD-ROM';
} else { } else {
return 'Network'; return 'CD-ROM';
} }
}, },
isEmpty(o) { isEmpty(o) {
@@ -212,16 +219,16 @@ export default {
<div class="col span-6"> <div class="col span-6">
<InputOrDisplay <InputOrDisplay
:name="t('harvester.virtualMachine.detail.details.bootOrder')" :name="t('harvester.virtualMachine.detail.details.bootOrder')"
:value="bootDevices" :value="disks"
:mode="mode" :mode="mode"
> >
<template #value> <template #value>
<ul> <ul>
<li <li
v-for="(device, i) in bootDevices" v-for="(disk, i) in disks"
:key="i" :key="i"
> >
{{ device.bootOrder }}. {{ device.name }} ({{ getDeviceType(device) }}) {{ disk.bootOrder }}. {{ disk.name }} ({{ getDeviceType(disk) }})
</li> </li>
</ul> </ul>
</template> </template>
@@ -214,15 +214,6 @@ export default {
this['diskRows'] = diskRows; this['diskRows'] = diskRows;
this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false }); this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false });
this['networkBootFirst'] = this.isNetworkBootFirst(neu.spec);
// getInitConfig() seeds these on created(); refresh them here too so the
// VM-wide performance panel does not go stale when the spec changes.
const domain = neu?.spec?.template?.spec?.domain;
this['blockMultiQueue'] = domain?.devices?.blockMultiQueue || false;
this['ioThreadsPolicy'] = domain?.ioThreadsPolicy || '';
this['ioThreadCount'] = domain?.ioThreads?.supplementalPoolThreadCount || 2;
}, },
deep: true deep: true
} }
@@ -263,9 +254,6 @@ export default {
:namespace="value.metadata.namespace" :namespace="value.metadata.namespace"
:vm="value" :vm="value"
:resource-type="value.type" :resource-type="value.type"
:block-multi-queue="blockMultiQueue"
:io-threads-policy="ioThreadsPolicy"
:io-thread-count="ioThreadCount"
/> />
</Tab> </Tab>
@@ -277,7 +265,6 @@ export default {
> >
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
:network-boot-first="networkBootFirst"
mode="view" mode="view"
:vm="value" :vm="value"
/> />
@@ -403,17 +390,14 @@ export default {
:vmi-resource="vmi" :vmi-resource="vmi"
:vmim-resource="vmim" :vmim-resource="vmim"
/> />
<div <DashboardMetrics
v-if="showVmMetrics && liveMigrationProgressEnabled" v-if="showVmMetrics && liveMigrationProgressEnabled"
class="migration-metrics-scroll mb-30" :detail-url="VM_MIGRATION_DETAIL_URL"
> graph-height="640px"
<DashboardMetrics :has-summary-and-detail="false"
:detail-url="VM_MIGRATION_DETAIL_URL" :vars="graphVars"
graph-height="960px" class="mb-30"
:has-summary-and-detail="false" />
:vars="graphVars"
/>
</div>
<Events <Events
v-if="liveMigrationProgressEnabled" v-if="liveMigrationProgressEnabled"
:resource="vmi" :resource="vmi"
@@ -452,10 +436,3 @@ export default {
</Tabbed> </Tabbed>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.migration-metrics-scroll {
max-height: 640px;
overflow-y: auto;
}
</style>
@@ -1,133 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { Checkbox } from '@components/Form/Checkbox';
import { escapeHtml } from '@shell/utils/string';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterEnableSriovGpuDevice',
emits: ['close'],
components: {
AsyncButton,
Card,
Checkbox,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return { skipMigConfiguration: this.resources[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true' };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
this.$emit('close');
},
async save(buttonCb) {
const actionResource = this.resources[0];
const prevEnabled = actionResource.spec.enabled;
const prevAnnotation = actionResource.metadata.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
try {
if (this.skipMigConfiguration) {
if (!actionResource.metadata.annotations) {
actionResource.metadata.annotations = {};
}
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = 'true';
} else {
if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
}
actionResource.spec.enabled = true;
await actionResource.save();
buttonCb(true);
this.close();
} catch (err) {
actionResource.spec.enabled = prevEnabled;
if (prevAnnotation !== undefined) {
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = prevAnnotation;
} else if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(actionResource.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.sriovgpu.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<Checkbox
v-model:value="skipMigConfiguration"
:label="t('harvester.sriovgpu.enable.skipMigConfiguration')"
/>
<p class="note mt-10">
{{ t('harvester.sriovgpu.enable.note') }}
</p>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
@click="save"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
.note {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
</style>
@@ -2,16 +2,13 @@
import { exceptionToErrorsArray } from '@shell/utils/error'; import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { mapGetters } from 'vuex'; 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 { 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 { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Checkbox } from '@components/Form/Checkbox';
export default { export default {
name: 'HotplugVolumeModal', name: 'HotplugVolumeModal',
@@ -19,7 +16,7 @@ export default {
emits: ['close'], emits: ['close'],
components: { components: {
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox AsyncButton, Card, LabeledInput, LabeledSelect, Banner
}, },
props: { props: {
@@ -31,19 +28,14 @@ export default {
async fetch() { async fetch() {
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC }); 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() { data() {
return { return {
diskName: '', diskName: '',
volumeName: '', volumeName: '',
shareable: false, errors: [],
errors: [], allPVCs: [],
allPVCs: [],
allStorageClasses: [],
allVMs: [],
}; };
}, },
@@ -58,31 +50,6 @@ export default {
return this.resources[0]; 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() { volumeOption() {
return sortBy( return sortBy(
this.PVCs this.PVCs
@@ -94,14 +61,6 @@ export default {
if (pvc.isGoldenImageVolume) { if (pvc.isGoldenImageVolume) {
return false; 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; return true;
}) })
@@ -114,62 +73,19 @@ export default {
'label' '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: { 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() { close() {
this.diskName = ''; this.diskName = '';
this.volumeName = ''; this.volumeName = '';
this.shareable = false;
this.$emit('close'); this.$emit('close');
}, },
async save(buttonCb) { async save(buttonCb) {
if (this.actionResource) { if (this.actionResource) {
try { try {
const input = { volumeSourceName: this.volumeName, diskName: this.diskName }; const res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false);
if (this.isShareableCapable && this.shareable) {
input.shareable = true;
}
const res = await this.actionResource.doAction('addVolume', input, {}, false);
if (res._status === 200 || res._status === 204) { if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', { this.$store.dispatch('growl/success', {
@@ -225,19 +141,6 @@ export default {
class="mt-20" class="mt-20"
required 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 <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :key="i"
+3 -26
View File
@@ -5,8 +5,6 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default { export default {
name: 'HarvesterBackupModal', name: 'HarvesterBackupModal',
@@ -17,7 +15,6 @@ export default {
AsyncButton, AsyncButton,
Card, Card,
LabeledInput, LabeledInput,
LabeledSelect,
Banner Banner
}, },
@@ -30,9 +27,8 @@ export default {
data() { data() {
return { return {
backUpName: '', backUpName: '',
errors: [], errors: []
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
}; };
}, },
@@ -41,22 +37,12 @@ export default {
actionResource() { actionResource() {
return this.resources[0]; return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
} }
}, },
methods: { methods: {
close() { close() {
this.backUpName = ''; this.backUpName = '';
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.errors = [];
this.$emit('close'); this.$emit('close');
}, },
@@ -65,7 +51,7 @@ export default {
try { try {
const res = await this.actionResource.doAction( const res = await this.actionResource.doAction(
'backup', 'backup',
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline }, { name: this.backUpName },
{}, {},
false false
); );
@@ -114,18 +100,9 @@ export default {
<template #body> <template #body>
<LabeledInput <LabeledInput
v-model:value="backUpName" v-model:value="backUpName"
class="mt-20"
:label="t('generic.name')" :label="t('generic.name')"
required required
/> />
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.backup.fileSystemFreezeDeadline.label')"
required
/>
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :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>
@@ -5,10 +5,8 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI } from '../types'; import { HCI } from '../types';
import { BACKUP_TYPE } from '../config/types'; import { BACKUP_TYPE } from '../config/types';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default { export default {
name: 'HarvesterVMSnapshotDialog', name: 'HarvesterVMSnapshotDialog',
@@ -19,7 +17,6 @@ export default {
AsyncButton, AsyncButton,
Card, Card,
LabeledInput, LabeledInput,
LabeledSelect,
Banner Banner
}, },
@@ -34,8 +31,7 @@ export default {
return { return {
snapshotName: '', snapshotName: '',
snapshotNamespace: '', snapshotNamespace: '',
errors: [], errors: []
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
}; };
}, },
@@ -44,23 +40,13 @@ export default {
actionResource() { actionResource() {
return this.resources[0]; return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
} }
}, },
methods: { methods: {
close() { close() {
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.snapshotNamespace = ''; this.snapshotNamespace = '';
this.snapshotName = ''; this.snapshotName = '';
this.errors = [];
this.$emit('close'); this.$emit('close');
}, },
@@ -74,8 +60,7 @@ export default {
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource) ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
}, },
spec: { spec: {
fsFreezeDeadline: this.fsFreezeDeadline, source: {
source: {
apiGroup: 'kubevirt.io', apiGroup: 'kubevirt.io',
kind: 'VirtualMachine', kind: 'VirtualMachine',
name: this.actionResource.metadata.name name: this.actionResource.metadata.name
@@ -147,14 +132,6 @@ export default {
:label="t('generic.name')" :label="t('generic.name')"
required required
/> />
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.vmSnapshot.fileSystemFreezeDeadline.label')"
required
/>
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :key="i"
@@ -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 YamlEditor from '@shell/components/YamlEditor';
import CreateEditView from '@shell/mixins/create-edit-view'; import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { HCI } from '@pkg/harvester/config/labels-annotations'; import { HCI } from '@pkg/harvester/config/labels-annotations';
export default { export default {
@@ -24,19 +23,14 @@ export default {
NameNsDescription, NameNsDescription,
}, },
mixins: [CreateEditView, FormValidation], mixins: [CreateEditView],
inheritAttrs: false, inheritAttrs: false,
data() { data() {
return { return {
config: this.value.data?.cloudInit || '', config: this.value.data?.cloudInit || '',
type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user', type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user',
fvFormRuleSets: [{
path: 'metadata.name',
rules: ['required'],
translationKey: 'nameNsDescription.name.label',
}],
}; };
}, },
@@ -84,7 +78,6 @@ export default {
:resource="value" :resource="value"
:errors="errors" :errors="errors"
:apply-hooks="applyHooks" :apply-hooks="applyHooks"
:validation-passed="fvFormIsValid"
@finish="save" @finish="save"
@cancel="done" @cancel="done"
@error="e=>errors=e" @error="e=>errors=e"
@@ -93,7 +86,6 @@ export default {
:value="value" :value="value"
:mode="mode" :mode="mode"
:namespaced="true" :namespaced="true"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)" @update:value="$emit('update:value', $event)"
/> />
@@ -468,15 +468,10 @@ export default {
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX }); const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
const parentDevice = d.status?.deviceStatus?.parentDevice; const parentDevice = d.status?.deviceStatus?.parentDevice;
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath); const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
const mountPoint = d.status?.deviceStatus?.fileSystem?.mountPoint;
const name = d.displayName; const name = d.displayName;
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`; let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
if (mountPoint) {
label = `${ label } - ${ this.t('harvester.host.disk.mounted') }`;
}
if (parentDevice) { if (parentDevice) {
label = `- ${ label }`; label = `- ${ label }`;
} }
@@ -486,7 +481,7 @@ export default {
value: d.id, value: d.id,
action: this.addDisk, action: this.addDisk,
kind: !parentDevice ? 'group' : '', kind: !parentDevice ? 'group' : '',
disabled: !!isChildAdded || !!mountPoint, disabled: !!isChildAdded,
group: parentDevice || devPath, group: parentDevice || devPath,
isParent: !!parentDevice, isParent: !!parentDevice,
}; };
@@ -1,5 +1,5 @@
<script> <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 { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList'; import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';
@@ -10,10 +10,6 @@ import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params'; 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 { export default {
name: 'CDISettings', name: 'CDISettings',
@@ -94,10 +90,10 @@ export default {
accessModeOptions() { accessModeOptions() {
return [ return [
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE }, { label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY }, { label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY }, { label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD }, { label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
]; ];
}, },
@@ -7,12 +7,8 @@ import { allHash } from '@shell/utils/promise';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
import { HCI } from '../../../types'; import { HCI } from '../../../types';
import { NODE } from '@shell/config/types'; import { NODE } from '@shell/config/types';
import { _CREATE, _CLONE } from '@shell/config/query-params';
import { LVM_TOPOLOGY_LABEL } from '../index.vue'; import { LVM_TOPOLOGY_LABEL } from '../index.vue';
const STRIPED_TYPE = 'striped';
const DM_THIN_TYPE = 'dm-thin';
const DEFAULT_PARAMETERS = [ const DEFAULT_PARAMETERS = [
'type', 'type',
'vgName' 'vgName'
@@ -58,17 +54,8 @@ export default {
data() { data() {
const node = (this.value.allowedTopologies?.[0]?.matchLabelExpressions || []).find((t) => t.key === LVM_TOPOLOGY_LABEL)?.values[0]; 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 { return {
volumeGroupTypes, volumeGroupTypes: ['striped', 'dm-thin'],
node, node,
}; };
}, },
@@ -308,9 +308,6 @@ export default {
> >
<Volume <Volume
v-model:value="diskRows" v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode" :mode="mode"
:namespace="value.metadata.namespace" :namespace="value.metadata.namespace"
:existing-volume-disabled="true" :existing-volume-disabled="true"
@@ -324,7 +321,6 @@ export default {
> >
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
v-model:network-boot-first="networkBootFirst"
:mode="mode" :mode="mode"
/> />
</Tab> </Tab>
+7 -47
View File
@@ -8,7 +8,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription'; import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions'; import Conditions from '@shell/components/form/Conditions';
import DiskPerformanceOptions from './kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';
import jsyaml from 'js-yaml'; import jsyaml from 'js-yaml';
@@ -27,11 +26,9 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim'; import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit'; 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'; import { isInternalStorageClass } from '../utils/storage-class';
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
export default { export default {
name: 'HarvesterVolume', name: 'HarvesterVolume',
@@ -48,8 +45,7 @@ export default {
LabeledSelect, LabeledSelect,
LabeledInput, LabeledInput,
NameNsDescription, NameNsDescription,
Conditions, Conditions
DiskPerformanceOptions
}, },
mixins: [CreateEditView], mixins: [CreateEditView],
@@ -87,7 +83,7 @@ export default {
if (this.mode === _CREATE) { if (this.mode === _CREATE) {
// default volumeMode to Block // default volumeMode to Block
this.value.spec.volumeMode = VOLUME_MODE.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; const storage = this.value?.spec?.resources?.requests?.storage || null;
@@ -102,15 +98,7 @@ export default {
createWithDataVolume: false, createWithDataVolume: false,
snapshots: [], snapshots: [],
images: [], images: [],
GIBIBYTE, GIBIBYTE
// Default KubeVirt high-performance disk profile, stored as annotations and
// applied when this volume is later attached to a VM as a disk.
perf: {
cache: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_CACHE_MODE }"`) || '',
io: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_IO_MODE }"`) || '',
dedicatedIOThread: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD }"`) === 'true',
volumeMode: this.value?.spec?.volumeMode,
},
}; };
}, },
@@ -172,7 +160,7 @@ export default {
}, },
accessModeOptions() { accessModeOptions() {
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY]; return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
}, },
imageOption() { imageOption() {
@@ -342,7 +330,6 @@ export default {
} }
}, },
'value.spec.volumeMode'(neu) { 'value.spec.volumeMode'(neu) {
this.perf.volumeMode = neu;
if (neu === VOLUME_MODE.FILE_SYSTEM) { if (neu === VOLUME_MODE.FILE_SYSTEM) {
this.setVolumeForVmAnnotation(); this.setVolumeForVmAnnotation();
} else if (neu === VOLUME_MODE.BLOCK ) { } else if (neu === VOLUME_MODE.BLOCK ) {
@@ -368,7 +355,7 @@ export default {
getAccessMode() { getAccessMode() {
if (!this.longhornV2LVMSupport) { if (!this.longhornV2LVMSupport) {
return [READ_WRITE_MANY]; return ['ReadWriteMany'];
} }
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) { if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@@ -384,7 +371,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2); 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() { buildDataVolumeObj() {
const storage = { const storage = {
@@ -457,23 +444,6 @@ export default {
imageAnnotations = { ...this.value.metadata.annotations }; imageAnnotations = { ...this.value.metadata.annotations };
} }
// Persist the default high-performance disk profile as annotations.
if (this.perf.cache) {
imageAnnotations[HCI_ANNOTATIONS.DISK_CACHE_MODE] = this.perf.cache;
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_CACHE_MODE];
}
if (this.perf.io) {
imageAnnotations[HCI_ANNOTATIONS.DISK_IO_MODE] = this.perf.io;
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_IO_MODE];
}
if (this.perf.dedicatedIOThread) {
imageAnnotations[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] = 'true';
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD];
}
const spec = { const spec = {
...this.value.spec, ...this.value.spec,
resources: { requests: { storage: this.storage } }, resources: { requests: { storage: this.storage } },
@@ -638,16 +608,6 @@ export default {
class="mb-20" class="mb-20"
@update:value="update" @update:value="update"
/> />
<Banner
color="info"
:label="t('harvester.volume.performance.pvcTip')"
/>
<DiskPerformanceOptions
:value="perf"
:mode="mode"
@update="update"
/>
</Tab> </Tab>
<Tab <Tab
v-if="!isCreate" v-if="!isCreate"
+3 -14
View File
@@ -9,7 +9,7 @@ import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view'; import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio'; import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types'; 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 ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types'; import { HCI } from '../../types';
@@ -142,12 +142,10 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || []; const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
const options = vlans.map((vlan) => ({ return vlans.map((vlan) => ({
label: vlan.id, label: vlan.id,
value: vlan.id, value: vlan.id,
})); }));
return [{ label: this.t('generic.none'), value: '' }, ...options];
} }
}, },
@@ -162,14 +160,6 @@ export default {
} }
}, },
methods: { methods: {
onVlanChange(value) {
if (value === '') {
remove(this.value.spec, 'vlan');
} else {
set(this.value, 'spec.vlan', value);
}
},
async saveSubnet(buttonCb) { async saveSubnet(buttonCb) {
const errors = []; const errors = [];
const name = this.value?.metadata?.name; const name = this.value?.metadata?.name;
@@ -293,13 +283,12 @@ export default {
</div> </div>
<div class="col span-6"> <div class="col span-6">
<LabeledSelect <LabeledSelect
:value="value.spec.vlan ?? ''" v-model:value="value.spec.vlan"
class="mb-20" class="mb-20"
:options="vlanOptions" :options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')" :placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')" :label="t('harvester.subnet.vlan.label')"
:mode="mode" :mode="mode"
@update:value="onVlanChange"
/> />
</div> </div>
</div> </div>
@@ -68,6 +68,12 @@ export default {
}, },
watch: { watch: {
osType(neu) {
if (neu === 'windows') {
this.id = '';
}
},
value(neu) { value(neu) {
this.yamlScript = neu; this.yamlScript = neu;
}, },
@@ -2,7 +2,6 @@
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Checkbox } from '@components/Form/Checkbox';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
import { _CREATE, _VIEW } from '@shell/config/query-params'; import { _CREATE, _VIEW } from '@shell/config/query-params';
@@ -34,7 +33,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
LabeledInput, LabeledSelect, InputOrDisplay, Checkbox LabeledInput, LabeledSelect, InputOrDisplay
}, },
props: { props: {
@@ -134,10 +133,6 @@ export default {
}]; }];
return this.isMasquerade ? masquerade : other; return this.isMasquerade ? masquerade : other;
},
staticIPForVMEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('staticIPForVM');
} }
}, },
@@ -155,7 +150,6 @@ export default {
isSingle(neu) { isSingle(neu) {
if (!neu) { if (!neu) {
this.value['macAddress'] = ''; this.value['macAddress'] = '';
this.value['staticIp'] = '';
this.update(); this.update();
} }
} }
@@ -169,8 +163,6 @@ export default {
if (neu === MANAGEMENT_NETWORK) { if (neu === MANAGEMENT_NETWORK) {
this.value.isPod = true; this.value.isPod = true;
this.value.macAddress = ''; this.value.macAddress = '';
this.value.staticIp = '';
this.value.networkBoot = false;
} else { } else {
this.value.isPod = false; this.value.isPod = false;
} }
@@ -287,22 +279,6 @@ export default {
</div> </div>
</div> </div>
<div
v-if="!isMasquerade"
data-testid="input-hen-networkBoot"
class="row mb-20"
>
<div class="col span-12">
<Checkbox
v-model:value="value.networkBoot"
:label="t('harvester.virtualMachine.network.networkBoot.label')"
:tooltip="t('harvester.virtualMachine.network.networkBoot.tip')"
:mode="mode"
@update:value="update"
/>
</div>
</div>
<div v-if="!isMasquerade && isSingle"> <div v-if="!isMasquerade && isSingle">
<div class="row mb-20"> <div class="row mb-20">
<a <a
@@ -313,30 +289,9 @@ export default {
</a> </a>
</div> </div>
<div <div class="row">
v-if="showAdvanced"
class="row"
>
<div
v-if="staticIPForVMEnabled"
data-testid="input-hen-staticIp"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.staticIp')"
:value="value.staticIp"
:mode="mode"
>
<LabeledInput
v-model:value="value.staticIp"
label-key="harvester.fields.staticIp"
:mode="mode"
:tooltip="t('harvester.virtualMachine.volume.staticIpTip', { nic_name: value.name || 'default' })"
@update:value="update"
/>
</InputOrDisplay>
</div>
<div <div
v-if="showAdvanced"
data-testid="input-hen-macAddress" data-testid="input-hen-macAddress"
class="col span-6" class="col span-6"
> >
@@ -1,6 +1,5 @@
<script> <script>
import InfoBox from '@shell/components/InfoBox'; import InfoBox from '@shell/components/InfoBox';
import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_ATTACHMENT } from '@shell/config/types'; import { NETWORK_ATTACHMENT } from '@shell/config/types';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
@@ -11,11 +10,7 @@ import { HCI as HCI_ANNOTATIONS } from '../../../config/labels-annotations';
import Base from './base'; import Base from './base';
export default { export default {
components: { components: { InfoBox, Base },
InfoBox, Base, RadioGroup
},
emits: ['update:value', 'update:networkBootFirst'],
props: { props: {
vm: { vm: {
@@ -40,11 +35,6 @@ export default {
isSingle: { isSingle: {
type: Boolean, type: Boolean,
default: true default: true
},
networkBootFirst: {
type: Boolean,
default: true
} }
}, },
@@ -92,20 +82,6 @@ export default {
vmState() { vmState() {
return this.vm?.stateDisplay; return this.vm?.stateDisplay;
},
hasNetworkBoot() {
return this.rows.some((R) => R.networkBoot);
},
networkBootOrderOptions() {
return [{
label: this.t('harvester.virtualMachine.network.networkBoot.first'),
value: true
}, {
label: this.t('harvester.virtualMachine.network.networkBoot.last'),
value: false
}];
} }
}, },
@@ -242,22 +218,6 @@ export default {
/> />
</InfoBox> </InfoBox>
<div
v-if="hasNetworkBoot"
data-testid="input-hen-networkBootFirst"
class="mb-20"
>
<RadioGroup
:value="networkBootFirst"
name="networkBootFirst"
:label="t('harvester.virtualMachine.network.networkBoot.order')"
:options="networkBootOrderOptions"
:mode="mode"
:row="true"
@update:value="$emit('update:networkBootFirst', $event)"
/>
</div>
<button <button
v-if="!isView" v-if="!isView"
type="button" type="button"
@@ -246,27 +246,4 @@ export default {
.group-actions { .group-actions {
display: inline; 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> </style>
@@ -5,7 +5,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import Banner from '@components/Banner/Banner.vue'; import Banner from '@components/Banner/Banner.vue';
import remove from 'lodash/remove'; import remove from 'lodash/remove';
import { set } from '@shell/utils/object'; import { set } from '@shell/utils/object';
import { uniq } from '@shell/utils/array';
import { HCI } from '../../../types'; import { HCI } from '../../../types';
import DeviceList from './DeviceList'; import DeviceList from './DeviceList';
import CompatibilityMatrix from '../CompatibilityMatrix'; import CompatibilityMatrix from '../CompatibilityMatrix';
@@ -61,15 +60,7 @@ export default {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name); row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
}); });
// When off, read from spec; otherwise the spec name is a placeholder ('provisioned'), vmDevices.forEach(({ name }) => {
// 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) => {
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) { if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
selectedDevices.push(name); selectedDevices.push(name);
} }
@@ -1,321 +0,0 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import { _VIEW } from '@shell/config/query-params';
import { DISK_CACHE_MODE, DISK_IO_MODE, DISK_PERFORMANCE_PROFILE } from '../../../config/harvester-map';
const { DEFAULT, HIGH, CUSTOM } = DISK_PERFORMANCE_PROFILE;
export default {
name: 'DiskPerformanceOptions',
components: {
LabeledSelect, Checkbox, Banner
},
emits: ['update'],
props: {
// The disk row. This component reads/writes value.cache, value.io,
// value.dedicatedIOThread and (for the "High Performance" preset) value.bus.
value: {
type: Object,
default: () => ({})
},
mode: {
type: String,
default: 'create'
},
},
data() {
const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread);
return {
DISK_PERFORMANCE_PROFILE,
expanded: hasPerf,
profile: this.detectProfile(),
// Bus the disk used before the "High Performance" preset switched it to
// virtio, so returning to "Default" can put it back.
busBeforeHighProfile: null,
};
},
computed: {
isView() {
return this.mode === _VIEW;
},
storagePerformanceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage');
},
isCdRom() {
return this.value.type === 'cd-rom';
},
isCustom() {
return this.profile === CUSTOM;
},
isNativeIo() {
return this.value.io === 'native';
},
profileOptions() {
return [DEFAULT, HIGH, CUSTOM].map((value) => ({
label: this.t(`harvester.virtualMachine.volume.performance.profile.${ value }`),
value,
}));
},
cacheOptions() {
return DISK_CACHE_MODE.map((value) => ({
label: this.cacheLabel(value),
value,
// Native AIO only works with an uncached (O_DIRECT) disk, so while it is
// selected the only valid cache option is "none" — disable everything
// else, including "Default", to prevent an invalid combination.
disabled: this.isNativeIo && value !== 'none',
}));
},
ioOptions() {
return DISK_IO_MODE.map((value) => ({
label: value === '' ? this.t('harvester.virtualMachine.volume.performance.ioMode.default') : this.t(`harvester.virtualMachine.volume.performance.ioMode.${ value }`),
value,
}));
},
showFilesystemCacheWarning() {
return this.value.cache === 'none' && this.value.volumeMode === 'Filesystem';
},
showBusTip() {
const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread);
return hasPerf && this.value.bus && this.value.bus !== 'virtio';
},
},
watch: {
// Keep the profile selector in sync if the row is repopulated (e.g. editing an existing VM).
'value.cache'() {
this.profile = this.detectProfile();
},
'value.io'() {
this.profile = this.detectProfile();
},
'value.dedicatedIOThread'() {
this.profile = this.detectProfile();
},
},
methods: {
detectProfile() {
const { cache, io, dedicatedIOThread } = this.value;
if (!cache && !io && !dedicatedIOThread) {
return DEFAULT;
}
if (cache === 'none' && io === 'native' && dedicatedIOThread) {
return HIGH;
}
return CUSTOM;
},
cacheLabel(value) {
if (value === '') {
return this.t('harvester.virtualMachine.volume.performance.cacheMode.default');
}
return this.t(`harvester.virtualMachine.volume.performance.cacheMode.${ value }`);
},
onProfileChange(profile) {
const previous = this.profile;
this.profile = profile;
if (profile === DEFAULT) {
this.value.cache = '';
this.value.io = '';
this.value.dedicatedIOThread = false;
// "Default" means no overrides, so also undo the virtio bus that the
// "High Performance" preset applied. Skip it if the user has since
// picked a different bus themselves — that choice is theirs to keep.
if (this.busBeforeHighProfile !== null) {
if (this.value.bus === 'virtio') {
this.value.bus = this.busBeforeHighProfile;
}
this.busBeforeHighProfile = null;
}
} else if (profile === HIGH) {
this.value.cache = 'none';
this.value.io = 'native';
this.value.dedicatedIOThread = true;
if (previous !== HIGH) {
this.busBeforeHighProfile = this.value.bus || '';
}
this.value.bus = 'virtio';
}
// CUSTOM keeps whatever is currently set, including the bus.
this.update();
},
onIoChange(io) {
this.value.io = io;
// Native AIO requires an uncached disk; force cache to "none" to keep the
// combo valid — including when cache is still on its "Default" ('') value,
// which libvirt would otherwise reject at domain start.
if (io === 'native' && this.value.cache !== 'none') {
this.value.cache = 'none';
}
this.update();
},
update() {
this.$emit('update');
},
},
};
</script>
<template>
<div
v-if="!isCdRom && storagePerformanceEnabled"
class="disk-performance"
>
<button
v-if="!isView"
type="button"
class="btn btn-sm role-link expand-toggle"
:aria-expanded="expanded"
:aria-label="expanded ? t('harvester.virtualMachine.volume.performance.collapse') : t('harvester.virtualMachine.volume.performance.expand')"
@click.prevent="expanded = !expanded"
>
<i
class="icon"
:class="expanded ? 'icon-chevron-down' : 'icon-chevron-right'"
/>
{{ t('harvester.virtualMachine.volume.performance.title') }}
</button>
<div
v-if="expanded || isView"
class="perf-body mt-10"
>
<div class="row mb-20">
<div
class="col span-6"
data-testid="input-disk-perf-profile"
>
<LabeledSelect
:value="profile"
:label="t('harvester.virtualMachine.volume.performance.profile.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.profile.tip')"
:options="profileOptions"
:mode="mode"
@update:value="onProfileChange"
/>
</div>
</div>
<Banner
v-if="profile === DISK_PERFORMANCE_PROFILE.HIGH"
color="info"
:label="t('harvester.virtualMachine.volume.performance.highProfileTip')"
/>
<div
v-if="isCustom || isView"
class="row mb-20"
>
<div
class="col span-6"
data-testid="input-disk-cache"
>
<LabeledSelect
v-model:value="value.cache"
:label="t('harvester.virtualMachine.volume.performance.cacheMode.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.cacheMode.tip')"
:options="cacheOptions"
:mode="mode"
@update:value="update"
/>
</div>
<div
class="col span-6"
data-testid="input-disk-io"
>
<LabeledSelect
:value="value.io"
:label="t('harvester.virtualMachine.volume.performance.ioMode.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.ioMode.tip')"
:options="ioOptions"
:mode="mode"
@update:value="onIoChange"
/>
</div>
</div>
<div
v-if="isCustom || isView"
class="row mb-10"
>
<div
class="col span-12"
data-testid="input-disk-dedicated-iothread"
>
<Checkbox
v-model:value="value.dedicatedIOThread"
:label="t('harvester.virtualMachine.volume.performance.dedicatedIOThread.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.dedicatedIOThread.tip')"
:mode="mode"
@update:value="update"
/>
</div>
</div>
<Banner
v-if="isNativeIo"
color="info"
:label="t('harvester.virtualMachine.volume.performance.ioMode.nativeRequiresNoCacheTip')"
/>
<Banner
v-if="showFilesystemCacheWarning"
color="warning"
:label="t('harvester.virtualMachine.volume.performance.cacheMode.filesystemWarning')"
/>
<Banner
v-if="showBusTip"
color="warning"
:label="t('harvester.virtualMachine.volume.performance.busTip')"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.disk-performance {
border-top: 1px solid var(--border);
margin-top: 10px;
padding-top: 10px;
.expand-toggle {
padding: 0;
font-weight: 600;
.icon {
margin-right: 4px;
}
}
}
</style>
@@ -1,191 +0,0 @@
<script>
import InfoBox from '@shell/components/InfoBox';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import { _VIEW } from '@shell/config/query-params';
import { IO_THREADS_POLICY } from '../../../config/harvester-map';
export default {
name: 'VMPerformanceOptions',
components: {
InfoBox, LabeledSelect, LabeledInput, Checkbox, Banner
},
emits: ['update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'],
props: {
blockMultiQueue: {
type: Boolean,
default: false
},
ioThreadsPolicy: {
type: String,
default: ''
},
ioThreadCount: {
type: Number,
default: 2
},
mode: {
type: String,
default: 'create'
},
// Whether the current disk set contains a virtio disk (VM context only).
hasVirtioDisk: {
type: Boolean,
default: true
},
// Whether any disk requests a dedicated I/O thread (drives the auto-enable hint).
hasDedicatedIothread: {
type: Boolean,
default: false
},
},
data() {
// These are specialist tuning options, so keep the panel collapsed unless the
// VM already has something configured.
return { expanded: !!(this.blockMultiQueue || this.ioThreadsPolicy) };
},
computed: {
isView() {
return this.mode === _VIEW;
},
storagePerformanceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage');
},
ioThreadsPolicyOptions() {
return IO_THREADS_POLICY.map((value) => ({
label: value === '' ? this.t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.default') : this.t(`harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.${ value }`),
value,
}));
},
},
watch: {
// Block Multi-Queue only applies to virtio disks. If the last virtio disk is
// removed while it is enabled, clear it so the VM is not left with an invalid
// setting the disabled checkbox can no longer surface.
hasVirtioDisk(neu) {
if (!neu && this.blockMultiQueue) {
this.$emit('update:blockMultiQueue', false);
}
},
},
};
</script>
<template>
<InfoBox
v-if="storagePerformanceEnabled && (!isView || blockMultiQueue || !!ioThreadsPolicy)"
class="vm-perf mt-10"
>
<button
v-if="!isView"
type="button"
class="btn btn-sm role-link expand-toggle"
:aria-expanded="expanded"
:aria-label="expanded ? t('harvester.virtualMachine.volume.diskPerformance.collapse') : t('harvester.virtualMachine.volume.diskPerformance.expand')"
@click.prevent="expanded = !expanded"
>
<i
class="icon"
:class="expanded ? 'icon-chevron-down' : 'icon-chevron-right'"
/>
{{ t('harvester.virtualMachine.volume.diskPerformance.title') }}
</button>
<h3
v-else
class="mb-5"
>
{{ t('harvester.virtualMachine.volume.diskPerformance.title') }}
</h3>
<template v-if="expanded || isView">
<p class="text-muted mb-15 mt-10">
{{ t('harvester.virtualMachine.volume.diskPerformance.description') }}
</p>
<div class="row mb-10">
<div
class="col span-12"
data-testid="input-vm-block-multi-queue"
>
<Checkbox
:value="blockMultiQueue"
:label="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.tip')"
:mode="mode"
:disabled="!hasVirtioDisk"
@update:value="$emit('update:blockMultiQueue', $event)"
/>
</div>
</div>
<Banner
v-if="!hasVirtioDisk && !isView"
color="info"
:label="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.noVirtioTip')"
/>
<div class="row mb-10">
<div
class="col span-6"
data-testid="input-vm-iothreads-policy"
>
<LabeledSelect
:value="ioThreadsPolicy"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.tip')"
:options="ioThreadsPolicyOptions"
:mode="mode"
@update:value="$emit('update:ioThreadsPolicy', $event)"
/>
</div>
<div
v-if="ioThreadsPolicy === 'supplementalPool'"
class="col span-6"
data-testid="input-vm-iothread-count"
>
<LabeledInput
type="number"
min="1"
:value="ioThreadCount"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadCount.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.ioThreadCount.tip')"
:mode="mode"
@update:value="$emit('update:ioThreadCount', Number($event))"
/>
</div>
</div>
<Banner
v-if="hasDedicatedIothread && !ioThreadsPolicy"
color="info"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.autoEnabledTip')"
/>
</template>
</InfoBox>
</template>
<style lang="scss" scoped>
.vm-perf {
.expand-toggle {
padding: 0;
font-weight: 600;
.icon {
margin-right: 4px;
}
}
}
</style>
@@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import DiskPerformanceOptions from '../DiskPerformanceOptions.vue';
const mountOptions = (value, { featureEnabled = true } = {}) => ({
props: { value, mode: _EDIT },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => featureEnabled,
'i18n/t': (key) => key,
'i18n/exists': () => true,
}
},
t: (key) => key,
},
}
});
const hardDisk = (overrides = {}) => ({
type: 'disk', bus: 'virtio', cache: '', io: '', dedicatedIOThread: false, ...overrides
});
describe('component: DiskPerformanceOptions', () => {
it('is hidden when the highPerformanceStorage feature is disabled', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk(), { featureEnabled: false }));
expect(wrapper.find('.disk-performance').exists()).toBe(false);
});
it('renders for a hard disk when the feature is enabled', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk()));
expect(wrapper.find('.disk-performance').exists()).toBe(true);
});
it('applies the High Performance preset and emits update', () => {
const value = hardDisk();
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
expect(value.cache).toBe('none');
expect(value.io).toBe('native');
expect(value.dedicatedIOThread).toBe(true);
expect(value.bus).toBe('virtio');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('clears all overrides when switching back to Default', () => {
const value = hardDisk({
cache: 'none', io: 'native', dedicatedIOThread: true
});
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('default');
expect(value.cache).toBe('');
expect(value.io).toBe('');
expect(value.dedicatedIOThread).toBe(false);
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('restores the original bus when leaving High Performance for Default', () => {
const value = hardDisk({ bus: 'sata' });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
expect(value.bus).toBe('virtio');
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('sata');
});
it('keeps a bus the user picked themselves after High Performance', () => {
const value = hardDisk({ bus: 'sata' });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
value.bus = 'scsi';
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('scsi');
});
it('leaves the bus alone for a disk that never used the High Performance preset', () => {
const value = hardDisk({
bus: 'sata', cache: 'writeback', io: 'threads'
});
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('sata');
});
it.each([
['a cached state', 'writeback'],
['the Default cache', ''],
])('forces cache to none when Native I/O is selected from %s', (_label, cache) => {
const value = hardDisk({ cache });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onIoChange('native');
expect(value.io).toBe('native');
expect(value.cache).toBe('none');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('disables every cache option except none while Native I/O is active', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ io: 'native' })));
const disabledByValue = wrapper.vm.cacheOptions.reduce((acc, opt) => {
acc[opt.value] = opt.disabled;
return acc;
}, {});
expect(disabledByValue['none']).toBe(false);
expect(disabledByValue['']).toBe(true);
expect(disabledByValue['writeback']).toBe(true);
expect(disabledByValue['writethrough']).toBe(true);
});
});
@@ -7,27 +7,24 @@ import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import ModalWithCard from '@shell/components/ModalWithCard'; import ModalWithCard from '@shell/components/ModalWithCard';
import VMPerformanceOptions from './VMPerformanceOptions';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PVC, STORAGE_CLASS } from '@shell/config/types'; import { PVC, STORAGE_CLASS } from '@shell/config/types';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
import { ucFirst, randomStr } from '@shell/utils/string'; import { ucFirst, randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array'; import { removeObject } from '@shell/utils/array';
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params'; import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs'; 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 { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types'; 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 { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm'; import { EMPTY_IMAGE } from '../../../utils/vm';
const { READ_WRITE_MANY } = ACCESS_MODE;
export default { export default {
emits: ['update:value', 'update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'], emits: ['update:value'],
components: { components: {
Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard, VMPerformanceOptions Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard
}, },
props: { props: {
@@ -72,28 +69,12 @@ export default {
customAccessMode: { customAccessMode: {
type: String, type: String,
default: READ_WRITE_MANY default: 'ReadWriteMany'
}, },
resourceType: { resourceType: {
type: String, type: String,
default: '' default: ''
},
// KubeVirt high-performance VM-level features
blockMultiQueue: {
type: Boolean,
default: false
},
ioThreadsPolicy: {
type: String,
default: ''
},
ioThreadCount: {
type: Number,
default: 2
} }
}, },
@@ -164,14 +145,6 @@ export default {
isLHV2VolExpansionFeatureEnabled() { isLHV2VolExpansionFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion'); return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
}, },
hasVirtioDisk() {
return this.rows.some((R) => R.type === 'disk' && R.bus === 'virtio');
},
hasDedicatedIOThread() {
return this.rows.some((R) => R.dedicatedIOThread);
},
}, },
watch: { watch: {
@@ -533,18 +506,6 @@ export default {
</button> </button>
</div> </div>
<VMPerformanceOptions
:block-multi-queue="blockMultiQueue"
:io-threads-policy="ioThreadsPolicy"
:io-thread-count="ioThreadCount"
:has-virtio-disk="hasVirtioDisk"
:has-dedicated-iothread="hasDedicatedIOThread"
:mode="mode"
@update:block-multi-queue="$emit('update:blockMultiQueue', $event)"
@update:io-threads-policy="$emit('update:ioThreadsPolicy', $event)"
@update:io-thread-count="$emit('update:ioThreadCount', $event)"
/>
<ModalWithCard <ModalWithCard
v-if="isOpen" v-if="isOpen"
name="deleteTip" name="deleteTip"
@@ -4,7 +4,6 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default { export default {
name: 'HarvesterEditContainer', name: 'HarvesterEditContainer',
@@ -12,7 +11,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
LabeledInput, LabeledSelect, InputOrDisplay, Banner, DiskPerformanceOptions LabeledInput, LabeledSelect, InputOrDisplay, Banner
}, },
props: { props: {
@@ -154,11 +153,5 @@ export default {
class="mb-20" class="mb-20"
:label="value.volumeBackups.error.message" :label="value.volumeBackups.error.message"
/> />
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div> </div>
</template> </template>
@@ -5,16 +5,13 @@ import LabelValue from '@shell/components/LabelValue';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import { sortBy } from '@shell/utils/sort'; 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 { _CREATE } from '@shell/config/query-params';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI } from '../../../../types'; import { HCI } from '../../../../types';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { GIBIBYTE } from '../../../../utils/unit'; import { GIBIBYTE } from '../../../../utils/unit';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default { export default {
name: 'HarvesterEditExisting', name: 'HarvesterEditExisting',
@@ -22,7 +19,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox, DiskPerformanceOptions UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
}, },
props: { props: {
@@ -97,30 +94,6 @@ export default {
return this.allPVCs.find( (P) => P.metadata.name === this.value.volumeName ); 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() { volumeOption() {
return sortBy( return sortBy(
this.allPVCs this.allPVCs
@@ -172,15 +145,6 @@ export default {
this.value.size = pvcResource.spec.resources.requests.storage; this.value.size = pvcResource.spec.resources.requests.storage;
this.value.storageClassName = pvcResource.spec.storageClassName; this.value.storageClassName = pvcResource.spec.storageClassName;
this.value.volumeMode = pvcResource.spec.volumeMode; this.value.volumeMode = pvcResource.spec.volumeMode;
this.value.shareable = false;
// Adopt the volume's default high-performance profile (if any) as a starting point.
const ann = pvcResource.metadata?.annotations || {};
this.value.cache = ann[HCI_ANNOTATIONS.DISK_CACHE_MODE] || '';
this.value.io = ann[HCI_ANNOTATIONS.DISK_IO_MODE] || '';
this.value.dedicatedIOThread = ann[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] === 'true';
this.update(); this.update();
}, },
@@ -191,13 +155,6 @@ export default {
} }
}, },
isShareableCapable(neu) {
if (!neu && this.value.shareable) {
this.value.shareable = false;
this.update();
}
},
pvcResource: { pvcResource: {
handler(pvc) { handler(pvc) {
if (!this.value.volumeName && pvc?.metadata?.name) { if (!this.value.volumeName && pvc?.metadata?.name) {
@@ -346,26 +303,6 @@ export default {
/> />
</InputOrDisplay> </InputOrDisplay>
</div> </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 <div
v-if="value.volumeBackups" v-if="value.volumeBackups"
class="col span-6" class="col span-6"
@@ -388,11 +325,5 @@ export default {
class="mb-20" class="mb-20"
:label="value.volumeBackups.error.message" :label="value.volumeBackups.error.message"
/> />
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div> </div>
</template> </template>
@@ -15,7 +15,6 @@ import LabelValue from '@shell/components/LabelValue';
import { ucFirst } from '@shell/utils/string'; import { ucFirst } from '@shell/utils/string';
import { GIBIBYTE } from '../../../../utils/unit'; import { GIBIBYTE } from '../../../../utils/unit';
import { EMPTY_IMAGE } from '../../../../utils/vm'; import { EMPTY_IMAGE } from '../../../../utils/vm';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default { export default {
name: 'HarvesterEditVMImage', name: 'HarvesterEditVMImage',
@@ -23,7 +22,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, DiskPerformanceOptions UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
}, },
props: { props: {
@@ -483,11 +482,5 @@ export default {
color="error" color="error"
:label="t('harvester.virtualMachine.volume.vmImageVolumeTip', {diskSize: diskSize, imageVirtualSize: imageVirtualSize})" :label="t('harvester.virtualMachine.volume.vmImageVolumeTip', {diskSize: diskSize, imageVirtualSize: imageVirtualSize})"
/> />
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div> </div>
</template> </template>
@@ -9,15 +9,12 @@ import { formatSi, parseSi } from '@shell/utils/units';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { _VIEW } from '@shell/config/query-params'; import { _VIEW } from '@shell/config/query-params';
import LabelValue from '@shell/components/LabelValue'; import LabelValue from '@shell/components/LabelValue';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
import { ucFirst } from '@shell/utils/string'; import { ucFirst } from '@shell/utils/string';
import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim'; import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../../../../utils/unit'; import { GIBIBYTE } from '../../../../utils/unit';
import { isInternalStorageClass } from '../../../../utils/storage-class'; import { isInternalStorageClass } from '../../../../utils/storage-class';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types'; import { VOLUME_MODE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
export default { export default {
name: 'HarvesterEditVolume', name: 'HarvesterEditVolume',
@@ -25,7 +22,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue, DiskPerformanceOptions InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue
}, },
props: { props: {
@@ -208,7 +205,7 @@ export default {
getAccessMode(storageClassName) { getAccessMode(storageClassName) {
if (!this.longhornV2LVMSupport) { if (!this.longhornV2LVMSupport) {
return READ_WRITE_MANY; return 'ReadWriteMany';
} }
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName); const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
@@ -219,7 +216,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2); 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() { update() {
@@ -363,11 +360,6 @@ export default {
</InputOrDisplay> </InputOrDisplay>
</div> </div>
</div> </div>
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
<div class="row mb-20"> <div class="row mb-20">
<div <div
v-if="value.volumeEncryptionFeatureEnabled && isView" v-if="value.volumeEncryptionFeatureEnabled && isView"
@@ -93,9 +93,6 @@ export default {
const hostname = this.value.spec.template.spec.hostname || ''; const hostname = this.value.spec.template.spec.hostname || '';
// Display name can contain arbitrary strings. There is no XSS risk because the value is
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
// this field. See harvester/harvester#10423 for details.
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]); const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return { return {
@@ -487,20 +484,6 @@ export default {
await value.save(); await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS); await this.applyHooks(AFTER_SAVE_HOOKS);
} catch (e) { } catch (e) {
// Reset the generated secret name(s) so a retried create (e.g. after correcting an
// invalid/duplicate VM name) always regenerates them from the current name instead of
// reusing names derived from this failed attempt. Only do this for create: in edit mode
// secretName/sysprep.secretName may reference an existing secret loaded from the VM,
// which must not be discarded. See harvester/harvester#11174.
if (this.isCreate) {
this.secretName = '';
this.secretNamePrefixUsed = '';
if (this.sysprepSecretNamePrefixUsed !== '') {
this.sysprep.secretName = '';
this.sysprepSecretNamePrefixUsed = '';
}
}
this.errors.push(...exceptionToErrorsArray(e)); this.errors.push(...exceptionToErrorsArray(e));
buttonCb(false); buttonCb(false);
} }
@@ -772,9 +755,6 @@ export default {
> >
<Volume <Volume
v-model:value="diskRows" v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode" :mode="mode"
:custom-volume-mode="customVolumeMode" :custom-volume-mode="customVolumeMode"
:namespace="value.metadata.namespace" :namespace="value.metadata.namespace"
@@ -791,7 +771,6 @@ export default {
> >
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
v-model:network-boot-first="networkBootFirst"
:mode="mode" :mode="mode"
:is-single="isSingle" :is-single="isSingle"
/> />
@@ -1066,6 +1045,7 @@ export default {
</div> </div>
<CloudConfig <CloudConfig
v-if="!isWindows"
ref="yamlEditor" ref="yamlEditor"
:user-script="userScript" :user-script="userScript"
:mode="mode" :mode="mode"
@@ -63,7 +63,6 @@ export default {
RANCHER_TYPES, RANCHER_TYPES,
fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }], fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }],
canEditPSPBindings: true, canEditPSPBindings: true,
isQuotasValid: true,
}; };
}, },
computed: { computed: {
@@ -191,13 +190,15 @@ export default {
this['membershipUpdate'] = update; this['membershipUpdate'] = update;
}, },
validateQuotas(isValid) { removeQuota(key) {
this.isQuotasValid = isValid; ['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => {
}, if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) {
delete this.value?.spec[specProp]?.limit[key];
onQuotasInput({ projectLimit, nsLimit } = {}) { }
this.value.spec.resourceQuota = { ...this.value.spec.resourceQuota, limit: projectLimit }; if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) {
this.value.spec.namespaceDefaultResourceQuota = { ...this.value.spec.namespaceDefaultResourceQuota, limit: nsLimit }; delete this.value?.spec[specProp]?.usedLimit[key];
}
});
} }
}, },
}; };
@@ -211,7 +212,7 @@ export default {
:resource="value" :resource="value"
:subtypes="[]" :subtypes="[]"
:can-yaml="false" :can-yaml="false"
:validation-passed="fvFormIsValid && isQuotasValid" :validation-passed="fvFormIsValid"
@error="e=>errors=e" @error="e=>errors=e"
@finish="save" @finish="save"
@cancel="done" @cancel="done"
@@ -266,11 +267,10 @@ export default {
:weight="9" :weight="9"
> >
<ResourceQuota <ResourceQuota
:value="value" v-model:value="value"
:mode="canEditTabElements" :mode="canEditTabElements"
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES" :types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
@input="onQuotasInput" @remove="removeQuota"
@validationChanged="validateQuotas"
/> />
</Tab> </Tab>
<Tab <Tab
@@ -283,9 +283,9 @@ export default {
v-model:value.number="vlanID" v-model:value.number="vlanID"
type="number" type="number"
required required
:min="1" :min="2"
:max="4094" :max="4094"
placeholder="e.g. 1 ~ 4094" placeholder="e.g. 2 ~ 4094"
:label="t('harvester.hostNetworkConfig.vlanID.label')" :label="t('harvester.hostNetworkConfig.vlanID.label')"
:mode="mode" :mode="mode"
/> />
+33 -68
View File
@@ -1,11 +1,11 @@
<script> <script>
import PercentageBar from '@shell/components/PercentageBar'; import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { METRIC, NODE } from '@shell/config/types'; import { METRIC, NODE } from '@shell/config/types';
import { parseSi } from '@shell/utils/units'; import { parseSi } from '@shell/utils/units';
export default { export default {
name: 'HarvesterCpuUsed', name: 'HarvesterCpuUsed',
components: { PercentageBar }, components: { ConsumptionGauge },
props: { props: {
value: { value: {
@@ -74,89 +74,54 @@ export default {
return 0; return 0;
} }
}, },
}
reservedPercentage() {
return this.cpuTotal ? (this.reserved * 100) / this.cpuTotal : 0;
},
usedPercentage() {
return this.cpuTotal ? (this.used * 100) / this.cpuTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.numberFormatter(this.reserved || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.numberFormatter(this.used || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
},
methods: {
numberFormatter(value) {
return Number.isInteger(value) ? value : value.toFixed(2);
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<div class="consumption-gauge"> <ConsumptionGauge
<div class="numbers"> :capacity="cpuTotal"
<span class="mr-5"> :used="reserved"
:units="cpuUnits"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }} {{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span> </span>
</div> </template>
<div class="mt-10"> </ConsumptionGauge>
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
<div <div
v-if="showUsed" v-if="showUsed"
class="consumption-gauge mt-10" class="mt-10"
> >
<div class="numbers"> <ConsumptionGauge
<span> :capacity="cpuTotal"
{{ t('harvester.formatters.hardwareResourceGauge.used') }} :used="used"
</span> :units="cpuUnits"
<span class="precent-data"> >
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }} <template #title="{amountTemplateValues, formattedPercentage}">
</span> <span>
</div> {{ t('harvester.formatters.hardwareResourceGauge.used') }}
<div class="mt-10"> </span>
<PercentageBar <span class="precent-data">
:model-value="usedPercentage" {{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
show-percentage <span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
/> </span>
</div> </span>
</template>
</ConsumptionGauge>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }
@@ -36,10 +36,6 @@ export default {
computed: { computed: {
ips() { ips() {
if (this.staticAnnotationIP.length) {
return this.staticAnnotationIP;
}
if (this.vmiIp.length) { if (this.vmiIp.length) {
return [...this.vmiIp, ...this.networkAnnotationIP] return [...this.vmiIp, ...this.networkAnnotationIP]
.filter(Boolean) .filter(Boolean)
@@ -49,24 +45,6 @@ export default {
return this.customAnnotationIP; return this.customAnnotationIP;
}, },
staticAnnotationIP() {
const annotations = this.row?.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
return Object.entries(annotations)
.filter(([key, value]) => key.startsWith(staticIpPrefix) && !!value)
.map(([key, value]) => {
const nicName = key.slice(staticIpPrefix.length);
const ip = String(value).replace(/\/[\d\D]*/, '');
return {
ip,
name: nicName
};
})
.filter(({ ip }) => isIpv4(ip));
},
customAnnotationIP() { customAnnotationIP() {
const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`); const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`);
@@ -132,28 +110,16 @@ export default {
</script> </script>
<template> <template>
<div <div v-if="showIP">
v-if="showIP" <span
class="ip-list"
>
<div
v-for="{ ip, name, isCustom } in ips" v-for="{ ip, name, isCustom } in ips"
:key="`${ip}-${name}`" :key="`${ip}-${name}`"
class="ip-item"
> >
<CopyToClipboardText <CopyToClipboardText
v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name" v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name"
:text="ip" :text="ip"
:plain="isCustom" :plain="isCustom"
/> />
</div> </span>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.ip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
@@ -1,11 +1,11 @@
<script> <script>
import PercentageBar from '@shell/components/PercentageBar'; import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { METRIC, NODE } from '@shell/config/types'; import { METRIC, NODE } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units'; import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit'; import { UNIT_SUFFIX } from '../utils/unit';
export default { export default {
name: 'HarvesterMemoryUsed', name: 'HarvesterMemoryUsed',
components: { PercentageBar }, components: { ConsumptionGauge },
props: { props: {
value: { value: {
@@ -76,30 +76,6 @@ export default {
return 0; return 0;
} }
}, },
reservedPercentage() {
return this.memoryTotal ? (this.reserved * 100) / this.memoryTotal : 0;
},
usedPercentage() {
return this.memoryTotal ? (this.used * 100) / this.memoryTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.reserved || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.used || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
}, },
methods: { methods: {
@@ -120,53 +96,50 @@ export default {
<template> <template>
<div> <div>
<div class="consumption-gauge"> <ConsumptionGauge
<div class="numbers"> :capacity="memoryTotal"
<span class="mr-10"> :used="reserved"
:units="memoryUnits"
:number-formatter="memoryFormatter"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }} {{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span> </span>
</div> </template>
<div class="mt-10"> </ConsumptionGauge>
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
<div <div
v-if="showUsed" v-if="showUsed"
class="consumption-gauge mt-10" class="mt-10"
> >
<div class="numbers"> <ConsumptionGauge
<span> :capacity="memoryTotal"
{{ t('harvester.formatters.hardwareResourceGauge.used') }} :used="used"
</span> :units="memoryUnits"
<span class="precent-data"> :number-formatter="memoryFormatter"
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }} >
</span> <template #title="{amountTemplateValues, formattedPercentage}">
</div> <span>
<div class="mt-10"> {{ t('harvester.formatters.hardwareResourceGauge.used') }}
<PercentageBar </span>
:model-value="usedPercentage" <span class="precent-data">
show-percentage {{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
/> <span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</div> </span>
</span>
</template>
</ConsumptionGauge>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }
@@ -1,12 +1,12 @@
<script> <script>
import PercentageBar from '@shell/components/PercentageBar'; import ConsumptionGauge from '@shell/components/ConsumptionGauge';
import { LONGHORN } from '@shell/config/types'; import { LONGHORN } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units'; import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit'; import { UNIT_SUFFIX } from '../utils/unit';
export default { export default {
name: 'HarvesterStorageUsed', name: 'HarvesterStorageUsed',
components: { PercentageBar }, components: { ConsumptionGauge },
props: { props: {
value: { value: {
@@ -120,14 +120,6 @@ export default {
unit: this.allocatedUnits, unit: this.allocatedUnits,
}; };
}, },
usedPercentage() {
return this.storageStats.maximum ? (this.storageStats.used * 100) / this.storageStats.maximum : 0;
},
allocatedPercentage() {
return this.storageStats.total ? (this.storageStats.scheduled * 100) / this.storageStats.total : 0;
},
}, },
methods: { methods: {
@@ -152,54 +144,53 @@ export default {
<div> <div>
<div <div
v-if="showAllocated" v-if="showAllocated"
class="consumption-gauge"
> >
<div class="numbers"> <ConsumptionGauge
<span class="mr-10"> :capacity="storageStats.total"
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }} :used="storageStats.scheduled"
</span> :units="allocatedUnits"
<span class="precent-data"> :number-formatter="formatter"
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }} :resource-name="resourceName"
</span> >
</div> <template #title="{formattedPercentage}">
<div class="mt-10"> <span>
<PercentageBar {{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
:model-value="allocatedPercentage" </span>
show-percentage <span class="precent-data">
/> {{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
</div> <span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div> </div>
<div <ConsumptionGauge
class="consumption-gauge" :capacity="storageStats.maximum"
:class="{ 'mt-10': showAllocated }" :used="storageStats.used"
:units="usedUnits"
:number-formatter="formatter"
:resource-name="showAllocated ? '' : resourceName"
:class="{
'mt-10': showAllocated,
}"
> >
<div class="numbers"> <template #title="{formattedPercentage}">
<span> <span>
{{ t('node.detail.glance.consumptionGauge.used') }} {{ t('node.detail.glance.consumptionGauge.used') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span> </span>
</div> </template>
<div class="mt-10"> </ConsumptionGauge>
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }
+18 -172
View File
@@ -13,15 +13,6 @@ generic:
basic: Basic basic: Basic
loading: Loading... loading: Loading...
none: None none: None
duration:
infinite: Infinite
1s: 1 second
5s: 5 seconds
10s: 10 seconds
30s: 30 seconds
1m: 1 minute
3m: 3 minutes
5m: 5 minutes
unsupported: unsupported:
serverVersion: 'Current version: <code>{serverVersion}</code>' serverVersion: 'Current version: <code>{serverVersion}</code>'
@@ -36,8 +27,7 @@ nav:
Logging: Logging Logging: Logging
'Monitoring and Logging': Monitoring and Logging 'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports vmimport: Virtual Machine Imports
vmMigration: Virtual Machine Migration vmMigration: VM Migration
Configurations: Configurations
resourceTable: resourceTable:
groupBy: groupBy:
@@ -84,8 +74,6 @@ dialog:
harvester: harvester:
branding: branding:
primeLabel: '{vendor}'
primeEmptyLabel: ''
logos: 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.' 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: favicon:
@@ -95,8 +83,6 @@ harvester:
backup: backup:
success: 'Backup { backUpName } has been initiated.' success: 'Backup { backUpName } has been initiated.'
addBackup: Add Backup addBackup: Add Backup
fileSystemFreezeDeadline:
label: 'File System Freeze Deadline'
quota: quota:
editVMQuota: Edit VM Quota editVMQuota: Edit VM Quota
editQuota: Edit Quota editQuota: Edit Quota
@@ -236,8 +222,6 @@ harvester:
title: Take Virtual Machine Snapshot title: Take Virtual Machine Snapshot
name: Name name: Name
success: 'Take virtual machine Snapshot { name } successfully.' success: 'Take virtual machine Snapshot { name } successfully.'
fileSystemFreezeDeadline:
label: 'File System Freeze Deadline'
restart: restart:
title: Restart Virtual Machine title: Restart Virtual Machine
tip: Restart the virtual machine for configuration changes to take effect. tip: Restart the virtual machine for configuration changes to take effect.
@@ -286,7 +270,6 @@ harvester:
createTemplate: Generate Template createTemplate: Generate Template
enableMaintenance: Enable Maintenance Mode enableMaintenance: Enable Maintenance Mode
disableMaintenance: Disable Maintenance Mode disableMaintenance: Disable Maintenance Mode
clearMaintenance: Clear Maintenance Mode Error
enableCPUManager: Enable CPU Manager enableCPUManager: Enable CPU Manager
disableCPUManager: Disable CPU Manager disableCPUManager: Disable CPU Manager
cordon: Cordon cordon: Cordon
@@ -334,8 +317,7 @@ harvester:
message: Message message: Message
phase: Phase phase: Phase
attachedVM: Attached Virtual Machine attachedVM: Attached Virtual Machine
cpuManager: CPU<br>Manager cpuManager: CPU Manager
diskState: Disk
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to. routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
fingerprint: Fingerprint fingerprint: Fingerprint
value: Value value: Value
@@ -383,7 +365,7 @@ harvester:
vmMigrationMapSourceProvider: Source Provider vmMigrationMapSourceProvider: Source Provider
vmMigrationMapDestProvider: Destination Provider vmMigrationMapDestProvider: Destination Provider
vmMigrationPlanTargetNs: Target Namespace vmMigrationPlanTargetNs: Target Namespace
vmMigrationPlanVmCount: VM Count vmMigrationPlanVmCount: VMs
vmMigrationMigrationPlan: Plan vmMigrationMigrationPlan: Plan
tab: tab:
volume: Volumes volume: Volumes
@@ -409,7 +391,6 @@ harvester:
network: Network network: Network
model: Model model: Model
macAddress: MAC address macAddress: MAC address
staticIp: Static IP
port: Port port: Port
protocol: Protocol protocol: Protocol
remove: Remove remove: Remove
@@ -491,6 +472,7 @@ harvester:
volume: volume:
upperType: Volume name upperType: Volume name
lowerType: volume name lowerType: volume name
needAtLeastOneBootable: 'At least one bootable volume is required!'
image: image:
ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.' ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.' ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
@@ -644,7 +626,6 @@ harvester:
notification: notification:
success: 'Update host "{name}" storage configurations successfully.' success: 'Update host "{name}" storage configurations successfully.'
error: Host has unready or unschedulable disks. error: Host has unready or unschedulable disks.
mounted: Already Mounted
fileSystem: fileSystem:
info: Current file system is {system}, You can format it manually. info: Current file system is {system}, You can format it manually.
formatting: Disk is formatting, please wait. formatting: Disk is formatting, please wait.
@@ -784,7 +765,6 @@ harvester:
dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order. dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order.
volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes. volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes.
macTip: "MAC address as seen inside the guest system." macTip: "MAC address as seen inside the guest system."
staticIpTip: "Static IP that will be stored to annotation static-ip.harvesterhci.io/{nic_name}."
volumeUpdate: 'Set volume { name } successfully' volumeUpdate: 'Set volume { name } successfully'
type: Type type: Type
size: Size size: Size
@@ -801,60 +781,6 @@ harvester:
setFirst: Set as root volume setFirst: Set as root volume
saveVolume: Update Volume saveVolume: Update Volume
encryption: Encryption encryption: Encryption
performance:
title: Storage Performance Options
expand: Show high-performance options
collapse: Hide high-performance options
profile:
label: Performance Profile
tip: "\"High Performance\" applies a tuned preset (no host cache, native AIO, a dedicated I/O thread on the virtio bus). Choose \"Custom\" to set each option yourself."
default: Default (Balanced)
highPerformance: High Performance
custom: Custom
highProfileTip: This disk will use the virtio bus with host cache disabled, native AIO and a dedicated I/O thread.
cacheMode:
label: Cache Mode
tip: "How the host caches disk I/O. \"None\" (host cache disabled) is best for high-throughput guests on block storage; \"WriteBack\" favours performance; \"WriteThrough\" favours durability."
default: Default (hypervisor)
none: None (no host cache)
writeback: WriteBack
writethrough: WriteThrough
filesystemWarning: "\"None\" (O_DIRECT) requires storage that supports direct I/O. It is recommended only for Block volume mode; on Filesystem volumes the VM may fail to start."
ioMode:
label: I/O Mode
tip: "How QEMU submits I/O. \"Native\" (Linux AIO) offers the lowest overhead but requires host cache set to \"None\"; \"Threads\" uses a host thread pool."
default: Default (hypervisor)
native: Native (AIO)
threads: Threads
nativeRequiresNoCacheTip: Native I/O mode requires cache mode "None"; it has been set for you.
dedicatedIOThread:
label: Dedicated I/O Thread
tip: Allocate an exclusive libvirt I/O thread to this disk instead of sharing one. Useful for disks with heavy I/O. Enabling this on any disk turns on the VM's I/O threads policy.
busTip: High-performance features work best on the virtio bus.
diskPerformance:
title: High Performance (I/O Threads and Multi-Queue)
description: These settings apply to all volumes in the Virtual Machine and complement the per-volume options above.
expand: Show VM-wide high-performance options
collapse: Hide VM-wide high-performance options
blockMultiQueue:
label: Virtio Block Multi-Queue
tip: Map disk I/O to multiple queues so it is processed across multiple CPUs. Applies to virtio disks and requires a fixed CPU allocation (Harvester always sets one).
noVirtioTip: Add at least one disk on the virtio bus to enable Block Multi-Queue.
ioThreadsPolicy:
label: I/O Threads Policy
tip: "How libvirt allocates I/O threads. \"Shared\" uses one thread for all disks; \"Auto\" pools threads round-robin (up to ~2x vCPUs); \"Supplemental Pool\" lets you pick a fixed thread count and adds matching CPUs to the pod."
default: "Disabled (default)"
shared: Shared
auto: Auto
supplementalPool: Supplemental Pool
autoEnabledTip: A disk requests a dedicated I/O thread, so the policy is enabled (defaults to "Shared").
ioThreadCount:
label: I/O Thread Count
tip: Number of I/O threads for the supplemental pool. 4-8 is a common range depending on workload.
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}) vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
lockTooltip: lockTooltip:
all: All volumes are encrypted. all: All volumes are encrypted.
@@ -872,12 +798,6 @@ harvester:
title: Network title: Network
addNetwork: Add Network addNetwork: Add Network
addPort: Add Port addPort: Add Port
networkBoot:
label: Enable network boot (PXE)
tip: Adds this interface to the VM boot order so the guest firmware can boot from the network. Requires a DHCP/TFTP (PXE) server reachable on the selected network; the management network does not provide one.
order: Network boot order
first: Before volumes
last: After volumes
cloudConfig: cloudConfig:
title: Cloud Configuration title: Cloud Configuration
createTemplateTitle: 'Create {name}' createTemplateTitle: 'Create {name}'
@@ -894,13 +814,13 @@ harvester:
title: "Network Data:" 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>" 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: 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>" 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... createNew: Create new...
createNewTitle: Create Windows Unattended & Sysprep Template createNewTitle: Create Windows Sysprep Template
xmlContent: autounattend.xml content xmlContent: autounattend.xml content
secret: secret:
label: Windows Unattended & Sysprep Template label: Windows Sysprep Template
validation: validation:
invalidXml: Invalid XML syntax invalidXml: Invalid XML syntax
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend' invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
@@ -1054,8 +974,6 @@ harvester:
createWithDataVolumeTooltip: Create Volume with Kubevirt/Containerized Data Importer way. It can fill accessMode/volumeMode automatically. createWithDataVolumeTooltip: Create Volume with Kubevirt/Containerized Data Importer way. It can fill accessMode/volumeMode automatically.
showAdvanced: Show Advanced Options showAdvanced: Show Advanced Options
hideAdvanced: Hide Advanced Options hideAdvanced: Hide Advanced Options
performance:
pvcTip: These high-performance options are saved as defaults on the volume and applied when it is attached to a virtual machine as a disk.
source: Source source: Source
kind: Kind kind: Kind
sourceOptions: sourceOptions:
@@ -1164,8 +1082,6 @@ harvester:
downloadLog: Download Log downloadLog: Download Log
logStatus: Log Download Status logStatus: Log Download Status
dismissMessage: Dismiss it dismissMessage: Dismiss it
upgradeAvailable: Upgrade Available
upgradeUnavailable: Upgrade Unavailable
upgradeInfo: upgradeInfo:
warning: WARNING warning: WARNING
doc: Read the <a href="{url}" target="_blank">documentation</a> before starting the upgrade process. Ensure that you complete procedures that are relevant to your environment and the version you are upgrading to. doc: Read the <a href="{url}" target="_blank">documentation</a> before starting the upgrade process. Ensure that you complete procedures that are relevant to your environment and the version you are upgrading to.
@@ -1893,8 +1809,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-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-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. '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-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-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. 'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
@@ -1905,7 +1819,6 @@ harvester:
unknown: Unknown unknown: Unknown
identifier: Identifier identifier: Identifier
podNetwork: Pod Network podNetwork: Pod Network
ignored: Ignored
usedBy: "Used by:" usedBy: "Used by:"
vlan: "VLAN {id}" vlan: "VLAN {id}"
vCpu: "{count} vCPU" vCpu: "{count} vCPU"
@@ -1926,7 +1839,7 @@ harvester:
connectionFailed: Connection failed connectionFailed: Connection failed
providerNotReady: Provider not ready providerNotReady: Provider not ready
labels: labels:
dashboard: Migration Overview dashboard: Migrations
provider: Providers provider: Providers
networkMap: Network Maps networkMap: Network Maps
storageMap: Storage Maps storageMap: Storage Maps
@@ -1960,14 +1873,11 @@ harvester:
providerSelect: Provider providerSelect: Provider
createNew: Create new provider createNew: Create new provider
requirementsTitle: Requirements requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Harvester Node." requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher."
name: Name name: Name
urlLabel: vCenter/ESXi URL urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk" urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https:// urlHint: Enter the full URL including https://
vddkImageLabel: VMware VDDK Image
vddkImagePlaceholder: "[your-private-registry]/vddk-image:latest"
vddkImageTooltip: Specify the VMware VDDK (Virtual Disk Development Kit) Image to accelerate and optimize VMware virtual machine migration.
skipSsl: Skip SSL certificate verification skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments skipSslHint: Not recommended for production environments
testConnection: testConnection:
@@ -1989,7 +1899,6 @@ harvester:
refreshNow: Refresh now refreshNow: Refresh now
availableVms: Available Virtual Machines availableVms: Available Virtual Machines
selected: selected selected: selected
clearAll: Clear All
saveSelection: Save Selection and Continue saveSelection: Save Selection and Continue
selectAllBanner: selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page." pageOnly: "Your current selection includes only VMs currently shown on the page."
@@ -2012,8 +1921,8 @@ harvester:
network: Network network: Network
datastore: Datastore datastore: Datastore
dashboard: dashboard:
title: Virtual Machine Migration Overview title: VMware to Harvester Migration
description: "Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester." description: Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester.
createPlan: Create Migration createPlan: Create Migration
tableTitle: Migrations tableTitle: Migrations
columns: columns:
@@ -2021,85 +1930,48 @@ harvester:
plan: Migration Plan plan: Migration Plan
progress: Progress progress: Progress
mappings: Mappings mappings: Mappings
duration: Duration
progress: progress:
failed: Failed failed: Failed
migration: Migration migration: Migration
initializingMigration: Initializing migration initializingMigration: Initializing migration
finishedSuccessfully: Finished Successfully finishedSuccessfully: Finished Successfully
step: "Step {index}" step: "Step {index}"
vmCount: "{count, plural, one {# VM} other {# VMs}}" vmCount: "{count} VMs"
vmId: "id: {id}" vmId: "id: {id}"
checkMigratedVm: Click to check migrated VM details
configureMappings: configureMappings:
title: Set Mappings title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue save: Save Mappings and Continue
noTemplate: Start from scratch noTemplate: Start from scratch
removeMap: Remove mapping removeMap: Remove Map
networkMapping: networkMapping:
title: Network Mapping title: Network Mapping
description: Map VMware port groups to Harvester networks description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network placeholder: Choose a Harvester network
template: Use existing network mapping as template template: Use existing network mapping as template
options: options:
podNetworking: Management Network podNetworking: Pod Networking
ignored: Ignored (No NIC will be created) ignored: Ignored
storageMapping: storageMapping:
title: Storage Mapping title: Storage Mapping
description: Map VMware datastores to Harvester storage classes description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Harvester Storage Class placeholder: Choose a Longhorn Storage Volume
template: Use existing storage mapping as template 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: reviewMigration:
title: Review Migration Plan title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster. description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name planName: Name
planNamePlaceholder: "e.g. my-migration-plan" planNamePlaceholder: "e.g. my-migration-plan"
planNameProviderConflict: 'The name “{name}” is already used by an existing provider. Please input a different plan name.'
planNamePlanConflict: 'The name “{name}” is already used by an existing migration plan. Please input a different plan name.'
migrationDetails: Migration Details migrationDetails: Migration Details
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs totalVms: Total VMs
vcpu: vCPU vcpu: vCPU
memory: Memory memory: Memory
storage: Storage storage: Storage
source: Source source: Source
provider: Provider targetNamespace: Target Namespace
targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace"
migrationMode: Migration Mode migrationMode: Migration Mode
coldMigration: Cold Migration coldMigration: Cold Migration
coldMigrationDescription: All source VMs will be powered off during migration.
coldMigrationHint: Plan a maintenance window and notify relevant stakeholders before starting.
virtualMachines: Virtual Machines virtualMachines: Virtual Machines
editSelection: Edit selection
noVmSelected: "No VMs selected. Return to "
noVmSelectedLink: "Step 2 to select virtual machines."
columns:
vmName: VM Name
os: Operating System
resources: Resources
powerState: Power State
networkMapping: Network Mapping
storageMapping: Storage Mapping
sourceTarget: "Source \u2192 Target"
actions:
menuLabel: Actions
editMappings: Edit Mappings
removeFromPlan: Remove from Plan
warningTitle: "Cold Migration Warning" warningTitle: "Cold Migration Warning"
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders" warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
startMigration: Start Migration Plan startMigration: Start Migration Plan
@@ -2113,8 +1985,6 @@ harvester:
start: Start start: Start
restart: Restart restart: Restart
stop: Stop stop: Stop
deleteDialog:
warning: Deleting this migration plan will also delete the following related resources
vmImport: vmImport:
titles: titles:
@@ -2199,14 +2069,6 @@ harvester:
hostname: Hostname hostname: Hostname
rancherVersion: Rancher Version rancherVersion: Rancher Version
password: Bootstrap Password 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!" switchFailed: "{action} {name} failed!"
nvidiaDriverToolkit: nvidiaDriverToolkit:
titles: titles:
@@ -2372,12 +2234,6 @@ harvester:
prefix: The nvidia-driver-toolkit add-on is not enabled, click prefix: The nvidia-driver-toolkit add-on is not enabled, click
middle: here middle: here
suffix: to enable it to manage your SR-IOV GPU devices. suffix: to enable it to manage your SR-IOV GPU devices.
enable:
title: Enable SR-IOV GPU Device
skipMigConfiguration: Use timesliced mode (skip MIG configuration)
note: If unchecked, MIG mode will be attempted by default. If MIG mode is not supported or fails to initialize, it will fall back to timesliced mode. The actual mode depends on whether the GPU supports MIG.
timeslicedBadge: timesliced mode
timesliced: Timesliced
migconfiguration: migconfiguration:
label: vGPU MIG Configurations label: vGPU MIG Configurations
@@ -2488,15 +2344,7 @@ typeDescription:
harvester: "Branding allows administrators to globally re-brand the UI by customizing the Harvester product name, logos and color scheme." harvester: "Branding allows administrators to globally re-brand the UI by customizing the Harvester product name, logos and color scheme."
advancedSettings: advancedSettings:
tags: experimental: 'Experimental features allow users to test and evaluate early-access functionality prior to official supported releases'
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.'
descriptions: descriptions:
'harv-vlan': Default Network Interface name of the VLAN network. 'harv-vlan': Default Network Interface name of the VLAN network.
'harv-backup-target': Custom backup target to store virtual machine backups. 'harv-backup-target': Custom backup target to store virtual machine backups.
@@ -2506,7 +2354,6 @@ advancedSettings:
'harv-containerd-registry': Containerd Registry Configuration to connect private registries. 'harv-containerd-registry': Containerd Registry Configuration to connect private registries.
'harv-log-level': Configure Harvester server log level. Defaults to Info. 'harv-log-level': Configure Harvester server log level. Defaults to Info.
'harv-server-version': Harvester server version. '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-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-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. '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.
@@ -2542,7 +2389,6 @@ advancedSettings:
'harv-kubevirt-migration': 'Configure cluster-wide KubeVirt live migration parameters.' 'harv-kubevirt-migration': 'Configure cluster-wide KubeVirt live migration parameters.'
'harv-instance-manager-resources': 'Configure resource percentage reservations for Longhorn instance manager V1 and V2. Valid instance manager CPU range between 0 - 40.' 'harv-instance-manager-resources': 'Configure resource percentage reservations for Longhorn instance manager V1 and V2. Valid instance manager CPU range between 0 - 40.'
'harv-cluster-pod-security-standard': 'Enforce Kubernetes Pod Security Standards (PSS) at the cluster level.' 'harv-cluster-pod-security-standard': 'Enforce Kubernetes Pod Security Standards (PSS) at the cluster level.'
'harv-maintenance-mode-drain-timeout': 'Maintenance mode drain timeout in minutes. Use 0 to disable the timeout.'
typeLabel: typeLabel:
kubevirt.io.virtualmachine: |- kubevirt.io.virtualmachine: |-
@@ -86,13 +86,6 @@ export default {
value: 'spec.address', value: 'spec.address',
sort: ['spec.address'] sort: ['spec.address']
}, },
{
name: 'timesliced',
labelKey: 'harvester.sriovgpu.timesliced',
value: 'isTimesliced',
formatter: 'Checked',
align: 'center',
},
{ {
name: 'vfAddresses', name: 'vfAddresses',
label: 'VF Addresses', label: 'VF Addresses',
@@ -53,6 +53,7 @@ export default {
devices() { devices() {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || []; const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
data.forEach((row) => { data.forEach((row) => {
+1 -1
View File
@@ -72,7 +72,7 @@ export default {
/> />
<a <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')" v-tooltip="t('harvester.addons.rancherVcluster.accessRancher')"
class="ml-5" class="ml-5"
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
+4 -13
View File
@@ -84,7 +84,7 @@ export default {
}, },
{ {
...NAME, ...NAME,
width: 150, width: 130,
}, },
{ {
name: 'host-ip', name: 'host-ip',
@@ -136,14 +136,14 @@ export default {
value: 'id', value: 'id',
formatter: 'HarvesterCPUPinning', formatter: 'HarvesterCPUPinning',
formatterOpts: { rows: this.rows }, formatterOpts: { rows: this.rows },
width: 75, width: 150,
align: 'center', align: 'center',
}); });
} }
if (this.hasLonghornSchema) { if (this.hasLonghornSchema) {
out.push({ out.push({
name: 'diskState', name: 'diskState',
labelKey: 'harvester.tableHeaders.diskState', labelKey: 'tableHeaders.diskState',
value: 'diskState', value: 'diskState',
formatter: 'HarvesterDiskState', formatter: 'HarvesterDiskState',
width: 130, width: 130,
@@ -156,6 +156,7 @@ export default {
name: 'console', name: 'console',
label: ' ', label: ' ',
align: 'right', align: 'right',
width: 80,
}); });
return out; return out;
@@ -238,8 +239,6 @@ export default {
v-if="!row.consoleUrl" v-if="!row.consoleUrl"
:href="consoleDocLink" :href="consoleDocLink"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="console-info"
><i class="icon icon-info" /></a> ><i class="icon icon-info" /></a>
</div> </div>
</template> </template>
@@ -250,13 +249,5 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.console-button { .console-button {
display: flex; display: flex;
position: relative;
justify-content: flex-end;
}
.console-info {
position: absolute;
top: -8px;
right: -3px;
} }
</style> </style>
@@ -59,7 +59,7 @@ export default {
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG); const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
return configs.filter((config) => config.spec?.clusterNetwork !== 'mgmt'); return configs;
}, },
vlanConfigSchema() { vlanConfigSchema() {
@@ -121,22 +121,6 @@ export default {
return [...this.rows, ...fakeRows]; return [...this.rows, ...fakeRows];
}, },
managementVlanConfigs() {
const inStore = this.$store.getters['currentProduct'].inStore;
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
return configs
.filter((vc) => vc.spec?.clusterNetwork === 'mgmt')
.map((vc) => ({
id: vc.id,
node: vc.id.replace('mgmt-vlanconfig-', ''),
bondMode: vc.spec?.uplink?.bondOptions?.mode || '-',
interfaces: (vc.spec?.uplink?.nics?.length ? vc.spec.uplink.nics.join(', ') : '-'),
mtu: vc.spec?.uplink?.linkAttributes?.mtu || '-',
}));
},
}, },
methods: { methods: {
@@ -240,29 +224,6 @@ export default {
</button> </button>
</div> </div>
</div> </div>
<div
v-if="group.key === 'mgmt'"
class="mgmt-summary"
>
<div class="mgmt-header">
<div>{{ t('harvester.tableHeaders.vm.node') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.bondOptions.mode.label') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.nics.label') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.linkAttributes.mtu.label') }}</div>
</div>
<div
v-for="config in managementVlanConfigs"
:key="config.id"
class="mgmt-row"
>
<div>{{ config.node }}</div>
<div>{{ config.bondMode }}</div>
<div>{{ config.interfaces }}</div>
<div>{{ config.mtu }}</div>
</div>
</div>
</template> </template>
<template <template
v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs" v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs"
@@ -312,21 +273,6 @@ export default {
} }
} }
} }
.mgmt-header,
.mgmt-row {
display: grid;
grid-template-columns: 2fr 2fr 2fr 1fr;
padding: 8px 12px;
}
.mgmt-header {
font-weight: 600;
}
.mgmt-row {
border-top: 1px solid var(--border);
}
</style> </style>
<style lang="scss"> <style lang="scss">
-12
View File
@@ -38,10 +38,6 @@ export const SSH_EXISTING_TYPE = {
export default { export default {
methods: { methods: {
hasCloudConfigComment(userScript) { hasCloudConfigComment(userScript) {
if (typeof userScript === 'string' && /^\s*#cloud-config(\s|$)/.test(userScript)) {
return true;
}
// Check that userData contains: #cloud-config // Check that userData contains: #cloud-config
const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({}); const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({});
const items = userDataDoc?.contents?.items || []; const items = userDataDoc?.contents?.items || [];
@@ -56,14 +52,6 @@ export default {
exist = true; 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) => { items.map((item) => {
const key = item.key; const key = item.key;
+63 -299
View File
@@ -25,9 +25,7 @@ import { HCI } from '../../types';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm'; import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl'; import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit'; import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE, ACCESS_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types'; import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY } = ACCESS_MODE;
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine'; const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
@@ -160,17 +158,10 @@ export default {
imageId: '', imageId: '',
diskRows: [], diskRows: [],
networkRows: [], networkRows: [],
networkBootFirst: true,
filesystemRows: [], filesystemRows: [],
machineType: '', machineType: '',
machineTypes: [], machineTypes: [],
secretName: '', secretName: '',
// Tracks the name prefix that `secretName` was generated from, so it can be
// regenerated if the VM name changes (e.g. after a failed create attempt is retried
// with a different name). See harvester/harvester#11174.
secretNamePrefixUsed: '',
// Same purpose as secretNamePrefixUsed, but for the Windows sysprep secret name.
sysprepSecretNamePrefixUsed: '',
secretRef: null, secretRef: null,
showAdvanced: false, showAdvanced: false,
deleteAgent: true, deleteAgent: true,
@@ -196,10 +187,6 @@ export default {
cpuPinning: false, cpuPinning: false,
cpuModel: '', cpuModel: '',
sysprep: { secretName: '', xmlContent: '' }, sysprep: { secretName: '', xmlContent: '' },
// KubeVirt high-performance VM-level features
blockMultiQueue: false,
ioThreadsPolicy: '',
ioThreadCount: 2,
}; };
}, },
@@ -283,7 +270,7 @@ export default {
}, },
customAccessMode() { customAccessMode() {
return this.storageClassSetting.accessModes || READ_WRITE_MANY; return this.storageClassSetting.accessModes || 'ReadWriteMany';
}, },
isWindows() { isWindows() {
@@ -385,7 +372,6 @@ export default {
const diskRows = this.getDiskRows(vm, volumeBackups); const diskRows = this.getDiskRows(vm, volumeBackups);
const networkRows = this.getNetworkRows(vm, { fromTemplate, init }); const networkRows = this.getNetworkRows(vm, { fromTemplate, init });
const networkBootFirst = this.isNetworkBootFirst(spec);
const hasCreateVolumes = this.getHasCreatedVolumes(spec) || []; const hasCreateVolumes = this.getHasCreatedVolumes(spec) || [];
let { userData = undefined, networkData = undefined } = this.getCloudInitNoCloud(spec); let { userData = undefined, networkData = undefined } = this.getCloudInitNoCloud(spec);
@@ -415,10 +401,6 @@ export default {
const cpuPinning = this.isCpuPinning(spec); const cpuPinning = this.isCpuPinning(spec);
const cpuModel = spec.template.spec.domain.cpu?.model || ''; const cpuModel = spec.template.spec.domain.cpu?.model || '';
const blockMultiQueue = spec.template.spec.domain?.devices?.blockMultiQueue || false;
const ioThreadsPolicy = spec.template.spec.domain?.ioThreadsPolicy || '';
const ioThreadCount = spec.template.spec.domain?.ioThreads?.supplementalPoolThreadCount || 2;
const secretRef = this.getSecret(spec); const secretRef = this.getSecret(spec);
const accessCredentials = this.getAccessCredentials(spec); const accessCredentials = this.getAccessCredentials(spec);
@@ -457,13 +439,8 @@ export default {
this['cpuPinning'] = cpuPinning; this['cpuPinning'] = cpuPinning;
this['cpuModel'] = cpuModel; this['cpuModel'] = cpuModel;
this['blockMultiQueue'] = blockMultiQueue;
this['ioThreadsPolicy'] = ioThreadsPolicy;
this['ioThreadCount'] = ioThreadCount;
this['hasCreateVolumes'] = hasCreateVolumes; this['hasCreateVolumes'] = hasCreateVolumes;
this['networkRows'] = networkRows; this['networkRows'] = networkRows;
this['networkBootFirst'] = networkBootFirst;
this['imageId'] = imageId; this['imageId'] = imageId;
this['diskRows'] = diskRows; this['diskRows'] = diskRows;
@@ -518,20 +495,17 @@ export default {
} }
out.push({ out.push({
id: randomStr(5), id: randomStr(5),
source: SOURCE_TYPE.IMAGE, source: SOURCE_TYPE.IMAGE,
name: 'disk-0', 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, bus,
volumeName: '', volumeName: '',
size, size,
type, type,
storageClassName: '', storageClassName: '',
image: this.imageId, image: this.imageId,
volumeMode: VOLUME_MODE.BLOCK, volumeMode: VOLUME_MODE.BLOCK,
cache: '',
io: '',
dedicatedIOThread: false,
isEncrypted, isEncrypted,
volumeBackups, volumeBackups,
}); });
@@ -590,7 +564,7 @@ export default {
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`); const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
source = SOURCE_TYPE.ATTACH_VOLUME; 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'; size = pvcResource?.spec?.resources?.requests?.storage || '10Gi';
storageClassName = pvcResource?.spec?.storageClassName; storageClassName = pvcResource?.spec?.storageClassName;
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK; volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
@@ -621,25 +595,21 @@ export default {
const volumeBackups = volBackups?.find((vBackup) => vBackup.volumeName === DISK.name) || null; const volumeBackups = volBackups?.find((vBackup) => vBackup.volumeName === DISK.name) || null;
return { return {
id: randomStr(5), id: randomStr(5),
bootOrder, bootOrder,
source, source,
name: DISK.name, name: DISK.name,
realName, realName,
bus, bus,
volumeName, volumeName,
container, container,
accessMode, accessMode,
size: `${ formatSize }${ GIBIBYTE }`, size: `${ formatSize }${ GIBIBYTE }`,
volumeMode: volumeMode || this.customVolumeMode, volumeMode: volumeMode || this.customVolumeMode,
image, image,
type, type,
storageClassName, storageClassName,
hotpluggable, hotpluggable,
cache: DISK?.cache || '',
io: DISK?.io || '',
dedicatedIOThread: DISK?.dedicatedIOThread || false,
shareable: DISK.shareable || false,
volumeStatus, volumeStatus,
dataSource, dataSource,
namespace, namespace,
@@ -660,8 +630,6 @@ export default {
const networks = vm.spec.template.spec.networks || []; const networks = vm.spec.template.spec.networks || [];
const interfaces = vm.spec.template.spec.domain.devices.interfaces || []; const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
const annotations = vm.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const out = interfaces.map( (I, index) => { const out = interfaces.map( (I, index) => {
const network = networks.find( (N) => I.name === N.name); const network = networks.find( (N) => I.name === N.name);
@@ -678,30 +646,12 @@ export default {
newCreateId: (fromTemplate || init) ? randomStr(10) : false, newCreateId: (fromTemplate || init) ? randomStr(10) : false,
model: I.model, model: I.model,
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName, networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
staticIp: annotations[`${ staticIpPrefix }${ I.name }`] || '',
networkBoot: !!I.bootOrder,
}; };
}); });
return out; return out;
}, },
/**
* Network boot devices are placed either before or after all volumes in the boot sequence.
* Derive which one from an existing spec, so a round trip through the form keeps it.
*/
isNetworkBootFirst(spec) {
const devices = spec?.template?.spec?.domain?.devices || {};
const nicOrders = (devices.interfaces || []).map((I) => I.bootOrder).filter(Boolean);
const diskOrders = (devices.disks || []).map((D) => D.bootOrder).filter(Boolean);
if (nicOrders.length === 0 || diskOrders.length === 0) {
return true;
}
return Math.min(...nicOrders) < Math.min(...diskOrders);
},
parseVM() { parseVM() {
this.userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent }); this.userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
this.parseOther(); this.parseOther();
@@ -797,8 +747,6 @@ export default {
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm; const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
this.syncStaticIpAnnotations(vm);
// parse reserved memory // parse reserved memory
if (!this.reservedMemory) { if (!this.reservedMemory) {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY]; delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
@@ -820,53 +768,6 @@ export default {
} }
}, },
syncStaticIpAnnotations(vm) {
if (!vm?.metadata) {
return;
}
if (!vm.metadata.annotations) {
vm.metadata.annotations = {};
}
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const annotations = vm.metadata.annotations;
const desired = {};
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
desired[`${ 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() { setCPUAndMemory() {
if (this.cpuMemoryHotplugEnabled) { if (this.cpuMemoryHotplugEnabled) {
// set CPU // set CPU
@@ -914,10 +815,9 @@ export default {
const disks = []; const disks = [];
const volumes = []; const volumes = [];
const volumeClaimTemplates = []; const volumeClaimTemplates = [];
const bootOrderOffset = this.networkBootFirst ? this.networkRows.filter((R) => R.networkBoot).length : 0;
disk.forEach( (R, index) => { disk.forEach( (R, index) => {
const _disk = this.parseDisk(R, index + bootOrderOffset); const _disk = this.parseDisk(R, index);
disks.push(_disk); disks.push(_disk);
@@ -937,30 +837,18 @@ export default {
} }
}); });
// Regenerate the cloud-init secret name whenever it hasn't been generated yet, or the VM if (this.needNewSecret || !this.secretName) {
// name has changed since it was last generated (e.g. a previous create attempt failed with
// an invalid/duplicate name and the user corrected it). Without this, a stale secret name
// derived from a rejected attempt gets reused for the VM that's actually created.
// See harvester/harvester#11174.
if (this.needNewSecret || !this.secretName || (this.isCreate && this.secretNamePrefixUsed !== this.secretNamePrefix)) {
this.secretName = this.generateSecretName(this.secretNamePrefix); this.secretName = this.generateSecretName(this.secretNamePrefix);
this.secretNamePrefixUsed = this.secretNamePrefix;
} }
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) { if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.(); const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
// If we have content but no secret name, it's a new secret that needs a name. Also // If we have content but no secret name, it's a new secret that needs a name.
// regenerate when creating a VM if the name prefix has changed since it was last if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) {
// generated.
const sysprepNeedsNewSecretName = !this.sysprep.secretName?.trim?.() ||
(this.isCreate && this.sysprepSecretNamePrefixUsed !== '' && this.sysprepSecretNamePrefixUsed !== this.secretNamePrefix);
if (hasSysprepContent && sysprepNeedsNewSecretName) {
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep'; const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`; this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
this.sysprepSecretNamePrefixUsed = this.secretNamePrefix;
} }
// Preserve/attach sysprep whenever a secret is selected/known. // Preserve/attach sysprep whenever a secret is selected/known.
@@ -979,60 +867,38 @@ export default {
} }
} }
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) { if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
disks.push({ if (!this.isWindows) {
name: 'cloudinitdisk', disks.push({
disk: { bus: 'virtio' } name: 'cloudinitdisk',
}); disk: { bus: 'virtio' }
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent }); const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = { const cloudinitdisk = {
name: 'cloudinitdisk', name: 'cloudinitdisk',
cloudInitNoCloud: {} cloudInitNoCloud: {}
}; };
if (this.saveUserDataAsClearText) { if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData; cloudinitdisk.cloudInitNoCloud.userData = userData;
} else { } else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName }; 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 specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
const mergedDisks = this.mergeDeviceList(specDisks, 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) => {
const parsed = disks.find((D) => D.name === disk.name);
if (disk.shareable && !parsed?.shareable) {
delete disk.shareable;
}
// High-performance fields are only serialized when requested (see
// parseDisk). When editing, mergeDeviceList would otherwise keep the
// previous spec's cache/io/dedicatedIOThread even after the row is
// switched back to "Default" or the dedicated I/O thread is unchecked,
// silently preserving stale values — strip them so the disk matches
// what the row actually requests.
if (parsed) {
['cache', 'io', 'dedicatedIOThread'].forEach((field) => {
if (!parsed[field] && field in disk) {
delete disk[field];
}
});
}
});
let spec = { let spec = {
...this.spec, ...this.spec,
runStrategy: this.runStrategy, runStrategy: this.runStrategy,
@@ -1067,35 +933,6 @@ export default {
delete spec.template.spec.volumes; delete spec.template.spec.volumes;
} }
// KubeVirt high-performance VM-level features (I/O threads & block multi-queue).
const domainRef = spec.template.spec.domain;
const hasDedicatedIOThread = mergedDisks.some((D) => D.dedicatedIOThread);
// If any disk requests a dedicated I/O thread, KubeVirt requires a policy;
// default it to "shared" so the dedicated thread is honored.
let ioThreadsPolicy = this.ioThreadsPolicy;
if (hasDedicatedIOThread && !ioThreadsPolicy) {
ioThreadsPolicy = 'shared';
}
if (ioThreadsPolicy) {
domainRef.ioThreadsPolicy = ioThreadsPolicy;
} else {
delete domainRef.ioThreadsPolicy;
}
if (ioThreadsPolicy === 'supplementalPool') {
domainRef.ioThreads = { supplementalPoolThreadCount: Number(this.ioThreadCount) || 1 };
} else {
delete domainRef.ioThreads;
}
if (this.blockMultiQueue) {
domainRef.devices.blockMultiQueue = true;
} else {
delete domainRef.devices.blockMultiQueue;
}
if (this.resourceType === HCI.VM) { if (this.resourceType === HCI.VM) {
if (!this.isSingle) { if (!this.isSingle) {
spec = this.multiVMScheduler(spec); spec = this.multiVMScheduler(spec);
@@ -1175,11 +1012,9 @@ export default {
const networks = []; const networks = [];
const interfaces = []; const interfaces = [];
let bootOrder = this.networkBootFirst ? 1 : this.diskRows.length + 1;
networkRow.forEach( (R) => { networkRow.forEach( (R) => {
const _network = this.parseNetwork(R); const _network = this.parseNetwork(R);
const _interface = this.parseInterface(R, R.networkBoot ? bootOrder++ : undefined); const _interface = this.parseInterface(R);
networks.push(_network); networks.push(_network);
interfaces.push(_interface); interfaces.push(_interface);
@@ -1260,16 +1095,6 @@ export default {
}, },
getInitUserData(config) { 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 _QGA_JSON = this.getMatchQGA(config.osType);
const out = jsyaml.dump(_QGA_JSON); const out = jsyaml.dump(_QGA_JSON);
@@ -1297,10 +1122,9 @@ export default {
userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config }); userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config });
const userDataYaml = userDataDoc.toString(); const userDataYaml = userDataDoc.toString();
const userDataValue = userDataDoc.toJSON();
if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) { if (userDataYaml === '{}\n') {
// Empty userData should not create cloud-init content like `null`. // When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned.
return undefined; return undefined;
} }
@@ -1345,27 +1169,10 @@ export default {
if (R.type === HARD_DISK) { if (R.type === HARD_DISK) {
out.disk = { bus: R.bus }; out.disk = { bus: R.bus };
// KubeVirt high-performance per-disk features. `cache`, `io` and
// `dedicatedIOThread` live at the Disk level (siblings of `disk`), not
// inside the DiskTarget. See disks_and_volumes.md#high-performance-features.
if (R.cache) {
out.cache = R.cache;
}
if (R.io) {
out.io = R.io;
}
if (R.dedicatedIOThread) {
out.dedicatedIOThread = true;
}
} else if (R.type === CD_ROM) { } else if (R.type === CD_ROM) {
out.cdrom = { bus: R.bus }; out.cdrom = { bus: R.bus };
} }
if (R.shareable) {
out.shareable = true;
}
out.bootOrder = index + 1; out.bootOrder = index + 1;
return out; return out;
@@ -1416,9 +1223,8 @@ export default {
case SOURCE_TYPE.IMAGE: { case SOURCE_TYPE.IMAGE: {
const image = this.images.find( (I) => R.image === I.id); const image = this.images.find( (I) => R.image === I.id);
out.spec.storageClassName = R.realName ? R.storageClassName : (R.storageClassName || image?.storageClassName);
if (image) { if (image) {
out.spec.storageClassName = image.storageClassName;
out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: image.id }; out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: image.id };
} else { } else {
out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: '' }; out.metadata.annotations = { [HCI_ANNOTATIONS.IMAGE_ID]: '' };
@@ -1435,7 +1241,7 @@ export default {
return arr.map( (id) => this.getSSHValue(id)).filter( (O) => O !== undefined); return arr.map( (id) => this.getSSHValue(id)).filter( (O) => O !== undefined);
}, },
parseInterface(R, bootOrder) { parseInterface(R) {
const _interface = {}; const _interface = {};
const type = R.type; const type = R.type;
@@ -1445,10 +1251,6 @@ export default {
_interface.macAddress = R.macAddress; _interface.macAddress = R.macAddress;
} }
if (bootOrder) {
_interface.bootOrder = bootOrder;
}
_interface.model = R.model; _interface.model = R.model;
_interface.name = R.name; _interface.name = R.name;
@@ -1495,22 +1297,13 @@ export default {
*/ */
deleteYamlDocProp(doc, paths) { deleteYamlDocProp(doc, paths) {
try { try {
const items = doc.getIn([])?.items; const item = doc.getIn([])?.items[0];
const firstKey = items?.[0]?.key; const key = item?.key;
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config'); const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
const isFirstProp = firstKey?.source === paths[paths.length - 1]; const isMatchProp = key.source === paths[paths.length - 1];
if (firstKey && hasCloudConfigComment && isFirstProp) { if (key && hasCloudConfigComment && isMatchProp) {
const comment = firstKey.commentBefore; // Comments are mounted on the next node and we should not delete the node containing cloud-config
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;
}
} else { } else {
doc.deleteIn(paths); doc.deleteIn(paths);
} }
@@ -1561,6 +1354,7 @@ export default {
if (packages.length > 0) { if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages); userDataDoc.setIn(['packages'], packages);
} else { } 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, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']); this.deleteYamlDocProp(userDataDoc, ['package_update']);
} }
@@ -1575,7 +1369,7 @@ export default {
}, },
deleteQGA(config) { 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 || ''; const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || '';
@@ -1584,15 +1378,6 @@ export default {
const packages = userDataJSON?.packages || []; const packages = userDataJSON?.packages || [];
const runcmd = userDataJSON?.runcmd || []; 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) { if (Array.isArray(packages) && deletePackage) {
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue); const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
@@ -1618,6 +1403,7 @@ export default {
if (packages.length > 0) { if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages); userDataDoc.setIn(['packages'], packages);
} else { } else {
userDataDoc.setIn(['packages'], []);
this.deleteYamlDocProp(userDataDoc, ['packages']); this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']); this.deleteYamlDocProp(userDataDoc, ['package_update']);
} }
@@ -1650,7 +1436,7 @@ export default {
}, },
async saveSecret(vm) { async saveSecret(vm) {
if (!vm?.spec || !this.secretName) { if (!vm?.spec || !this.secretName || this.isWindows) {
return true; return true;
} }
@@ -1956,11 +1742,6 @@ export default {
delete merged['bridge']; delete merged['bridge'];
} }
// boot order is owned by the form, don't inherit a stale one from the old spec
if (!iface.bootOrder) {
delete merged.bootOrder;
}
return merged; return merged;
} }
@@ -1978,18 +1759,7 @@ export default {
const specDevice = specDeviceMap.get(device.name); const specDevice = specDeviceMap.get(device.name);
if (specDevice) { if (specDevice) {
const merged = { ...specDevice, ...device }; return { ...specDevice, ...device };
// A disk entry must be either `disk` or `cdrom`, never both.
const type = device?.cdrom ? CD_ROM : device?.disk ? HARD_DISK : '';
if (type === CD_ROM) {
delete merged.disk;
} else if (type === HARD_DISK) {
delete merged.cdrom;
}
return merged;
} }
return device; return device;
@@ -2037,15 +1807,6 @@ export default {
}, },
watch: { watch: {
networkRows: {
handler() {
const vm = this.resourceType === HCI.VM ? this.value : this.value?.spec?.vm;
this.syncStaticIpAnnotations(vm);
},
deep: true
},
diskRows: { diskRows: {
handler(neu, old) { handler(neu, old) {
if (Array.isArray(neu)) { if (Array.isArray(neu)) {
@@ -2076,6 +1837,8 @@ export default {
isWindows(val) { isWindows(val) {
if (val) { if (val) {
this['sshKey'] = []; this['sshKey'] = [];
this['userScript'] = undefined;
this['networkScript'] = undefined;
this['installAgent'] = false; this['installAgent'] = false;
} }
}, },
@@ -2135,8 +1898,9 @@ export default {
osType(neu, old) { osType(neu, old) {
this.installAgent = old === 'windows' ? true : this.installAgent; 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(); this.refreshYamlEditor();
}, },
@@ -69,15 +69,16 @@ export default class SRIOVDevice extends SteveModel {
return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0; return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0;
} }
get isTimesliced() { async enableDevice() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true'; try {
} this.spec.enabled = true;
await this.save();
enableDevice() { } catch (err) {
this.$dispatch('promptModal', { this.$dispatch('growl/fromError', {
resources: [this], title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }),
component: 'EnableSriovGpuDevice' err,
}); }, { root: true });
}
} }
async disableDevice() { async disableDevice() {
@@ -14,17 +14,6 @@ export default class ForkliftPlan extends HarvesterResource {
}; };
} }
/**
* The base HarvesterResource sets `doneOverride` to `listLocation`, which would
* force every single-item delete to redirect to the VM Migration dashboard.
* Return undefined so the delete flow stays on the current page (list/dashboard)
* and only falls back to the shell's default navigation when a now-invalid
* detail page is being viewed.
*/
get doneOverride() {
return undefined;
}
get planFailed() { get planFailed() {
const conditions = this.status?.conditions || []; const conditions = this.status?.conditions || [];
@@ -46,10 +35,6 @@ export default class ForkliftPlan extends HarvesterResource {
} }
get stateDescription() { get stateDescription() {
if (this.metadata?.deletionTimestamp) {
return null;
}
const messages = []; const messages = [];
const conditions = this.status?.conditions || []; const conditions = this.status?.conditions || [];
@@ -71,7 +56,7 @@ export default class ForkliftPlan extends HarvesterResource {
get stateObj() { get stateObj() {
return { return {
error: this.planFailed || this.planCritical, error: this.planFailed || this.planCritical,
transitioning: this.isMigrating || this.isPendingMigrationStart || !!this.metadata?.deletionTimestamp, transitioning: this.isMigrating,
message: this.stateDescription, message: this.stateDescription,
}; };
} }
@@ -82,14 +67,6 @@ export default class ForkliftPlan extends HarvesterResource {
return !!migration?.started && !migration?.completed; return !!migration?.started && !migration?.completed;
} }
// Right after creating a plan, controller status can lag briefly.
// Treat this window as in-progress so we don't flash a succeeded badge.
get isPendingMigrationStart() {
const vmCount = this.spec?.vms?.length || 0;
return vmCount > 0 && !this.planFailed && !this.planCritical && !this.planCanceled && !this.planSucceeded && !this.isMigrating;
}
get planCanceled() { get planCanceled() {
const history = this.status?.migration?.history || []; const history = this.status?.migration?.history || [];
@@ -110,10 +87,6 @@ export default class ForkliftPlan extends HarvesterResource {
} }
get stateDisplay() { get stateDisplay() {
if (this.metadata?.deletionTimestamp) {
return 'Terminating';
}
if (this.planFailed) { if (this.planFailed) {
return this.t('harvester.addons.vmMigration.plan.states.error'); return this.t('harvester.addons.vmMigration.plan.states.error');
} }
@@ -130,18 +103,10 @@ export default class ForkliftPlan extends HarvesterResource {
return this.t('harvester.addons.vmMigration.plan.states.inProgress'); return this.t('harvester.addons.vmMigration.plan.states.inProgress');
} }
if (this.isPendingMigrationStart) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
return this.t('harvester.addons.vmMigration.plan.states.active'); return this.t('harvester.addons.vmMigration.plan.states.active');
} }
get stateBackground() { get stateBackground() {
if (this.metadata?.deletionTimestamp) {
return 'bg-info';
}
if (this.planFailed) { if (this.planFailed) {
return 'bg-error'; return 'bg-error';
} }
@@ -158,10 +123,6 @@ export default class ForkliftPlan extends HarvesterResource {
return 'bg-info'; return 'bg-info';
} }
if (this.isPendingMigrationStart) {
return 'bg-info';
}
return 'bg-success'; return 'bg-success';
} }
@@ -283,4 +244,15 @@ export default class ForkliftPlan extends HarvesterResource {
async deletePlan() { async deletePlan() {
await this.remove(); await this.remove();
} }
/**
* Deleting a Plan cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider (→ Secret).
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove(opt = {}) {
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
return this._remove(opt);
}
} }
-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) { cleanForSave(data, _forNew) {
return data; return data;
} }
@@ -5,7 +5,6 @@ import SYSTEM_NAMESPACES from '@shell/config/system-namespaces';
import { get } from '@shell/utils/object'; import { get } from '@shell/utils/object';
import { NAMESPACE } from '@shell/config/types'; import { NAMESPACE } from '@shell/config/types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester';
import { FORKLIFT_NAMESPACE } from '@pkg/harvester/config/harvester-map';
import { HCI } from '../../types'; import { HCI } from '../../types';
const OBSCURE_NAMESPACE_PREFIX = [ const OBSCURE_NAMESPACE_PREFIX = [
@@ -94,10 +93,6 @@ export default class HciNamespace extends namespace {
} }
get isSystem() { get isSystem() {
if (this.metadata?.name === FORKLIFT_NAMESPACE) {
return false;
}
const systemNamespaces = ['fleet-default']; const systemNamespaces = ['fleet-default'];
if (systemNamespaces.includes(this.metadata.name)) { if (systemNamespaces.includes(this.metadata.name)) {
+8 -66
View File
@@ -23,13 +23,6 @@ const ALLOW_SYSTEM_LABEL_KEYS = [
const HEALTHY = 'healthy'; const HEALTHY = 'healthy';
const WARNING = 'warning'; const WARNING = 'warning';
const MAINTENANCE_MODE = 'MaintenanceMode';
const MAINTENANCE_ERROR = 'Error';
const MAINTENANCE_ENTERING_REASONS = ['Validating', 'Draining', 'Evacuating'];
const MAINTENANCE_COMPLETED = 'Completed';
// Legacy `harvesterhci.io/maintain-status` annotation values (clusters < v1.10)
const LEGACY_MAINTENANCE_RUNNING = 'running';
const LEGACY_MAINTENANCE_COMPLETED = 'completed';
export default class HciNode extends HarvesterResource { export default class HciNode extends HarvesterResource {
get _availableActions() { get _availableActions() {
@@ -65,14 +58,6 @@ export default class HciNode extends HarvesterResource {
total: 1 total: 1
}; };
const clearMaintenance = {
action: 'clearMaintenanceMode',
enabled: this.hasAction('clearMaintenanceMode'),
icon: 'icon icon-fw icon-close',
label: this.t('harvester.action.clearMaintenance'),
total: 1
};
const enableCPUManager = { const enableCPUManager = {
action: 'enableCPUManager', action: 'enableCPUManager',
enabled: this.cpuPinningFeatureEnabled && this.hasAction('enableCPUManager') && !this.isCPUManagerEnableInProgress && !this.isCPUManagerEnabled && !this.isEtcd, // witness node doesn't have CPU manager enabled: this.cpuPinningFeatureEnabled && this.hasAction('enableCPUManager') && !this.isCPUManagerEnableInProgress && !this.isCPUManagerEnabled && !this.isEtcd, // witness node doesn't have CPU manager
@@ -118,7 +103,6 @@ export default class HciNode extends HarvesterResource {
uncordon, uncordon,
enableMaintenance, enableMaintenance,
disableMaintenance, disableMaintenance,
clearMaintenance,
enableCPUManager, enableCPUManager,
disableCPUManager, disableCPUManager,
shutDown, shutDown,
@@ -214,10 +198,6 @@ export default class HciNode extends HarvesterResource {
} }
get stateDisplay() { get stateDisplay() {
if (this.isMaintenanceModeError) {
return super.stateDisplay;
}
if (this.isEnteringMaintenance) { if (this.isEnteringMaintenance) {
return 'Entering maintenance mode'; return 'Entering maintenance mode';
} }
@@ -266,10 +246,6 @@ export default class HciNode extends HarvesterResource {
} }
get stateDescription() { get stateDescription() {
if (this.isMaintenanceModeError) {
return super.stateDescription;
}
const currentIP = this.metadata?.annotations?.[HCI_ANNOTATIONS.CURRENT_IP]; const currentIP = this.metadata?.annotations?.[HCI_ANNOTATIONS.CURRENT_IP];
const initIP = this.metadata?.annotations?.[HCI_ANNOTATIONS.INIT_IP]; const initIP = this.metadata?.annotations?.[HCI_ANNOTATIONS.INIT_IP];
@@ -349,11 +325,7 @@ export default class HciNode extends HarvesterResource {
} }
disableMaintenanceMode() { disableMaintenanceMode() {
this.doActionGrowl('disableMaintenanceMode', {}); this.doAction('disableMaintenanceMode', {});
}
clearMaintenanceMode() {
this.doActionGrowl('clearMaintenanceMode', {});
} }
enableCPUManager() { enableCPUManager() {
@@ -371,32 +343,11 @@ export default class HciNode extends HarvesterResource {
); );
} }
get maintenanceModeCondition() {
return (this.status?.conditions || []).find(
(c) => c.type === MAINTENANCE_MODE
);
}
get maintenanceModeConditionsEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('maintenanceModeConditions');
}
// Clusters < v1.10 only expose the `harvesterhci.io/maintain-status` annotation
get legacyMaintenanceStatus() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.MAINTENANCE_STATUS];
}
get isMaintenanceModeError() {
return this.maintenanceModeConditionsEnabled && this.maintenanceModeCondition?.reason === MAINTENANCE_ERROR;
}
get isMigratable() { get isMigratable() {
const states = ['in-progress', 'unavailable']; const states = ['in-progress', 'unavailable'];
const engaged = this.maintenanceModeConditionsEnabled ? this.maintenanceModeCondition?.status === 'True' : !!this.legacyMaintenanceStatus;
return ( return (
!engaged && !this.metadata?.annotations?.[HCI_ANNOTATIONS.MAINTENANCE_STATUS] &&
!this.isUnSchedulable && !this.isUnSchedulable &&
!states.includes(this.state) !states.includes(this.state)
); );
@@ -411,26 +362,17 @@ export default class HciNode extends HarvesterResource {
} }
get isEnteringMaintenance() { get isEnteringMaintenance() {
if (!this.maintenanceModeConditionsEnabled) {
return this.legacyMaintenanceStatus === LEGACY_MAINTENANCE_RUNNING;
}
const cond = this.maintenanceModeCondition;
return ( return (
cond?.status === 'True' && this.metadata?.annotations?.[HCI_ANNOTATIONS.MAINTENANCE_STATUS] ===
MAINTENANCE_ENTERING_REASONS.includes(cond?.reason) 'running'
); );
} }
get isMaintenance() { get isMaintenance() {
if (!this.maintenanceModeConditionsEnabled) { return (
return this.legacyMaintenanceStatus === LEGACY_MAINTENANCE_COMPLETED; this.metadata?.annotations?.[HCI_ANNOTATIONS.MAINTENANCE_STATUS] ===
} 'completed'
);
const cond = this.maintenanceModeCondition;
return cond?.status === 'True' && cond?.reason === MAINTENANCE_COMPLETED;
} }
get cpuPinningFeatureEnabled() { get cpuPinningFeatureEnabled() {
+6 -18
View File
@@ -5,14 +5,12 @@ import HarvesterResource from './harvester';
import { HCI } from '../types'; import { HCI } from '../types';
const HARVESTER_NVIDIA_DRIVER_TOOLKIT = 'harvester-system/nvidia-driver-toolkit'; 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 { export default class HciAddonConfig extends HarvesterResource {
get availableActions() { get availableActions() {
const out = super._availableActions; const out = super._availableActions;
if (this.id === RANCHER_VCLUSTER || this.id === RANCHER_K3K_ADDON) { if (this.id === 'harvester-system/rancher-vcluster') {
const rancherDashboard = { const rancherDashboard = {
action: 'goToRancher', action: 'goToRancher',
enabled: this.spec.enabled, enabled: this.spec.enabled,
@@ -59,15 +57,6 @@ export default class HciAddonConfig extends HarvesterResource {
return; return;
} }
if (!this.spec.enabled && this.id === RANCHER_K3K_ADDON) {
this.$dispatch('promptModal', {
resources: [this],
component: 'HarvesterEnableK3k',
});
return;
}
this.spec.enabled = !this.spec.enabled; this.spec.enabled = !this.spec.enabled;
await this.save(); await this.save();
} catch (err) { } catch (err) {
@@ -80,19 +69,18 @@ export default class HciAddonConfig extends HarvesterResource {
} }
goToRancher() { goToRancher() {
const valuesContent = jsyaml.load(this.spec.valuesContent);
window.open( window.open(
this.rancherHostname, `https://${ valuesContent.hostname }`,
'_blank', '_blank',
); );
} }
get rancherHostname() { 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 return `https://${ valuesContent.hostname }`;
const hostname = this.id === RANCHER_K3K_ADDON ? valuesContent.rancher?.hostname : valuesContent.hostname;
return `https://${ hostname }`;
} }
get stateColor() { 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() { get schedulingVMBackupFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('schedulingVMBackup'); 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;
}
}
+2 -2
View File
@@ -1,13 +1,13 @@
{ {
"name": "harvester", "name": "harvester",
"description": "Rancher UI Extension for Harvester", "description": "Rancher UI Extension for Harvester",
"version": "1.10.0-dev", "version": "1.9.0-dev",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
"catalog.cattle.io/display-name": "Harvester", "catalog.cattle.io/display-name": "Harvester",
"catalog.cattle.io/kube-version": ">= 1.16.0-0", "catalog.cattle.io/kube-version": ">= 1.16.0-0",
"catalog.cattle.io/rancher-version": ">= 2.15.0-0", "catalog.cattle.io/rancher-version": ">= 2.14.0-0",
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0" "catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
} }
}, },
+2 -14
View File
@@ -63,7 +63,6 @@ export default {
return { return {
vendor: getVendor(), vendor: getVendor(),
uiPLSetting: {}, uiPLSetting: {},
privateLabelDraft: null,
uiLogoDarkSetting: {}, uiLogoDarkSetting: {},
uiLogoDark: '', uiLogoDark: '',
uiLogoLightSetting: {}, uiLogoLightSetting: {},
@@ -82,17 +81,6 @@ export default {
}; };
}, },
computed: { 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() { mode() {
const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.SETTING); const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.SETTING);
@@ -124,7 +112,7 @@ export default {
this.errors.push(e); this.errors.push(e);
}, },
async save(btnCB) { async save(btnCB) {
this.uiPLSetting.value = (this.privateLabel || '').replaceAll(/[\<>&=#()"]/gm, ''); this.uiPLSetting.value = this.uiPLSetting.value.replaceAll(/[\<>&=#()"]/gm, '');
if (this.customizeLogo) { if (this.customizeLogo) {
this.uiLogoLightSetting.value = this.uiLogoLight; this.uiLogoLightSetting.value = this.uiLogoLight;
this.uiLogoDarkSetting.value = this.uiLogoDark; this.uiLogoDarkSetting.value = this.uiLogoDark;
@@ -181,7 +169,7 @@ export default {
<div class="row mb-20"> <div class="row mb-20">
<div class="col span-6"> <div class="col span-6">
<LabeledInput <LabeledInput
v-model:value="privateLabel" v-model:value="uiPLSetting.value"
:label="t('branding.uiPL.label')" :label="t('branding.uiPL.label')"
:mode="mode" :mode="mode"
:maxlength="100" :maxlength="100"
@@ -1,9 +1,10 @@
<script> <script>
import Loading from '@shell/components/Loading';
import { HCI } from '../../../../../types'; import { HCI } from '../../../../../types';
import SerialConsole from '../../../../../components/SerialConsole'; import SerialConsole from '../../../../../components/SerialConsole';
export default { export default {
components: { SerialConsole }, components: { SerialConsole, Loading },
async fetch() { async fetch() {
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI }); this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
@@ -37,44 +38,16 @@ export default {
</script> </script>
<template> <template>
<div class="serial-console-page"> <Loading v-if="$fetchState.pending" />
<SerialConsole <SerialConsole
ref="serialConsole" v-else
:value="vmi || {}" ref="serialConsole"
/> v-model:value="vmi"
</div> />
</template> </template>
<style lang="scss"> <style lang="scss" scoped>
html, body, #__nuxt, #__layout, main {
body,
#__nuxt,
#__layout,
main {
height: 100%; 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> </style>
@@ -11,8 +11,6 @@ import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types'; import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester'; import { PRODUCT_NAME } from '../../../../config/harvester';
import { ADD_ONS } from '../../../../config/harvester-map';
import { currentRouter } from '../../../../utils/router';
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers'; import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers'; import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
@@ -37,88 +35,6 @@ const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN)); const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP)); const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP)); const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
const allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
const escapeHtml = (value = '') => String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const getPipelineStepMeta = (step = {}) => {
const phase = String(step.phase || '').toLowerCase();
const hasProgress = typeof step.progress?.completed === 'number' && typeof step.progress?.total === 'number' && step.progress.total > 0;
const progressPct = hasProgress ? Math.round((step.progress.completed / step.progress.total) * 1000) / 10 : null;
if (phase === 'completed' || phase === 'succeeded') {
return {
icon: '✓',
color: 'var(--success)',
text: ''
};
}
if (phase === 'running' || phase === 'inprogress' || phase === 'progressing' || phase === 'started' || (hasProgress && progressPct > 0)) {
return {
icon: '↻',
color: 'var(--primary)',
text: progressPct !== null ? `${ step.phase || t('generic.inProgress') } (${ progressPct }%)` : (step.phase || t('generic.inProgress'))
};
}
if (phase === 'failed' || phase === 'error') {
return {
icon: '✕',
color: 'var(--error)',
text: step.phase || t('harvester.addons.vmMigration.dashboard.progress.failed')
};
}
return {
icon: '○',
color: 'var(--muted)',
text: ''
};
};
const formatPipelineTooltip = (pipeline = []) => {
if (!pipeline.length) {
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:var(--muted);font-weight:700;">○</span><span>${ escapeHtml(t('harvester.addons.vmMigration.dashboard.progress.initializingMigration')) }</span></div>`;
}
return pipeline
.map((step, idx) => {
const stepName = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
const meta = getPipelineStepMeta(step);
const lineText = meta.text ? `${ escapeHtml(stepName) } : ${ escapeHtml(meta.text) }` : escapeHtml(stepName);
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:${ meta.color };font-weight:700;">${ meta.icon }</span><span>${ lineText }</span></div>`;
})
.join('');
};
const formatDuration = (started, completed) => {
if (!started) {
return { display: null, seconds: -1 };
}
const startMs = new Date(started).getTime();
const endMs = completed ? new Date(completed).getTime() : Date.now();
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
return { display: null, seconds: -1 };
}
const totalSeconds = Math.floor((endMs - startMs) / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const display = hours > 0 ? `${ hours }h ${ minutes }m` : `${ minutes }m ${ seconds }s`;
return { display, seconds: totalSeconds };
};
const rows = computed(() => { const rows = computed(() => {
return allPlans.value.map((plan) => { return allPlans.value.map((plan) => {
@@ -130,8 +46,8 @@ const rows = computed(() => {
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs); const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs); const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking') : (e.destination?.name || 'Ignored') }`); plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.storageClass || 'Ignored' }`); plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-'; plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
@@ -179,67 +95,35 @@ const rows = computed(() => {
overallProgress = Math.round(overallProgress * 10) / 10; overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it. // If no step-level error but the plan itself is failed, surface it
// Skip VMs that already reached 100% (they finished successfully before if (!errorMsg && plan.planFailed) {
// the plan failed) so their bar stays green instead of turning red.
if (!errorMsg && plan.planFailed && overallProgress < 100) {
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`; errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
} }
const vmNamespace = vm.namespace || plan.spec?.targetNamespace || plan.metadata?.namespace;
const routeParams = currentRouter().currentRoute?.value?.params || {};
const product = routeParams.product || store.getters['productId'];
const cluster = routeParams.cluster || store.getters['clusterId'];
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
// A VM that individually reached 100% without error should be navigable
// even when the overall plan failed (other VMs in the plan may have
// failed); `overallProgress >= 100 && !errorMsg && targetVm` already
// guarantees this VM migrated successfully.
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planCanceled && !!targetVm && !!product && !!cluster;
const vmDetailLocation = canNavigateToVm ? {
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
params: {
product,
cluster,
resource: HCI.VM,
namespace: targetVm.metadata.namespace,
id: targetVm.metadata.name,
}
} : null;
return { return {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'), vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '', vmId: vm.id || '',
progress: overallProgress, progress: overallProgress,
currentStep, currentStep,
pipelineTooltip: formatPipelineTooltip(pipeline),
errorMsg, errorMsg,
canceled: plan.planCanceled, canceled: plan.planCanceled,
vmDetailLocation,
}; };
}); });
// If no migration progress but we have VMs in the spec, show them at 0% // If no migration progress but we have VMs in the spec, show them at 0%
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) { if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
plan.vmProgress = plan.spec.vms.map((vm) => ({ plan.vmProgress = plan.spec.vms.map((vm) => ({
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'), vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '', vmId: vm.id || '',
progress: 0, progress: 0,
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'), currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
pipelineTooltip: formatPipelineTooltip([]), errorMsg: '',
errorMsg: '', canceled: false,
canceled: false,
})); }));
} }
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0; plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
const durationResult = formatDuration(plan.status?.migration?.started, plan.status?.migration?.completed);
plan.duration = durationResult.display;
plan.durationSeconds = durationResult.seconds;
return plan; return plan;
}); });
}); });
@@ -263,63 +147,22 @@ const headers = [
name: 'progress', name: 'progress',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress', labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
value: 'progress', value: 'progress',
width: 450, width: 500,
}, },
{ {
name: 'mappings', name: 'mappings',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings', labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
}, },
{
name: 'duration',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
value: 'duration',
sort: 'durationSeconds',
width: 90,
},
{ ...AGE }, { ...AGE },
]; ];
const init = async() => { const init = async() => {
try { try {
// Guard: the Forklift dashboard is only available when the Forklift addon is
// enabled. Redirect back to the Harvester dashboard otherwise (e.g. direct URL).
let forkliftEnabled = false;
if (store.getters[`${ inStore.value }/schemaFor`](HCI.ADD_ONS)) {
const addons = await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.ADD_ONS });
forkliftEnabled = addons.find((a) => a.metadata?.name === ADD_ONS.FORKLIFT_OPERATOR)?.spec?.enabled === true;
}
if (!forkliftEnabled) {
await currentRouter().replace({
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.DASHBOARD,
},
});
return;
}
await Promise.all([ await Promise.all([
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]); ]);
// VMs are only needed to conditionally show the post-migration VM-detail
// link, so load them separately and non-fatally: a failure here (or the
// cost of findAll on large clusters) should not block plans/maps from
// rendering.
try {
await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.VM });
} catch (e) {
// Intentionally ignored: the dashboard still works without the VM list,
// the migrated VM-detail link simply won't be shown.
}
} catch (e) { } catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')]; errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally { } finally {
@@ -346,10 +189,9 @@ init();
> >
<template #subHeader> <template #subHeader>
<div class="mmt-5"> <div class="mmt-5">
<p <p class="text-muted">
v-clean-html="t('harvester.addons.vmMigration.dashboard.description')" {{ t('harvester.addons.vmMigration.dashboard.description') }}
class="text-muted" </p>
></p>
</div> </div>
</template> </template>
<template #createButton> <template #createButton>
@@ -371,19 +213,14 @@ init();
:search="false" :search="false"
key-field="_key" key-field="_key"
> >
<template #header-left>
<h3 class="table-title m-0">
{{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
</h3>
</template>
<template #cell:name="{ row }"> <template #cell:name="{ row }">
<div class="plan-name-cell"> <div class="plan-name-cell">
<router-link <div class="plan-name">
v-if="row.detailLocation"
:to="row.detailLocation"
class="plan-name"
>
{{ row.metadata.name }}
</router-link>
<div
v-else
class="plan-name"
>
{{ row.metadata.name }} {{ row.metadata.name }}
</div> </div>
</div> </div>
@@ -403,31 +240,17 @@ init();
> >
<div class="vm-progress-header"> <div class="vm-progress-header">
<div class="vm-name-block"> <div class="vm-name-block">
<span class="vm-name"> <span class="vm-name">{{ vm.vmName }}</span>
{{ vm.vmName }}
<router-link
v-if="vm.vmDetailLocation"
v-clean-tooltip="t('harvester.addons.vmMigration.dashboard.progress.checkMigratedVm')"
:to="vm.vmDetailLocation"
class="vm-detail-link"
>
<i class="icon icon-external-link" />
</router-link>
</span>
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span> <span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
</div> </div>
</div> </div>
<div class="vm-pct-block"> <div class="vm-pct-block">
<div <PercentageBar
v-clean-tooltip="{ content: vm.pipelineTooltip, html: true }" :model-value="vm.progress"
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
preferred-direction="MORE"
class="vm-bar" class="vm-bar"
> />
<PercentageBar
:model-value="vm.progress"
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
preferred-direction="MORE"
/>
</div>
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span> <span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
</div> </div>
<div <div
@@ -464,13 +287,6 @@ init();
:storage-entries="row.storageEntries" :storage-entries="row.storageEntries"
/> />
</template> </template>
<template #cell:duration="{ row }">
<span v-if="row.duration">{{ row.duration }}</span>
<span
v-else
class="text-muted"
>&mdash;</span>
</template>
</ResourceTable> </ResourceTable>
</div> </div>
</template> </template>
@@ -504,14 +320,6 @@ init();
.vm-name { .vm-name {
font-size: 14px; font-size: 14px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-detail-link {
display: inline-flex;
align-items: center;
} }
.vm-name-block { .vm-name-block {
@@ -5,7 +5,6 @@ import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types'; import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue'; import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue'; import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
@@ -47,7 +46,6 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -164,7 +162,7 @@ const onFinish = async(buttonCb) => {
buttonCb(true); buttonCb(true);
currentRouter().push(providerListLocation); currentRouter().push(providerListLocation);
} catch (err) { } 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); buttonCb(false);
} }
}; };
@@ -195,7 +193,6 @@ const init = async() => {
stepData.provider.selectedProvider = fetchedProvider.metadata.name; stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name; stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || ''; stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider; stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret; const secretRef = fetchedProvider.spec?.secret;
@@ -234,7 +231,7 @@ const init = async() => {
// Maps may not exist yet // Maps may not exist yet
} }
} catch (err) { } 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; initialLoading.value = false;
@@ -9,7 +9,6 @@ import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/Config
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue'; import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter } from '@pkg/harvester/utils/router'; import { currentRouter } from '@pkg/harvester/utils/router';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
@@ -41,7 +40,6 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -57,7 +55,7 @@ const stepData = reactive({
networkEntries: [], networkEntries: [],
storageEntries: [], storageEntries: [],
}, },
review: { planName: '', targetNamespace: '' }, review: { planName: '' },
}); });
const steps = reactive([ const steps = reactive([
@@ -174,24 +172,6 @@ const onReviewReady = (ready) => {
reviewReady.value = ready; reviewReady.value = ready;
}; };
const onEditSelection = () => {
wizardComponent.value?.goToStep(2);
};
const onEditMappings = () => {
wizardComponent.value?.goToStep(3);
};
const onRemoveVm = (id) => {
selectedVMs.value = selectedVMs.value.filter((vm) => vm.id !== id);
stepData.vms.selectedVMIds.delete(id);
vmsReady.value = selectedVMs.value.length > 0;
if (!vmsReady.value) {
reviewReady.value = false;
}
};
// Clear downstream data when provider changes // Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => { watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
@@ -210,13 +190,13 @@ watch(() => stepData.provider.providerName, (newVal, oldVal) => {
} }
}); });
// Clear mappings and review when VMs are added to the selection. Pure removals // Clear mappings and review when VM selection changes
// (e.g. "Remove from Plan" on the review step) keep the existing mappings and plan name.
watch(selectedVMs, (newVal, oldVal) => { watch(selectedVMs, (newVal, oldVal) => {
const newIds = new Set(newVal.map((v) => v.id));
const oldIds = new Set(oldVal.map((v) => v.id)); const oldIds = new Set(oldVal.map((v) => v.id));
const added = newVal.some((v) => !oldIds.has(v.id)); const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id));
if (oldVal.length > 0 && added) { if (oldVal.length > 0 && changed) {
stepData.mappings.networkEntries = []; stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = []; stepData.mappings.storageEntries = [];
mappingsReady.value = false; mappingsReady.value = false;
@@ -240,13 +220,7 @@ const onFinish = async(buttonCb) => {
buttonCb(true); buttonCb(true);
currentRouter().push(migrationListLocation); currentRouter().push(migrationListLocation);
} catch (err) { } catch (err) {
errors.value = exceptionToErrorsArray(err).map((e) => { errors.value = [err instanceof Error ? err.message : String(err)];
if (typeof e === 'string') {
return e;
}
return stringify(e);
});
buttonCb(false); buttonCb(false);
} }
}; };
@@ -309,9 +283,6 @@ const onCancel = () => {
:mapping-entries="stepData.mappings" :mapping-entries="stepData.mappings"
:step-data="stepData.review" :step-data="stepData.review"
@ready="onReviewReady" @ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/> />
</template> </template>
</CruResource> </CruResource>
@@ -1,330 +0,0 @@
<script>
import { defineComponent } from 'vue';
import { mapState, mapGetters } from 'vuex';
import { resourceNames } from '@shell/utils/string';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI } from '../types';
export default defineComponent({
name: 'PromptRemoveForkliftPlanDialog',
emits: ['errors'],
props: {
value: {
type: Array,
default: () => []
},
names: {
type: Array,
default: () => []
},
type: {
type: String,
required: true
},
close: {
type: Function,
required: true
},
doneLocation: {
type: Object,
default: () => {}
}
},
data() {
return {
errors: [],
removing: false,
};
},
computed: {
...mapState('action-menu', ['toRemove']),
...mapGetters({ t: 'i18n/t' }),
existingRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
return {
[HCI.FORKLIFT_MIGRATION]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_MIGRATION) || [],
[HCI.FORKLIFT_NETWORK_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [],
[HCI.FORKLIFT_STORAGE_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [],
};
},
resourceType() {
return this.t('typeLabel."forklift.konveyor.io.plan"', { count: this.names?.length || 1 });
},
relatedSummary() {
const existing = this.existingRelatedResources;
return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || '';
const 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);
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;
return {
planName,
namespace,
migrations,
networkMap: matchedNetworkMap ? {
namespace: matchedNetworkMap?.metadata?.namespace || networkMapRef?.namespace || namespace,
name: matchedNetworkMap?.metadata?.name,
} : (networkMapRef ? {
namespace: networkMapRef.namespace,
name: networkMapRef.name,
} : null),
storageMap: matchedStorageMap ? {
namespace: matchedStorageMap?.metadata?.namespace || storageMapRef?.namespace || namespace,
name: matchedStorageMap?.metadata?.name,
} : (storageMapRef ? {
namespace: storageMapRef.namespace,
name: storageMapRef.name,
} : null),
};
});
},
},
async mounted() {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
await Promise.all([
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
} catch {
// Best-effort: the dialog can still function without this prefetch.
}
},
methods: {
resourceNames,
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`.
* Matches on `kind: Plan` and the plan name; when both sides expose a uid it
* must match too, to disambiguate same-named plans.
*/
ownedByPlan(resource, plan) {
const planName = plan?.metadata?.name;
const planUid = plan?.metadata?.uid;
const owners = resource?.metadata?.ownerReferences || [];
if (!planName) {
return false;
}
return owners.some((owner) => owner?.kind === 'Plan' &&
owner?.name === planName &&
(!planUid || !owner?.uid || owner.uid === planUid));
},
buildDeleteTargets() {
const existing = this.existingRelatedResources;
const targets = new Map();
const addTarget = (type, name, namespace) => {
const ns = namespace || '';
if (!name) {
return;
}
const key = `${ type }|${ ns }|${ name }`;
targets.set(key, {
type, name, namespace: ns
});
};
for (const plan of this.value || []) {
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
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_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource?.metadata?.name, resource?.metadata?.namespace));
}
return [...targets.values()];
},
async deleteRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
const targets = this.buildDeleteTargets();
if (!targets.length) {
return;
}
let cache = {};
try {
cache = {
[HCI.FORKLIFT_MIGRATION]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
[HCI.FORKLIFT_NETWORK_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
[HCI.FORKLIFT_STORAGE_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
};
} catch (err) {
// Best-effort cleanup; don't block closing the dialog if listing related resources fails.
return;
}
for (const target of targets) {
const matched = (cache[target.type] || []).find((resource) => resource?.metadata?.name === target.name && resource?.metadata?.namespace === target.namespace
);
if (!matched) {
continue;
}
try {
await matched.remove({ params: { propagationPolicy: 'Background' } });
} catch (err) {
// Continue deleting remaining resources and let final plan deletion fail if needed.
}
}
},
async remove(buttonDone) {
this.errors = [];
this.removing = true;
try {
const results = await Promise.allSettled((this.value || []).map((resource) => resource.remove({ params: { propagationPolicy: 'Background' } })));
await this.deleteRelatedResources();
const rejected = results.filter((r) => r.status === 'rejected');
if (rejected.length) {
throw rejected[0].reason;
}
this.close(buttonDone);
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.$emit('errors', err);
if (buttonDone) {
buttonDone(false);
}
} finally {
this.removing = false;
}
},
}
});
</script>
<template>
<div class="mt-10">
<div class="mb-10">
{{ t('promptRemove.attemptingToRemove', { type: resourceType }) }}
<span v-clean-html="resourceNames(names, null, t)" />
</div>
<hr class="mb-10">
<div class="mb-10 text-warning">
<i class="icon icon-warning icon-lg mr-5" />
{{ t('harvester.addons.vmMigration.deleteDialog.warning') }}
</div>
<div
v-for="summary in relatedSummary"
:key="`${ summary.namespace }/${ summary.planName }`"
class="mb-10"
>
<div>• {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
<template v-if="summary.migrations.length">
<div
v-for="migrationName in summary.migrations"
:key="migrationName"
>
<div class="ml-20">
- {{ migrationName }}
</div>
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div>• {{ t('harvester.addons.vmMigration.labels.networkMap') }}:</div>
<template v-if="summary.networkMap">
<div class="ml-20">
- {{ summary.networkMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div>• {{ t('harvester.addons.vmMigration.labels.storageMap') }}:</div>
<template v-if="summary.storageMap">
<div class="ml-20">
- {{ summary.storageMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
</div>
<Banner
v-for="(error, i) in errors"
:key="i"
color="error"
:label="error"
/>
</div>
</template>
-19
View File
@@ -1,6 +1,4 @@
import Parse from 'url-parse'; import Parse from 'url-parse';
import { MANAGEMENT } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { HCI } from '../types'; import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester'; import { PRODUCT_NAME } from '../config/harvester';
import { featureEnabled, getVersion } from '../utils/feature-flags'; import { featureEnabled, getVersion } from '../utils/feature-flags';
@@ -66,23 +64,6 @@ const mutations = {
}; };
const getters = { 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) { getBundleId(state) {
return state.latestBundleId; return state.latestBundleId;
}, },
-43
View File
@@ -1,43 +0,0 @@
export const DEFAULT_FS_FREEZE_DEADLINE = '1s';
/**
* Generate filesystem freeze deadline options with i18n translations
* @param {Function} t - i18n translation function
* @returns {Array} Array of deadline options
*/
export function getFsFreezeDeadlineOptions(t) {
return [
{
label: t('generic.duration.infinite'),
value: '0s'
},
{
label: t('generic.duration.1s'),
value: '1s'
},
{
label: t('generic.duration.5s'),
value: '5s'
},
{
label: t('generic.duration.10s'),
value: '10s'
},
{
label: t('generic.duration.30s'),
value: '30s'
},
{
label: t('generic.duration.1m'),
value: '1m'
},
{
label: t('generic.duration.3m'),
value: '3m'
},
{
label: t('generic.duration.5m'),
value: '5m'
}
];
}
+12 -29
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. * Dynamically toggles SideNav entries based on the enabled status of a specific Addon.
* *
@@ -10,11 +8,9 @@ import { FAVORITE_TYPES } from '@shell/store/prefs';
* @param {String} config.resourceType - The schema ID for addons. * @param {String} config.resourceType - The schema ID for addons.
* @param {String} config.navGroup - The group name in the side nav. * @param {String} config.navGroup - The group name in the side nav.
* @param {Array<String>} config.types - Array of Resource IDs to show/hide. * @param {Array<String>} config.types - Array of Resource IDs to show/hide.
* @param {Boolean} [config.requireSchema=true] - When true, only types with an
* accessible schema are shown. Set false for schema-less virtual types.
*/ */
export function registerAddonSideNav(store, productName, { export function registerAddonSideNav(store, productName, {
addonName, resourceType, navGroup, types, requireSchema = true addonName, resourceType, navGroup, types
}) { }) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -26,17 +22,13 @@ export function registerAddonSideNav(store, productName, {
const kickSideNav = () => { const kickSideNav = () => {
const TRIGGER = 'ui.refresh.trigger'; const TRIGGER = 'ui.refresh.trigger';
// Toggle the trigger a few times so an early kick (fired before the SideNav store.dispatch('type-map/addFavorite', TRIGGER);
// 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.commit('prefs/load', { key: FAVORITE_TYPES, value: [...base, TRIGGER] }); // SideNav component seem to ignore rapid state changes.
setTimeout(() => store.commit('prefs/load', { key: FAVORITE_TYPES, value: [...base] }), 300); // Wait 600ms to ensure the toggle event triggers a re-render.
}, delay); setTimeout(() => {
}); store.dispatch('type-map/removeFavorite', TRIGGER);
}, 600);
}; };
const hasAccessibleSchema = (t) => { const hasAccessibleSchema = (t) => {
@@ -65,7 +57,7 @@ export function registerAddonSideNav(store, productName, {
// Adds or removes the resource IDs from the product visibility whitelist. // Adds or removes the resource IDs from the product visibility whitelist.
const setMenuVisibility = (visible) => { const setMenuVisibility = (visible) => {
const accessibleTypes = visible ? (requireSchema ? types.filter(hasAccessibleSchema) : types) : []; const accessibleTypes = visible ? types.filter(hasAccessibleSchema) : [];
// Always clear first to remove any previously-registered types that are // Always clear first to remove any previously-registered types that are
// no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped). // no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped).
@@ -98,25 +90,16 @@ export function registerAddonSideNav(store, productName, {
// Store is ready. Stop polling. // Store is ready. Stop polling.
clearInterval(waitForStore); clearInterval(waitForStore);
// Watch the addon's enabled status together with the schema availability // Watch the specific addon resource for changes to its enabled status.
// 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.
store.watch( store.watch(
(state, getters) => { (state, getters) => {
const addons = getters[`${ productName }/all`](resourceType); const addons = getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName); const addon = addons.find((a) => a.metadata.name === addonName);
const isEnabled = addon?.spec?.enabled === true;
const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true; return addon?.spec?.enabled === true;
return `${ isEnabled }:${ schemaReady }`;
}, },
() => { (isEnabled) => {
const addons = store.getters[`${ productName }/all`](resourceType); setMenuVisibility(isEnabled);
const addon = addons.find((a) => a.metadata.name === addonName);
setMenuVisibility(addon?.spec?.enabled === true);
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
); );
+4 -17
View File
@@ -73,21 +73,8 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) {
export function buildStorageMapEntries(entries = []) { export function buildStorageMapEntries(entries = []) {
return entries return entries
.filter((entry) => !!entry.target) .filter((entry) => !!entry.target)
.map((entry) => { .map((entry) => ({
const destination = { storageClass: entry.target }; source: { name: entry.name, id: entry.id },
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,
};
});
} }

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