mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
Compare commits
56 Commits
v1.9.0-rc5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63be73b11 | ||
|
|
fabc1cec21 | ||
|
|
ed2b6f2551 | ||
|
|
72282ae50e | ||
|
|
8fba4e7050 | ||
|
|
fbd4a1108f | ||
|
|
823a88c85b | ||
|
|
3a2c6e3a3d | ||
|
|
be6e9b5f06 | ||
|
|
a88bed9eb4 | ||
|
|
f97f14c811 | ||
|
|
f8a29a1c54 | ||
|
|
9a935434c2 | ||
|
|
afe632b723 | ||
|
|
d050f21f9a | ||
|
|
b87f367583 | ||
|
|
92aa8ec603 | ||
|
|
e013534373 | ||
|
|
d23c3d3b72 | ||
|
|
719c33fa12 | ||
|
|
a6bd7ec00e | ||
|
|
ac26fe2f0e | ||
|
|
790cade04d | ||
|
|
2903e86c02 | ||
|
|
2d747e435a | ||
|
|
eadda2a18b | ||
|
|
a178302f73 | ||
|
|
d376bfb3a5 | ||
|
|
7a84e08f99 | ||
|
|
3d39626b93 | ||
|
|
1c3b40b631 | ||
|
|
87baf75301 | ||
|
|
a10d651827 | ||
|
|
11ff7c8a7c | ||
|
|
f6dffe9816 | ||
|
|
b813bc578e | ||
|
|
7c3b2730ab | ||
|
|
429d9e18ad | ||
|
|
dc166d8fc9 | ||
|
|
3ff4534294 | ||
|
|
d4223ad2ae | ||
|
|
44297ada16 | ||
|
|
aea53fdd65 | ||
|
|
9640c87947 | ||
|
|
2cefd7cb78 | ||
|
|
fb6ffa3c0f | ||
|
|
e0436bff28 | ||
|
|
219f39ae52 | ||
|
|
41cf44d231 | ||
|
|
7d09cb7142 | ||
|
|
560643d299 | ||
|
|
c3bb0ea4be | ||
|
|
3c37f04ef6 | ||
|
|
d98db6ba49 | ||
|
|
531ed03796 | ||
|
|
59f260eab8 |
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
@ -1,14 +1,23 @@
|
|||||||
<!-- 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
|
||||||
<!-- Define findings related to the feature or bug issue. -->
|
<!-- Link the issue as harvester/harvester#<issue_number> -->
|
||||||
|
harvester/harvester#<issue_number>
|
||||||
|
|
||||||
|
|
||||||
|
### Test Steps
|
||||||
|
<!-- Provide the test steps for reviewer to verify the issue -->
|
||||||
|
1. Go to ... page
|
||||||
|
|
||||||
### Test screenshot or video
|
### Test screenshot or video
|
||||||
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
|
<!-- Attach screenshot or video of the changes and eventual comparison -->
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
33
.github/workflows/build-standalone-on-merge.yaml
vendored
33
.github/workflows/build-standalone-on-merge.yaml
vendored
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@ -29,3 +30,35 @@ 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"
|
||||||
|
|||||||
40
AGENTS.md
40
AGENTS.md
@ -1,4 +1,4 @@
|
|||||||
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
|
> 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.
|
||||||
|
|
||||||
|
|
||||||
# Project Overview
|
# Project Overview
|
||||||
@ -28,9 +28,7 @@ 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>:
|
<type>: <description>
|
||||||
|
|
||||||
<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.
|
||||||
@ -130,8 +128,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.
|
||||||
- Place source tag above template above style.
|
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
|
||||||
- style tag should contain `lang='scss' scoped`.
|
- The `<style>` tag should include `lang="scss" scoped`.
|
||||||
- `Linting`: Follow the ESLint configuration in the root.
|
- `Linting`: Follow the ESLint configuration in the root.
|
||||||
|
|
||||||
- **File Structure:**
|
- **File Structure:**
|
||||||
@ -163,24 +161,26 @@ To get started, follow the `Development Setup` section.
|
|||||||
|
|
||||||
## Harvester UI Extension Development Guide
|
## Harvester UI Extension Development Guide
|
||||||
|
|
||||||
1. Backward Compatibility
|
### 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:
|
||||||
|
|
||||||
Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
|
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
|
||||||
|
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
|
||||||
|
|
||||||
Check: Use the following getter to verify if the feature is enabled for the current version:
|
```js
|
||||||
|
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>[optional scope]: <description>
|
<type>: <description>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ For Harvester UI extension installation instructions, please refer to the page *
|
|||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
Ensure **Node.js v20 or later** is installed for development and debugging.
|
Ensure **Node.js v24 or later** is installed for development and debugging.
|
||||||
|
|
||||||
### Standalone Mode
|
### Standalone Mode
|
||||||
|
|
||||||
|
|||||||
@ -32,10 +32,11 @@ 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': [2, 'always', 100],
|
'footer-max-line-length': [0, 'always', 100],
|
||||||
},
|
},
|
||||||
// Ignore merge commits and revert commits
|
// Ignore merge commits and revert commits
|
||||||
ignores: [
|
ignores: [
|
||||||
|
|||||||
@ -6,9 +6,7 @@
|
|||||||
- Run `yarn lint:fix` before commits.
|
- Run `yarn lint:fix` before commits.
|
||||||
- Use conventional commit format:
|
- Use conventional commit format:
|
||||||
```
|
```
|
||||||
<type>:
|
<type>: <description>
|
||||||
|
|
||||||
<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.
|
||||||
|
|||||||
@ -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.
|
||||||
- Place source tag above template above style.
|
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
|
||||||
- style tag should contain `lang='scss' scoped`.
|
- The `<style>` tag should include `lang="scss" scoped`.
|
||||||
- `Linting`: Follow the ESLint configuration in the root.
|
- `Linting`: Follow the ESLint configuration in the root.
|
||||||
|
|
||||||
- **File Structure:**
|
- **File Structure:**
|
||||||
@ -49,22 +49,24 @@ To get started, follow the `Development Setup` section.
|
|||||||
|
|
||||||
## Harvester UI Extension Development Guide
|
## Harvester UI Extension Development Guide
|
||||||
|
|
||||||
1. Backward Compatibility
|
### 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:
|
||||||
|
|
||||||
Register: Add a unique [Feature Name] to the corresponding release array in the configuration.
|
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
|
||||||
|
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
|
||||||
|
|
||||||
Check: Use the following getter to verify if the feature is enabled for the current version:
|
```js
|
||||||
|
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>[optional scope]: <description>
|
<type>: <description>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
|
> 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.
|
||||||
|
|
||||||
|
|
||||||
# Project Overview
|
# Project Overview
|
||||||
|
|||||||
16
package.json
16
package.json
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "harvester-ui-extension",
|
"name": "harvester-ui-extension",
|
||||||
"version": "1.9.0-dev",
|
"version": "1.10.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.12-rc.3",
|
"@rancher/shell": "3.0.12-rc.7",
|
||||||
"@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,13 +15,19 @@
|
|||||||
"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.4",
|
"@types/node": "25.9.5",
|
||||||
"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",
|
||||||
@ -29,12 +35,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.24",
|
"@types/lodash": "4.17.25",
|
||||||
"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.15.3",
|
"qs": "6.15.3",
|
||||||
"roarr": "7.21.6",
|
"roarr": "7.21.7",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -271,4 +271,9 @@ 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,6 +116,11 @@ 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>
|
||||||
|
|||||||
@ -55,8 +55,12 @@ 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);
|
||||||
},
|
},
|
||||||
@ -154,14 +158,23 @@ export default {
|
|||||||
:cluster="currentCluster.nameDisplay"
|
:cluster="currentCluster.nameDisplay"
|
||||||
/>
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<span
|
||||||
v-if="versionOptions.length && !isUpgradeInProgress"
|
v-clean-tooltip="{
|
||||||
type="button"
|
content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'),
|
||||||
class="btn bg-warning btn-sm"
|
triggers: ['hover', 'focus', 'touch'],
|
||||||
@click="open"
|
}"
|
||||||
|
class="upgrade-btn-wrap"
|
||||||
|
:tabindex="canUpgrade ? -1 : 0"
|
||||||
>
|
>
|
||||||
<t k="harvester.upgradePage.upgrade" />
|
<button
|
||||||
</button>
|
:disabled="!canUpgrade"
|
||||||
|
type="button"
|
||||||
|
class="btn bg-warning btn-sm"
|
||||||
|
@click="open"
|
||||||
|
>
|
||||||
|
<t k="harvester.upgradePage.upgrade" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ModalWithCard
|
<ModalWithCard
|
||||||
@ -296,6 +309,18 @@ 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;
|
||||||
|
|||||||
59
pkg/harvester/components/MappingsCell.vue
Normal file
59
pkg/harvester/components/MappingsCell.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
networkEntries: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
storageEntries: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mappings-cell">
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in networkEntries"
|
||||||
|
:key="'net-' + idx"
|
||||||
|
class="mapping-entry"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon icon-network"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in storageEntries"
|
||||||
|
:key="'stor-' + idx"
|
||||||
|
class="mapping-entry"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon icon-datastore"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mappings-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,7 +1,6 @@
|
|||||||
<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,
|
||||||
@ -22,74 +21,98 @@ export default {
|
|||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
socket: null,
|
socket: null,
|
||||||
terminal: null,
|
terminal: null,
|
||||||
fitAddon: null,
|
textDecoder: null,
|
||||||
searchAddon: null,
|
fitAddon: null,
|
||||||
webglAddon: null,
|
searchAddon: null,
|
||||||
isOpen: false,
|
webglAddon: null,
|
||||||
isOpening: false,
|
onResize: null,
|
||||||
backlog: [],
|
isOpen: false,
|
||||||
firstTime: true,
|
isOpening: false,
|
||||||
queue: []
|
backlog: [],
|
||||||
|
firstTime: true,
|
||||||
|
queue: [],
|
||||||
|
isDraining: false,
|
||||||
|
drainScheduled: false,
|
||||||
|
drainTimer: null,
|
||||||
|
showSearch: false,
|
||||||
|
searchQuery: '',
|
||||||
|
searchOptions: {
|
||||||
|
caseSensitive: false,
|
||||||
|
regex: false,
|
||||||
|
wholeWord: false,
|
||||||
|
incremental: true,
|
||||||
|
decorations: {
|
||||||
|
matchBackground: '#B45309',
|
||||||
|
matchBorder: '#FDBA74',
|
||||||
|
matchOverviewRuler: '#FDBA74',
|
||||||
|
activeMatchBackground: '#EA580C',
|
||||||
|
activeMatchBorder: '#FED7AA',
|
||||||
|
activeMatchColorOverviewRuler: '#FED7AA',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
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,
|
||||||
fontSize: 12,
|
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
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');
|
const xterm = await import(/* webpackChunkName: "xterm" */ '@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-cursor').trim(),
|
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
|
||||||
foreground: docStyle.getPropertyValue('--terminal-text').trim()
|
foreground: docStyle.getPropertyValue('--terminal-text').trim()
|
||||||
},
|
},
|
||||||
...this.xtermConfig,
|
...this.xtermConfig,
|
||||||
@ -97,25 +120,48 @@ 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);
|
||||||
|
|
||||||
if ( this.webglAddon ) {
|
// The webgl renderer can fail without throwing: the context may be lost
|
||||||
|
// mid-session, or it can attach but silently never paint, leaving the
|
||||||
|
// terminal's helper textarea at 0x0. Detect both cases and fall back to the
|
||||||
|
// canvas renderer so the terminal stays interactive.
|
||||||
|
try {
|
||||||
|
this.webglAddon = new addons.webgl.WebglAddon();
|
||||||
|
|
||||||
|
this.webglAddon.onContextLoss(() => {
|
||||||
|
if (!this.isUnmounted && this.terminal) {
|
||||||
|
this.fallbackToCanvas(terminal, addons);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
@ -123,9 +169,132 @@ 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();
|
||||||
|
|
||||||
@ -140,12 +309,131 @@ 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.value?.getSerialConsolePath }`;
|
return this.serialConsoleUrl;
|
||||||
},
|
},
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
@ -181,6 +469,7 @@ 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'));
|
||||||
@ -195,7 +484,7 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
|
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
|
||||||
this.queue.push(e.detail.data.text());
|
this.enqueueMessage(this.decodeMessageData(e.detail.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.connect();
|
this.socket.connect();
|
||||||
@ -218,22 +507,19 @@ 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();
|
||||||
}
|
}
|
||||||
@ -248,6 +534,44 @@ 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"
|
||||||
@ -257,18 +581,73 @@ export default {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '../../../../node_modules/xterm/css/xterm.css';
|
@import '../../../../node_modules/@xterm/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;
|
||||||
|
|
||||||
.shell-body, .terminal.xterm {
|
.resize-observer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--body-bg) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-search-input {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 420px;
|
||||||
|
flex: 0 1 320px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--body-bg);
|
||||||
|
color: var(--body-text);
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-search-btn {
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
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>
|
||||||
|
|||||||
@ -55,6 +55,10 @@ 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 }`;
|
||||||
|
|
||||||
@ -63,7 +67,7 @@ export default {
|
|||||||
window.open(
|
window.open(
|
||||||
url,
|
url,
|
||||||
'_blank',
|
'_blank',
|
||||||
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
|
`toolbars=0,width=${ popupWidth },height=${ popupHeight },left=${ popupLeft },top=${ popupTop },noreferrer`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
649
pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue
Normal file
649
pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue
Normal file
@ -0,0 +1,649 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, toRefs } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
|
||||||
|
import { isInternalStorageClass } from '../../utils/storage-class';
|
||||||
|
import MappingColumn from './MappingColumn.vue';
|
||||||
|
import StorageDefaultsModal from './StorageDefaultsModal.vue';
|
||||||
|
|
||||||
|
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
|
||||||
|
const DEFAULT_ACCESS_MODE = ACCESS_MODE?.READ_WRITE_MANY ?? 'ReadWriteMany';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
selectedVms: { type: Array, default: () => [] },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
useAllProviderData: { type: Boolean, default: false },
|
||||||
|
existingNetworkMap: { type: Object, default: null },
|
||||||
|
existingStorageMap: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ready']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const vms = ref([]);
|
||||||
|
const harvesterNetworks = ref([]);
|
||||||
|
const storageClasses = ref([]);
|
||||||
|
const allNetworkMaps = ref([]);
|
||||||
|
const allStorageMaps = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
// Storage defaults edit modal state.
|
||||||
|
const showStorageDefaultsModal = ref(false);
|
||||||
|
const editingStorageEntry = ref(null);
|
||||||
|
|
||||||
|
const { networkEntries, storageEntries } = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||||
|
|
||||||
|
const harvesterNetworkOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
|
||||||
|
];
|
||||||
|
|
||||||
|
harvesterNetworks.value.forEach((net) => {
|
||||||
|
const ns = net.metadata?.namespace || '';
|
||||||
|
const name = net.metadata?.name || net.id;
|
||||||
|
const fullName = ns ? `${ ns }/${ name }` : name;
|
||||||
|
|
||||||
|
options.push({ label: fullName, value: fullName });
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageClassOptions = computed(() => {
|
||||||
|
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => {
|
||||||
|
const value = sc.name;
|
||||||
|
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value;
|
||||||
|
let disabled = false;
|
||||||
|
|
||||||
|
if (isInternalStorageClass(value)) {
|
||||||
|
label += ` (${ t('harvester.storage.internal.label') })`;
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!options.find((o) => o.value === 'harvester-longhorn')) {
|
||||||
|
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyNetworkMapTargets = (mapSpec) => {
|
||||||
|
if (!mapSpec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
networkEntries.value.forEach((entry) => {
|
||||||
|
const match = mapSpec.find(
|
||||||
|
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match?.destination) {
|
||||||
|
const dest = match.destination;
|
||||||
|
|
||||||
|
if (dest.type === 'pod') {
|
||||||
|
entry.target = 'pod';
|
||||||
|
} else if (dest.type === 'ignored') {
|
||||||
|
entry.target = 'ignored';
|
||||||
|
} else if (dest.type === 'multus' && dest.name) {
|
||||||
|
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
|
||||||
|
if (!mapSpec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storageEntries.value.forEach((entry) => {
|
||||||
|
const match = mapSpec.find(
|
||||||
|
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match?.destination?.storageClass) {
|
||||||
|
entry.target = match.destination.storageClass;
|
||||||
|
|
||||||
|
if (captureInherited) {
|
||||||
|
entry.inheritedFromProvider = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.destination.volumeMode) {
|
||||||
|
entry.volumeMode = match.destination.volumeMode;
|
||||||
|
|
||||||
|
if (captureInherited) {
|
||||||
|
entry.inheritedVolumeMode = match.destination.volumeMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.destination.accessMode) {
|
||||||
|
entry.accessModes = [match.destination.accessMode];
|
||||||
|
|
||||||
|
if (captureInherited) {
|
||||||
|
entry.inheritedAccessModes = [match.destination.accessMode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markOverridden) {
|
||||||
|
entry.overridden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDefaultNetworkMap = () => {
|
||||||
|
const defaultMap = allNetworkMaps.value.find(
|
||||||
|
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
|
||||||
|
);
|
||||||
|
|
||||||
|
applyNetworkMapTargets(defaultMap?.spec?.map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDefaultStorageMap = () => {
|
||||||
|
const defaultMap = allStorageMaps.value.find(
|
||||||
|
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
|
||||||
|
);
|
||||||
|
|
||||||
|
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
|
||||||
|
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
|
||||||
|
|
||||||
|
// The "Inherited from provider" hint only appears in the migration plan wizard
|
||||||
|
// (where mappings inherit from the provider default), not on the provider creation page.
|
||||||
|
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
|
||||||
|
|
||||||
|
const openStorageDefaults = (entry) => {
|
||||||
|
editingStorageEntry.value = entry;
|
||||||
|
showStorageDefaultsModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeStorageDefaults = () => {
|
||||||
|
showStorageDefaultsModal.value = false;
|
||||||
|
editingStorageEntry.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
|
||||||
|
if (editingStorageEntry.value) {
|
||||||
|
editingStorageEntry.value.volumeMode = volumeMode;
|
||||||
|
editingStorageEntry.value.accessModes = accessModes;
|
||||||
|
|
||||||
|
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
|
||||||
|
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
|
||||||
|
const selectedAccessMode = accessModes?.[0];
|
||||||
|
|
||||||
|
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = computed(() => {
|
||||||
|
if (props.useAllProviderData) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
|
||||||
|
allNetworksMapped.value && allStorageMapped.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(canSave, (val) => {
|
||||||
|
emit('ready', val);
|
||||||
|
});
|
||||||
|
|
||||||
|
// After restore, check if ready
|
||||||
|
if (canSave.value) {
|
||||||
|
emit('ready', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildNetworkEntries = () => {
|
||||||
|
const networkMap = {};
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
const vmName = vm.name || vm.id;
|
||||||
|
|
||||||
|
if (vm.networks && vm.networks.length > 0) {
|
||||||
|
vm.networks.forEach((net) => {
|
||||||
|
const netKey = net.id || net.name || 'default';
|
||||||
|
|
||||||
|
if (!networkMap[netKey]) {
|
||||||
|
networkMap[netKey] = {
|
||||||
|
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
id: net.id || '',
|
||||||
|
vlanId: net.vlanId || '',
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `net-${ netKey }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!networkMap[netKey].usedBy.includes(vmName)) {
|
||||||
|
networkMap[netKey].usedBy.push(vmName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
networkEntries.value = Object.values(networkMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStorageEntries = () => {
|
||||||
|
const datastoreMap = {};
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
const vmName = vm.name || vm.id;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
vm.disks.forEach((disk) => {
|
||||||
|
const ds = disk.datastore;
|
||||||
|
|
||||||
|
if (ds && ds.id) {
|
||||||
|
if (!datastoreMap[ds.id]) {
|
||||||
|
datastoreMap[ds.id] = {
|
||||||
|
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
id: ds.id,
|
||||||
|
type: ds.type || '',
|
||||||
|
capacity: 0,
|
||||||
|
target: '',
|
||||||
|
volumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
accessModes: [DEFAULT_ACCESS_MODE],
|
||||||
|
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
|
||||||
|
inheritedFromProvider: false,
|
||||||
|
overridden: false,
|
||||||
|
usedBy: [],
|
||||||
|
_key: `stor-${ ds.id }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
datastoreMap[ds.id].capacity += disk.capacity || 0;
|
||||||
|
|
||||||
|
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
|
||||||
|
datastoreMap[ds.id].usedBy.push(vmName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
storageEntries.value = Object.values(datastoreMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNetworkEntriesFromProvider = (networksData) => {
|
||||||
|
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
|
||||||
|
name: net.name || net.id,
|
||||||
|
id: net.id || '',
|
||||||
|
vlanId: net.vlanId || '',
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `net-${ net.id || net.name }`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStorageEntriesFromProvider = (datastoresData) => {
|
||||||
|
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
|
||||||
|
name: ds.name || ds.id,
|
||||||
|
id: ds.id || '',
|
||||||
|
type: ds.type || '',
|
||||||
|
capacity: ds.capacity || 0,
|
||||||
|
target: '',
|
||||||
|
volumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
accessModes: [DEFAULT_ACCESS_MODE],
|
||||||
|
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
|
||||||
|
inheritedFromProvider: false,
|
||||||
|
overridden: false,
|
||||||
|
usedBy: [],
|
||||||
|
_key: `stor-${ ds.id || ds.name }`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStorageDetail = (entry) => {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (entry.type) {
|
||||||
|
parts.push(entry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.capacity) {
|
||||||
|
const gb = entry.capacity / (1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
if (gb >= 1024) {
|
||||||
|
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
|
||||||
|
} else {
|
||||||
|
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNetworkMapSpec = (providerRef) => ({
|
||||||
|
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
|
||||||
|
provider: providerRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildStorageMapSpec = (providerRef) => ({
|
||||||
|
map: buildStorageMapEntries(storageEntries.value),
|
||||||
|
provider: providerRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAndReturn = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const providerRef = {
|
||||||
|
source: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.providerName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: 'host',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update existing maps in place (edit mode)
|
||||||
|
if (props.existingNetworkMap && props.existingStorageMap) {
|
||||||
|
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
|
||||||
|
await props.existingNetworkMap.save();
|
||||||
|
|
||||||
|
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
|
||||||
|
await props.existingStorageMap.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
networkMapName: props.existingNetworkMap.metadata.name,
|
||||||
|
storageMapName: props.existingStorageMap.metadata.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOwnerRef = props.provider ? [{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.provider.metadata.name,
|
||||||
|
uid: props.provider.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
}] : [];
|
||||||
|
|
||||||
|
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
metadata: {
|
||||||
|
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
ownerReferences: providerOwnerRef,
|
||||||
|
},
|
||||||
|
spec: buildNetworkMapSpec(providerRef),
|
||||||
|
});
|
||||||
|
|
||||||
|
await networkMap.save();
|
||||||
|
|
||||||
|
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
metadata: {
|
||||||
|
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
ownerReferences: providerOwnerRef,
|
||||||
|
},
|
||||||
|
spec: buildStorageMapSpec(providerRef),
|
||||||
|
});
|
||||||
|
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ saveMappings: saveAndReturn });
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
|
||||||
|
} catch (e) {
|
||||||
|
harvesterNetworks.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
|
||||||
|
} catch (e) {
|
||||||
|
storageClasses.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||||
|
} catch (e) {
|
||||||
|
allNetworkMaps.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||||
|
} catch (e) {
|
||||||
|
allStorageMaps.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
|
||||||
|
try {
|
||||||
|
const providerUid = props.provider?.metadata?.uid;
|
||||||
|
const providerType = props.provider?.spec?.type || 'vsphere';
|
||||||
|
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||||
|
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [networksData, datastoresData] = await Promise.all([
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (props.useAllProviderData) {
|
||||||
|
if (networkEntries.value.length === 0) {
|
||||||
|
buildNetworkEntriesFromProvider(networksData);
|
||||||
|
}
|
||||||
|
if (storageEntries.value.length === 0) {
|
||||||
|
buildStorageEntriesFromProvider(datastoresData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vms.value = props.selectedVms;
|
||||||
|
|
||||||
|
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
|
||||||
|
map[n.id] = n.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||||
|
map[d.id] = { name: d.name, type: d.type || '' };
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
vms.value = vms.value.map((vm) => {
|
||||||
|
const resolved = { ...vm };
|
||||||
|
|
||||||
|
if (resolved.networks) {
|
||||||
|
resolved.networks = resolved.networks.map((n) => ({
|
||||||
|
...n,
|
||||||
|
name: n.name || networkNameMap[n.id] || n.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.disks) {
|
||||||
|
resolved.disks = resolved.disks.map((d) => {
|
||||||
|
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
datastore: d.datastore ? {
|
||||||
|
...d.datastore,
|
||||||
|
name: d.datastore.name || dsInfo?.name || d.datastore.id,
|
||||||
|
type: dsInfo?.type || '',
|
||||||
|
} : d.datastore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (networkEntries.value.length === 0) {
|
||||||
|
buildNetworkEntries();
|
||||||
|
}
|
||||||
|
if (storageEntries.value.length === 0) {
|
||||||
|
buildStorageEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
|
||||||
|
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
|
||||||
|
|
||||||
|
if (!hasExistingNetworkTargets) {
|
||||||
|
if (props.existingNetworkMap?.spec?.map) {
|
||||||
|
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
|
||||||
|
} else {
|
||||||
|
applyDefaultNetworkMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExistingStorageTargets) {
|
||||||
|
if (props.existingStorageMap?.spec?.map) {
|
||||||
|
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
|
||||||
|
} else {
|
||||||
|
applyDefaultStorageMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="configure-mappings"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
|
||||||
|
</p>
|
||||||
|
<div class="mappings-columns">
|
||||||
|
<MappingColumn
|
||||||
|
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
|
||||||
|
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
|
||||||
|
:entries="networkEntries"
|
||||||
|
:options="harvesterNetworkOptions"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
|
||||||
|
:show-used-by="!useAllProviderData"
|
||||||
|
:clearable="useAllProviderData"
|
||||||
|
>
|
||||||
|
<template #source-detail="{ entry }">
|
||||||
|
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
|
||||||
|
</template>
|
||||||
|
</MappingColumn>
|
||||||
|
|
||||||
|
<MappingColumn
|
||||||
|
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
|
||||||
|
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
|
||||||
|
:entries="storageEntries"
|
||||||
|
:options="storageClassOptions"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||||
|
:show-used-by="!useAllProviderData"
|
||||||
|
:clearable="useAllProviderData"
|
||||||
|
:show-volume-settings="true"
|
||||||
|
:inherited-provider-name="inheritedProviderName"
|
||||||
|
@edit-defaults="openStorageDefaults"
|
||||||
|
>
|
||||||
|
<template #source-detail="{ entry }">
|
||||||
|
<span
|
||||||
|
v-if="entry.type || entry.capacity"
|
||||||
|
class="source-detail text-deemphasized"
|
||||||
|
>{{ formatStorageDetail(entry) }}</span>
|
||||||
|
</template>
|
||||||
|
</MappingColumn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StorageDefaultsModal
|
||||||
|
v-if="showStorageDefaultsModal && editingStorageEntry"
|
||||||
|
:storage-class-name="editingStorageEntry.target"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:show-inherited="!!inheritedProviderName"
|
||||||
|
:volume-mode="editingStorageEntry.volumeMode"
|
||||||
|
:access-modes="editingStorageEntry.accessModes"
|
||||||
|
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
|
||||||
|
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
|
||||||
|
@apply="applyStorageDefaults"
|
||||||
|
@close="closeStorageDefaults"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mappings-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
|
||||||
|
gap: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-mappings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
573
pkg/harvester/components/vm-migration/ConfigureProviderStep.vue
Normal file
573
pkg/harvester/components/vm-migration/ConfigureProviderStep.vue
Normal file
@ -0,0 +1,573 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, toRefs } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import { Checkbox } from '@components/Form/Checkbox';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import AsyncButton from '@shell/components/AsyncButton';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { SECRET } from '@shell/config/types';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import { decodeSecretValue } from '../../utils/forklift';
|
||||||
|
|
||||||
|
const CREATE_NEW = '__create_new__';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
createOnly: { type: Boolean, default: false },
|
||||||
|
editMode: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const allProviders = ref([]);
|
||||||
|
const allSecrets = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const testBtnRef = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const testing = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedProvider,
|
||||||
|
providerName,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
skipTlsVerify,
|
||||||
|
vddkInitImage,
|
||||||
|
testPassed,
|
||||||
|
testResult,
|
||||||
|
testError,
|
||||||
|
createdProvider,
|
||||||
|
createdSecret,
|
||||||
|
} = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
|
||||||
|
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
|
||||||
|
|
||||||
|
const providerOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
|
||||||
|
];
|
||||||
|
|
||||||
|
allProviders.value.forEach((p) => {
|
||||||
|
if (!p.spec?.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretRef = p.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const secret = allSecrets.value.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secret?.data?.user || !secret?.data?.password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
label: p.metadata.name,
|
||||||
|
value: p.metadata.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the provider selection changes, populate or clear the fields
|
||||||
|
watch(selectedProvider, (val) => {
|
||||||
|
testPassed.value = false;
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = null;
|
||||||
|
|
||||||
|
if (val === CREATE_NEW) {
|
||||||
|
providerName.value = '';
|
||||||
|
url.value = '';
|
||||||
|
username.value = '';
|
||||||
|
password.value = '';
|
||||||
|
skipTlsVerify.value = false;
|
||||||
|
vddkInitImage.value = '';
|
||||||
|
createdProvider.value = null;
|
||||||
|
createdSecret.value = null;
|
||||||
|
} else {
|
||||||
|
const provider = allProviders.value.find(
|
||||||
|
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (provider) {
|
||||||
|
providerName.value = provider.metadata.name;
|
||||||
|
url.value = provider.spec?.url || '';
|
||||||
|
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
|
||||||
|
|
||||||
|
const secretRef = provider.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const secret = allSecrets.value.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret?.data) {
|
||||||
|
username.value = decodeSecretValue(secret.data.user);
|
||||||
|
password.value = decodeSecretValue(secret.data.password);
|
||||||
|
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createdProvider.value = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset test state when any field changes (only for create new)
|
||||||
|
watch([providerName, url, username, password], () => {
|
||||||
|
if (!isExistingProvider.value) {
|
||||||
|
testPassed.value = false;
|
||||||
|
testResult.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit ready/complete when testPassed changes
|
||||||
|
watch(testPassed, (val) => {
|
||||||
|
emit('ready', val);
|
||||||
|
if (val) {
|
||||||
|
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Emit form-valid when form validity changes
|
||||||
|
watch(isFormValid, (val) => {
|
||||||
|
emit('form-valid', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(testing, (val) => {
|
||||||
|
emit('testing', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const pollProviderReady = async(name) => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
const namespace = FORKLIFT_NAMESPACE;
|
||||||
|
const maxAttempts = 15;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const refreshed = await store.dispatch(`${ inStore }/find`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
id: `${ namespace }/${ name }`,
|
||||||
|
opt: { force: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditions = refreshed?.status?.conditions || [];
|
||||||
|
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
|
||||||
|
const readyCondition = conditions.find((c) => c.type === 'Ready');
|
||||||
|
|
||||||
|
if (connectionCondition) {
|
||||||
|
if (connectionCondition.status === 'True') {
|
||||||
|
return { connected: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyCondition) {
|
||||||
|
if (readyCondition.status === 'True') {
|
||||||
|
return { connected: true };
|
||||||
|
} else if (readyCondition.status === 'False') {
|
||||||
|
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: false, errorMsg: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
|
||||||
|
if (connected) {
|
||||||
|
testPassed.value = true;
|
||||||
|
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(true);
|
||||||
|
} else {
|
||||||
|
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupCreatedResources = async() => {
|
||||||
|
if (createdProvider.value) {
|
||||||
|
try {
|
||||||
|
await createdProvider.value.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
createdProvider.value = null;
|
||||||
|
createdSecret.value = null;
|
||||||
|
} else if (createdSecret.value) {
|
||||||
|
try {
|
||||||
|
await createdSecret.value.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
createdSecret.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async(buttonCb) => {
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = null;
|
||||||
|
testing.value = true;
|
||||||
|
|
||||||
|
if (!providerName.value || !url.value || !username.value || !password.value) {
|
||||||
|
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
// Edit mode: update existing provider URL + secret, then poll
|
||||||
|
if (props.editMode && createdProvider.value) {
|
||||||
|
try {
|
||||||
|
createdProvider.value.spec.url = url.value;
|
||||||
|
|
||||||
|
const vddkImage = vddkInitImage.value?.trim();
|
||||||
|
|
||||||
|
if (vddkImage) {
|
||||||
|
createdProvider.value.spec.settings = {
|
||||||
|
...(createdProvider.value.spec.settings || {}),
|
||||||
|
vddkInitImage: vddkImage,
|
||||||
|
};
|
||||||
|
} else if (createdProvider.value.spec.settings) {
|
||||||
|
delete createdProvider.value.spec.settings.vddkInitImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createdProvider.value.save();
|
||||||
|
|
||||||
|
const secretRef = createdProvider.value.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef && createdSecret.value) {
|
||||||
|
createdSecret.value.data = {
|
||||||
|
user: btoa(username.value),
|
||||||
|
password: btoa(password.value),
|
||||||
|
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||||
|
url: btoa(url.value),
|
||||||
|
};
|
||||||
|
await createdSecret.value.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
|
||||||
|
if (isExistingProvider.value) {
|
||||||
|
try {
|
||||||
|
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new providers, create provider + secret then poll
|
||||||
|
try {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
|
||||||
|
const namespace = FORKLIFT_NAMESPACE;
|
||||||
|
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
|
||||||
|
|
||||||
|
const spec = {
|
||||||
|
type: 'vsphere',
|
||||||
|
url: url.value,
|
||||||
|
secret: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const vddkImage = vddkInitImage.value?.trim();
|
||||||
|
|
||||||
|
if (vddkImage) {
|
||||||
|
spec.settings = { vddkInitImage: vddkImage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
metadata: {
|
||||||
|
name: providerName.value,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
spec,
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.save();
|
||||||
|
createdProvider.value = provider;
|
||||||
|
|
||||||
|
const newSecret = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: SECRET,
|
||||||
|
metadata: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: provider.metadata.name,
|
||||||
|
uid: provider.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newSecret['_type'] = 'Opaque';
|
||||||
|
newSecret['data'] = {
|
||||||
|
user: btoa(username.value),
|
||||||
|
password: btoa(password.value),
|
||||||
|
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||||
|
url: btoa(url.value),
|
||||||
|
};
|
||||||
|
|
||||||
|
await newSecret.save();
|
||||||
|
createdSecret.value = newSecret;
|
||||||
|
|
||||||
|
const result = await pollProviderReady(providerName.value);
|
||||||
|
|
||||||
|
if (!result.connected) {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePollResult(result, buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||||
|
|
||||||
|
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
|
||||||
|
type: SECRET,
|
||||||
|
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
const clickTestButton = () => {
|
||||||
|
testBtnRef.value?.$el?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ testConnection, clickTestButton });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="configure-provider-step"
|
||||||
|
>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="configure-provider-step-content">
|
||||||
|
<h3 class="table-title m-0">
|
||||||
|
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
class="requirements-banner m-0"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
<div class="requirements-banner-content">
|
||||||
|
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
|
||||||
|
<br>
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<div class="configure-provider-step-form">
|
||||||
|
<div v-if="!createOnly && !editMode">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="selectedProvider"
|
||||||
|
required
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
|
||||||
|
:options="providerOptions"
|
||||||
|
:reduce="(opt) => opt.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isExistingProvider || editMode"
|
||||||
|
>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="providerName"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.name')"
|
||||||
|
:disabled="editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="url"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized mt-5">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="vddkInitImage"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
|
||||||
|
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="username"
|
||||||
|
:label="t('harvester.addons.vmMigration.fields.username')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
:label="t('harvester.addons.vmMigration.fields.password')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
v-model:value="skipTlsVerify"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized ml-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<AsyncButton
|
||||||
|
ref="testBtnRef"
|
||||||
|
mode="test"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
|
||||||
|
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
|
||||||
|
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
|
||||||
|
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
|
||||||
|
:action-color="'role-secondary'"
|
||||||
|
:waiting-color="'role-disabled'"
|
||||||
|
@click="testConnection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
v-if="testResult"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
{{ testResult }}
|
||||||
|
</Banner>
|
||||||
|
<Banner
|
||||||
|
v-if="testError"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{{ testError }}
|
||||||
|
</Banner>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{{ err }}
|
||||||
|
</Banner>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.requirements-banner {
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.requirements-banner-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-provider-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-provider-step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.configure-provider-step-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checkbox-label) {
|
||||||
|
color: var(--body-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
252
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
252
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { RcItemCard } from '@components/RcItemCard';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { VOLUME_MODE } from '../../config/types';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
entries: { type: Array, default: () => [] },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
showUsedBy: { type: Boolean, default: false },
|
||||||
|
clearable: { type: Boolean, default: false },
|
||||||
|
// Storage-specific: show the volume/access mode defaults row with an Edit action.
|
||||||
|
showVolumeSettings: { type: Boolean, default: false },
|
||||||
|
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
|
||||||
|
inheritedProviderName: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit-defaults']);
|
||||||
|
|
||||||
|
// Only offer "Remove Map" for entries that already have a target selected;
|
||||||
|
// entries without a selection just show the regular options.
|
||||||
|
const optionsFor = (entry) => {
|
||||||
|
if (!props.clearable || !entry.target) {
|
||||||
|
return props.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
|
||||||
|
value: null,
|
||||||
|
kind: 'highlighted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'divider',
|
||||||
|
disabled: true,
|
||||||
|
kind: 'divider',
|
||||||
|
},
|
||||||
|
...props.options,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatModes = (entry) => {
|
||||||
|
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
|
||||||
|
const accessModes = (entry.accessModes || []).join(', ');
|
||||||
|
|
||||||
|
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mapping-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="mapping-section-title">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="text-deemphasized line-height-20"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RcItemCard
|
||||||
|
v-for="entry in entries"
|
||||||
|
:id="entry._key"
|
||||||
|
:key="entry._key"
|
||||||
|
:variant="'small'"
|
||||||
|
:header="{}"
|
||||||
|
class="bg-light-gray"
|
||||||
|
>
|
||||||
|
<template #item-card-content>
|
||||||
|
<div class="card-content-column">
|
||||||
|
<div class="card-content-row">
|
||||||
|
<div class="mapping-source">
|
||||||
|
<span class="source-name">{{ entry.name }}</span>
|
||||||
|
<slot
|
||||||
|
name="source-detail"
|
||||||
|
:entry="entry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
|
||||||
|
<i
|
||||||
|
class="icon icon-right-arrow-alt"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-target">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="entry.target"
|
||||||
|
:options="optionsFor(entry)"
|
||||||
|
:placeholder="placeholder+'...'"
|
||||||
|
:searchable="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showVolumeSettings && entry.target"
|
||||||
|
class="storage-defaults-row"
|
||||||
|
>
|
||||||
|
<div class="storage-defaults">
|
||||||
|
<div class="storage-defaults-info">
|
||||||
|
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
|
||||||
|
class="text-deemphasized storage-defaults-inherited"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
class="storage-defaults-edit"
|
||||||
|
@click.prevent="emit('edit-defaults', entry)"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
|
||||||
|
<span class="used-by">
|
||||||
|
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</RcItemCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mapping-column {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:deep(.item-card-header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 2;
|
||||||
|
min-width: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.used-by {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-arrow {
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-target {
|
||||||
|
flex: 3;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-defaults-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-defaults {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--body-bg);
|
||||||
|
|
||||||
|
.storage-defaults-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-defaults-summary {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-defaults-inherited {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-defaults-edit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light-gray {
|
||||||
|
background-color: var(--category-active) !important;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
674
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
674
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import SortableTable from '@shell/components/SortableTable';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { BadgeState } from '@components/BadgeState';
|
||||||
|
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { NAMESPACE } from '@shell/config/types';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import {
|
||||||
|
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
|
||||||
|
} from '../../utils/forklift';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
selectedVms: { type: Array, default: () => [] },
|
||||||
|
mappingEntries: { type: Object, default: null },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const vms = computed(() => props.selectedVms);
|
||||||
|
const networkMappings = ref([]);
|
||||||
|
const storageMappings = ref([]);
|
||||||
|
const planName = ref('');
|
||||||
|
const targetNamespace = ref('');
|
||||||
|
const namespaceOptions = ref([]);
|
||||||
|
const existingProviderNames = ref([]);
|
||||||
|
const existingPlanNames = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
// Restore persisted state
|
||||||
|
planName.value = props.stepData.planName;
|
||||||
|
targetNamespace.value = props.stepData.targetNamespace || '';
|
||||||
|
|
||||||
|
const NS = FORKLIFT_NAMESPACE;
|
||||||
|
|
||||||
|
const nameConflictError = computed(() => {
|
||||||
|
const name = (planName.value || '').trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProviderNames.value.includes(name)) {
|
||||||
|
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingPlanNames.value.includes(name)) {
|
||||||
|
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(planName, (val) => {
|
||||||
|
props.stepData.planName = val;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(targetNamespace, (val) => {
|
||||||
|
props.stepData.targetNamespace = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([planName, targetNamespace, nameConflictError], () => {
|
||||||
|
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const formatNetworkTarget = (target = '') => {
|
||||||
|
if (target === 'pod') {
|
||||||
|
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'ignored') {
|
||||||
|
return t('harvester.addons.vmMigration.generic.ignored');
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
|
||||||
|
|
||||||
|
const totalMemoryGB = computed(() => {
|
||||||
|
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
|
||||||
|
|
||||||
|
return mbToGB(totalMB);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalStorageGB = computed(() => {
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bytesToGB(totalBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const vmRows = computed(() => {
|
||||||
|
return vms.value.map((vm) => {
|
||||||
|
const cpus = vm.cpuCount || vm.numCPU || 0;
|
||||||
|
const memMB = vm.memoryMB || vm.memory || 0;
|
||||||
|
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||||
|
|
||||||
|
let totalDiskBytes = 0;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||||
|
const os = vm.guestName || vm.guestOS || vm.os || '-';
|
||||||
|
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||||
|
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||||
|
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
|
||||||
|
|
||||||
|
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||||
|
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||||
|
|
||||||
|
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
|
||||||
|
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||||
|
id: vm.id,
|
||||||
|
name: vm.name || vm.id,
|
||||||
|
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||||
|
os,
|
||||||
|
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
|
||||||
|
resourcesSub: `${ memGB } • ${ diskDisplay }`,
|
||||||
|
powerState,
|
||||||
|
powerStateClass,
|
||||||
|
networkMappings: vmNetworkMappings,
|
||||||
|
storageMappings: vmStorageMappings,
|
||||||
|
networkMapping: networkMappingSort,
|
||||||
|
storageMapping: storageMappingSort,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const vmHeaders = [
|
||||||
|
{
|
||||||
|
name: 'vmName',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
|
||||||
|
value: 'name',
|
||||||
|
sort: ['name'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'os',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
|
||||||
|
value: 'os',
|
||||||
|
sort: ['os'],
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resources',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
|
||||||
|
value: 'resources',
|
||||||
|
sort: false,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'powerState',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
|
||||||
|
value: 'powerState',
|
||||||
|
sort: ['powerState'],
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'networkMapping',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
|
||||||
|
value: 'networkMapping',
|
||||||
|
sort: ['networkMapping'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'storageMapping',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
|
||||||
|
value: 'storageMapping',
|
||||||
|
sort: ['storageMapping'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'actions',
|
||||||
|
label: ' ',
|
||||||
|
align: 'right',
|
||||||
|
sort: false,
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const onEditSelection = () => emit('edit-selection');
|
||||||
|
const onEditMappings = () => emit('edit-mappings');
|
||||||
|
const onRemoveVm = (id) => emit('remove-vm', id);
|
||||||
|
|
||||||
|
const startMigrationAction = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const providerRef = {
|
||||||
|
source: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.providerName,
|
||||||
|
namespace: NS,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Provider',
|
||||||
|
name: 'host',
|
||||||
|
namespace: NS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkMapName = `${ planName.value }-network-map`;
|
||||||
|
const storageMapName = `${ planName.value }-storage-map`;
|
||||||
|
|
||||||
|
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
metadata: { name: networkMapName, namespace: NS },
|
||||||
|
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
await networkMap.save();
|
||||||
|
|
||||||
|
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
metadata: { name: storageMapName, namespace: NS },
|
||||||
|
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
const plan = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_PLAN,
|
||||||
|
metadata: { name: planName.value, namespace: NS },
|
||||||
|
spec: {
|
||||||
|
provider: providerRef,
|
||||||
|
map: {
|
||||||
|
network: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'NetworkMap',
|
||||||
|
name: networkMapName,
|
||||||
|
namespace: NS,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'StorageMap',
|
||||||
|
name: storageMapName,
|
||||||
|
namespace: NS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetNamespace: targetNamespace.value,
|
||||||
|
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
|
||||||
|
warm: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await plan.save();
|
||||||
|
|
||||||
|
// Kick off the first migration through the model so the Migration payload
|
||||||
|
// lives in a single place (also reused by the dashboard start/restart action).
|
||||||
|
await plan.startMigration();
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
|
||||||
|
|
||||||
|
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
|
||||||
|
} catch (e) {
|
||||||
|
namespaceOptions.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
|
||||||
|
.map((p) => p.metadata?.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
|
||||||
|
.map((p) => p.metadata?.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (props.mappingEntries) {
|
||||||
|
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
|
||||||
|
source: entry.name || entry.id,
|
||||||
|
target: entry.target || '',
|
||||||
|
usedBy: entry.usedBy || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
|
||||||
|
source: entry.name || entry.id,
|
||||||
|
target: entry.target || '',
|
||||||
|
usedBy: entry.usedBy || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
defineExpose({ startMigration: startMigrationAction });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="review-migration"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="review-migration-content"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-if="nameConflictError"
|
||||||
|
color="error"
|
||||||
|
:label="nameConflictError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Migration Details Summary -->
|
||||||
|
<div class="migration-details">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
|
||||||
|
</h3>
|
||||||
|
<span class="section-description text-deemphasized">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="name-row">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="planName"
|
||||||
|
class="name-input"
|
||||||
|
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="targetNamespace"
|
||||||
|
class="namespace-select"
|
||||||
|
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
|
||||||
|
:options="namespaceOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row">
|
||||||
|
<div class="migration-mode-column">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
|
||||||
|
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
|
||||||
|
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
|
||||||
|
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-column">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
|
||||||
|
<span class="detail-value">{{ providerName }}</span>
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
|
||||||
|
<span class="detail-value">{{ vms.length }}</span>
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
|
||||||
|
<span class="detail-value">{{ totalVCpu }}</span>
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
|
||||||
|
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
|
||||||
|
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Virtual Machines -->
|
||||||
|
<div class="vm-section">
|
||||||
|
<SortableTable
|
||||||
|
class="vm-sortable-table"
|
||||||
|
:rows="vmRows"
|
||||||
|
:headers="vmHeaders"
|
||||||
|
:search="false"
|
||||||
|
:table-actions="false"
|
||||||
|
:row-actions="false"
|
||||||
|
:groupable="false"
|
||||||
|
:paging="true"
|
||||||
|
key-field="_key"
|
||||||
|
>
|
||||||
|
<template #header-left>
|
||||||
|
<h4 class="section-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
|
||||||
|
</h4>
|
||||||
|
</template>
|
||||||
|
<template #header-right>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="edit-selection-link"
|
||||||
|
@click.prevent="onEditSelection"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmName="{ row }">
|
||||||
|
<div class="vm-name-cell">
|
||||||
|
<span class="vm-name">{{ row.name }}</span>
|
||||||
|
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:resources="{ row }">
|
||||||
|
<span>{{ row.resources }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:powerState="{ row }">
|
||||||
|
<BadgeState
|
||||||
|
:label="row.powerState"
|
||||||
|
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:networkMapping="{ row }">
|
||||||
|
<template v-if="row.networkMappings.length">
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in row.networkMappings"
|
||||||
|
:key="'net-' + i"
|
||||||
|
class="mapping-cell"
|
||||||
|
>
|
||||||
|
<span class="mapping-source">{{ m.source }}</span>
|
||||||
|
<span class="mapping-target text-deemphasized">→ {{ formatNetworkTarget(m.target) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-deemphasized"
|
||||||
|
>-</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:storageMapping="{ row }">
|
||||||
|
<template v-if="row.storageMappings.length">
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in row.storageMappings"
|
||||||
|
:key="'stor-' + i"
|
||||||
|
class="mapping-cell"
|
||||||
|
>
|
||||||
|
<span class="mapping-source">{{ m.source }}</span>
|
||||||
|
<span class="mapping-target text-deemphasized">→ {{ m.target }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-deemphasized"
|
||||||
|
>-</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:actions="{ row }">
|
||||||
|
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
|
||||||
|
<rc-dropdown-trigger
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
|
||||||
|
>
|
||||||
|
<i class="icon icon-actions" />
|
||||||
|
</rc-dropdown-trigger>
|
||||||
|
<template #dropdownCollection>
|
||||||
|
<rc-dropdown-item @click="onEditMappings">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
|
||||||
|
</rc-dropdown-item>
|
||||||
|
<rc-dropdown-item
|
||||||
|
class="text-error"
|
||||||
|
@click="onRemoveVm(row.id)"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
|
||||||
|
</rc-dropdown-item>
|
||||||
|
</template>
|
||||||
|
</rc-dropdown>
|
||||||
|
</template>
|
||||||
|
<template #no-rows>
|
||||||
|
<tr class="no-rows">
|
||||||
|
<td
|
||||||
|
:colspan="vmHeaders.length"
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="edit-selection-link"
|
||||||
|
@click.prevent="onEditSelection"
|
||||||
|
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</SortableTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error banner -->
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.review-migration {
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-migration-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px clamp(12px, 1vw, 96px);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px clamp(12px, 1vw, 96px);
|
||||||
|
line-height: 20px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-mode-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.cold-migration-badge {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(224, 191, 90, 0.16);
|
||||||
|
color: #E0BF5A;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.theme-light & {
|
||||||
|
background: rgba(168, 128, 26, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
justify-content: start;
|
||||||
|
align-content: start;
|
||||||
|
column-gap: clamp(24px, 4vw, 64px);
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-selection-link {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-sortable-table {
|
||||||
|
:deep(.fixed-header-actions) {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(td) {
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th:first-child),
|
||||||
|
:deep(td:first-child) {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-cell {
|
||||||
|
.mapping-target {
|
||||||
|
margin-left: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-cell + .mapping-cell {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
668
pkg/harvester/components/vm-migration/SelectVmsStep.vue
Normal file
668
pkg/harvester/components/vm-migration/SelectVmsStep.vue
Normal file
@ -0,0 +1,668 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
|
||||||
|
} from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import SortableTable from '@shell/components/SortableTable';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { BadgeState } from '@components/BadgeState';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { bytesToGB, mbToGB } from '../../utils/forklift';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete', 'loading']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const selectedVMs = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
// Restored from stepData so friendly names survive step navigation / remount.
|
||||||
|
const networkMap = ref(props.stepData.networkMap || {});
|
||||||
|
const datastoreMap = ref(props.stepData.datastoreMap || {});
|
||||||
|
const sortableTableRef = ref(null);
|
||||||
|
const allVMsSelected = ref(false);
|
||||||
|
const errors = ref([]);
|
||||||
|
let skipNextSelectionEvent = false;
|
||||||
|
|
||||||
|
const lastFetchedAt = ref(null);
|
||||||
|
const now = ref(Date.now());
|
||||||
|
|
||||||
|
const formatElapsed = (ms) => {
|
||||||
|
const totalMinutes = Math.floor(ms / 60000);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0 && minutes > 0) {
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastSyncedTime = computed(() => {
|
||||||
|
if (!lastFetchedAt.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatElapsed(now.value - lastFetchedAt.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowTimer = setInterval(() => {
|
||||||
|
now.value = Date.now();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(nowTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore selection state from stepData
|
||||||
|
if (selectedVMIds.value.size > 0) {
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmCount = computed(() => discoveredVMs.value.length);
|
||||||
|
const selectedCount = computed(() => selectedVMs.value.length);
|
||||||
|
|
||||||
|
const showSelectAllBanner = computed(() => {
|
||||||
|
if (allVMsSelected.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagedRows = sortableTableRef.value?.pagedRows || [];
|
||||||
|
|
||||||
|
if (pagedRows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
return allPageSelected && tableRows.value.length > pagedRows.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
||||||
|
|
||||||
|
const headers = ref([
|
||||||
|
{
|
||||||
|
name: 'vmName',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||||
|
value: 'vmName',
|
||||||
|
sort: ['vmName'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'os',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
|
||||||
|
value: 'os',
|
||||||
|
sort: ['os'],
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resources',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
|
||||||
|
value: 'resources',
|
||||||
|
sort: false,
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'powerState',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
|
||||||
|
value: 'powerState',
|
||||||
|
sort: ['powerState'],
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'network',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
|
||||||
|
value: 'network',
|
||||||
|
sort: ['network'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'datastore',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
|
||||||
|
value: 'datastore',
|
||||||
|
sort: ['datastore'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buildTableRows = () => {
|
||||||
|
return discoveredVMs.value.map((vm) => {
|
||||||
|
const cpus = vm.cpuCount || vm.numCPU || '-';
|
||||||
|
const memMB = vm.memoryMB || vm.memory || 0;
|
||||||
|
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||||
|
|
||||||
|
let totalDiskBytes = 0;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||||
|
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||||
|
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
let networks = [];
|
||||||
|
|
||||||
|
if (vm.networks && vm.networks.length > 0) {
|
||||||
|
networks = vm.networks.map((n) => ({
|
||||||
|
name: n.name || networkMap.value[n.id] || n.id || n,
|
||||||
|
id: n.id || '',
|
||||||
|
vlanId: n.vlanId || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const datastores = [];
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
vm.disks.forEach((d) => {
|
||||||
|
const dsId = d.datastore?.id || d.datastore?.name;
|
||||||
|
|
||||||
|
if (dsId && !seen.has(dsId)) {
|
||||||
|
seen.add(dsId);
|
||||||
|
datastores.push({
|
||||||
|
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
|
||||||
|
id: d.datastore?.id || '',
|
||||||
|
type: d.datastore?.type || '',
|
||||||
|
capacity: d.capacity || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_original: vm,
|
||||||
|
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||||
|
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
|
||||||
|
vmName: vm.name || vm.metadata?.name || '-',
|
||||||
|
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||||
|
os: vm.guestName || vm.guestOS || vm.os || '-',
|
||||||
|
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
|
||||||
|
resourcesSub: `${ memGB } • ${ diskDisplay }`,
|
||||||
|
powerState,
|
||||||
|
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
|
||||||
|
networks,
|
||||||
|
network: networks.map((n) => n.name).join(', ') || '-',
|
||||||
|
datastores,
|
||||||
|
datastore: datastores.map((d) => d.name).join(', ') || '-',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (rows) => {
|
||||||
|
if (skipNextSelectionEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPageRows = sortableTableRef.value?.pagedRows || [];
|
||||||
|
const selectedKeys = new Set(rows.map((r) => r._key));
|
||||||
|
|
||||||
|
currentPageRows.forEach((row) => {
|
||||||
|
const vmId = row._original?.id;
|
||||||
|
|
||||||
|
if (selectedKeys.has(row._key)) {
|
||||||
|
selectedVMIds.value.add(vmId);
|
||||||
|
} else {
|
||||||
|
selectedVMIds.value.delete(vmId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
allVMsSelected.value = false;
|
||||||
|
selectedVMIds.value = new Set();
|
||||||
|
selectedVMs.value = [];
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
table.clearSelection();
|
||||||
|
|
||||||
|
if (typeof table.setPage === 'function') {
|
||||||
|
table.setPage(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllVMs = () => {
|
||||||
|
allVMsSelected.value = true;
|
||||||
|
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
|
||||||
|
selectedVMs.value = discoveredVMs.value.slice();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Moves currently-selected VMs to the first page(s) and re-checks them.
|
||||||
|
const sortSelectedToFront = () => {
|
||||||
|
if (selectedVMIds.value.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableRows.value.forEach((row) => {
|
||||||
|
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
headers.value = headers.value.map((h) => (
|
||||||
|
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
|
||||||
|
));
|
||||||
|
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof table.changeSort === 'function') {
|
||||||
|
table.changeSort('vmName', false);
|
||||||
|
} else if (typeof table.setPage === 'function') {
|
||||||
|
table.setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sortableTableRef.value?.page,
|
||||||
|
() => {
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
const isLoading = computed(() => loading.value || refreshing.value);
|
||||||
|
|
||||||
|
watch(isLoading, (val) => {
|
||||||
|
emit('loading', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const fetchVMs = async() => {
|
||||||
|
if (!props.provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
const providerUid = props.provider.metadata.uid;
|
||||||
|
const providerType = props.provider.spec?.type || 'vsphere';
|
||||||
|
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||||
|
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Array.isArray(vmsResp)) {
|
||||||
|
discoveredVMs.value = vmsResp;
|
||||||
|
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
|
||||||
|
discoveredVMs.value = vmsResp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
|
||||||
|
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
|
||||||
|
|
||||||
|
networkMap.value = networks.reduce((map, n) => {
|
||||||
|
map[n.id] = n.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
datastoreMap.value = datastores.reduce((map, d) => {
|
||||||
|
map[d.id] = d.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
props.stepData.networkMap = networkMap.value;
|
||||||
|
props.stepData.datastoreMap = datastoreMap.value;
|
||||||
|
|
||||||
|
lastFetchedAt.value = Date.now();
|
||||||
|
tableRows.value = buildTableRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshVMs = async() => {
|
||||||
|
refreshing.value = true;
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
const previousSelectedIds = new Set(selectedVMIds.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
errors.value = [];
|
||||||
|
await fetchVMs();
|
||||||
|
} catch (e) {
|
||||||
|
discoveredVMs.value = [];
|
||||||
|
tableRows.value = [];
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedVMIds.value = new Set(
|
||||||
|
discoveredVMs.value
|
||||||
|
.filter((vm) => previousSelectedIds.has(vm.id))
|
||||||
|
.map((vm) => vm.id)
|
||||||
|
);
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
|
||||||
|
props.stepData.discoveredVMs = discoveredVMs.value;
|
||||||
|
props.stepData.selectedVMIds = selectedVMIds.value;
|
||||||
|
props.stepData.tableRows = tableRows.value;
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
refreshing.value = false;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
if (discoveredVMs.value.length > 0) {
|
||||||
|
if (!lastFetchedAt.value) {
|
||||||
|
lastFetchedAt.value = Date.now();
|
||||||
|
}
|
||||||
|
tableRows.value = buildTableRows();
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
|
||||||
|
await nextTick();
|
||||||
|
sortSelectedToFront();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchVMs();
|
||||||
|
} catch (e) {
|
||||||
|
discoveredVMs.value = [];
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit initial complete if VMs are already selected (restored from stepData)
|
||||||
|
if (selectedVMs.value.length > 0) {
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading || refreshing" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="select-vms-step"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
|
||||||
|
<router-link
|
||||||
|
v-if="provider"
|
||||||
|
class="provider-link"
|
||||||
|
:to="provider._detailLocation"
|
||||||
|
>
|
||||||
|
{{ providerName }}
|
||||||
|
</router-link>
|
||||||
|
<br>
|
||||||
|
<span
|
||||||
|
v-if="lastSyncedTime"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
class="text-bold"
|
||||||
|
:class="{ disabled: refreshing }"
|
||||||
|
@click.prevent="refreshVMs"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<!-- Discovered VMs table -->
|
||||||
|
<SortableTable
|
||||||
|
ref="sortableTableRef"
|
||||||
|
:rows="tableRows"
|
||||||
|
:headers="headers"
|
||||||
|
:search="true"
|
||||||
|
:table-actions="true"
|
||||||
|
:row-actions="false"
|
||||||
|
:groupable="false"
|
||||||
|
:paging="true"
|
||||||
|
key-field="_key"
|
||||||
|
@selection="onSelect"
|
||||||
|
>
|
||||||
|
<template #header-left>
|
||||||
|
<div class="vm-table-title">
|
||||||
|
<h3 class="m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||||
|
</h3>
|
||||||
|
<span class="selected-actions">
|
||||||
|
<span class="text-deemphasized">
|
||||||
|
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
|
||||||
|
</span>
|
||||||
|
<template v-if="selectedCount > 0">
|
||||||
|
<span class="text-deemphasized">|</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="clearSelection"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmName="{ row }">
|
||||||
|
<div class="vm-name-cell">
|
||||||
|
<span class="vm-name">{{ row.vmName }}</span>
|
||||||
|
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:resources="{ row }">
|
||||||
|
<span>{{ row.resourcesDisplay }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:powerState="{ row }">
|
||||||
|
<BadgeState
|
||||||
|
:label="row.powerState"
|
||||||
|
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:network="{ row }">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(net, i) in row.networks"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mt-4': i > 0 }"
|
||||||
|
>
|
||||||
|
<span>{{ net.name }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ net.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:datastore="{ row }">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(ds, i) in row.datastores"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mt-4': i > 0 }"
|
||||||
|
>
|
||||||
|
<span>{{ ds.name }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ ds.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SortableTable>
|
||||||
|
|
||||||
|
<Teleport
|
||||||
|
v-if="showSelectAllBanner && theadElement"
|
||||||
|
:to="theadElement"
|
||||||
|
>
|
||||||
|
<tr class="select-all-banner-row">
|
||||||
|
<td
|
||||||
|
:colspan="headers.length + 1"
|
||||||
|
class="select-all-banner-cell"
|
||||||
|
>
|
||||||
|
<template v-if="allVMsSelected">
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="clearSelection"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="selectAllVMs"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.vm-table-title {
|
||||||
|
.text-deemphasized {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-vms-step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-all-banner-row) {
|
||||||
|
.select-all-banner-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: var(--sortable-table-header-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
181
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<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>
|
||||||
@ -73,7 +73,9 @@ const FEATURE_FLAGS = {
|
|||||||
'supportFilesystem',
|
'supportFilesystem',
|
||||||
'disableResourcePooling',
|
'disableResourcePooling',
|
||||||
'expandOnlineEncryptedVolume',
|
'expandOnlineEncryptedVolume',
|
||||||
'longhornV2HugepageSettings'
|
'longhornV2HugepageSettings',
|
||||||
|
'staticIPForVM',
|
||||||
|
'fsFreezeDeadline',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,13 @@ import {
|
|||||||
VM_IMPORT_SOURCE_O_STATUS,
|
VM_IMPORT_SOURCE_O_STATUS,
|
||||||
VM_IMPORT_SOURCE_OVA_URL,
|
VM_IMPORT_SOURCE_OVA_URL,
|
||||||
VM_IMPORT_SOURCE_OVA_STATUS,
|
VM_IMPORT_SOURCE_OVA_STATUS,
|
||||||
|
FORKLIFT_PROVIDER_TYPE,
|
||||||
|
FORKLIFT_PROVIDER_URL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
FORKLIFT_PLAN_TARGET_NS,
|
||||||
|
FORKLIFT_PLAN_VM_COUNT,
|
||||||
|
FORKLIFT_MIGRATION_PLAN,
|
||||||
} from './table-headers';
|
} 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';
|
||||||
@ -1319,4 +1326,187 @@ export function init($plugin, store) {
|
|||||||
exact: false,
|
exact: false,
|
||||||
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
||||||
});
|
});
|
||||||
|
// ===========================================================================
|
||||||
|
// Forklift Addon UI Flow
|
||||||
|
// ===========================================================================
|
||||||
|
weightGroup('vmMigration', 0, false);
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
headers(HCI.FORKLIFT_PROVIDER, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_PROVIDER_TYPE,
|
||||||
|
FORKLIFT_PROVIDER_URL,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_PROVIDER, {
|
||||||
|
resource: HCI.FORKLIFT_PROVIDER,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_PROVIDER,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.provider',
|
||||||
|
group: 'vmMigration',
|
||||||
|
namespaced: true,
|
||||||
|
weight: 100,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NetworkMap
|
||||||
|
headers(HCI.FORKLIFT_NETWORK_MAP, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_NETWORK_MAP, {
|
||||||
|
resource: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// StorageMap
|
||||||
|
headers(HCI.FORKLIFT_STORAGE_MAP, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_STORAGE_MAP, {
|
||||||
|
resource: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plan
|
||||||
|
headers(HCI.FORKLIFT_PLAN, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_PLAN_TARGET_NS,
|
||||||
|
FORKLIFT_PLAN_VM_COUNT,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_PLAN, {
|
||||||
|
resource: HCI.FORKLIFT_PLAN,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PLAN }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_PLAN,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.plan',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PLAN }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migration
|
||||||
|
headers(HCI.FORKLIFT_MIGRATION, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MIGRATION_PLAN,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_MIGRATION, {
|
||||||
|
resource: HCI.FORKLIFT_MIGRATION,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_MIGRATION,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.migration',
|
||||||
|
group: 'vmMigration::Configurations',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
|
||||||
|
virtualType({
|
||||||
|
name: 'forklift-create',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
|
||||||
|
group: 'vmMigration',
|
||||||
|
namespaced: true,
|
||||||
|
weight: 200,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable SideNav based on Forklift Addon Status
|
||||||
|
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
|
||||||
|
// is registered with requireSchema:false to still be gated by the addon status.
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration',
|
||||||
|
requireSchema: false,
|
||||||
|
types: ['forklift-create']
|
||||||
|
});
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration',
|
||||||
|
types: [
|
||||||
|
HCI.FORKLIFT_PROVIDER,
|
||||||
|
]
|
||||||
|
});
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration::Configurations',
|
||||||
|
types: [
|
||||||
|
HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
HCI.FORKLIFT_PLAN,
|
||||||
|
HCI.FORKLIFT_MIGRATION,
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// ===========================================================================
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export const ADD_ONS = {
|
|||||||
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
||||||
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
||||||
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
||||||
|
FORKLIFT_OPERATOR: 'forklift-operator',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CSI_SECRETS = {
|
export const CSI_SECRETS = {
|
||||||
@ -92,6 +93,9 @@ export const CSI_SECRETS = {
|
|||||||
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||||
|
export const FORKLIFT_NAMESPACE = 'forklift';
|
||||||
|
|
||||||
// Some harvester CRD type is not equal to model file name, define the mapping here
|
// Some harvester CRD type is not equal to model file name, define the mapping here
|
||||||
export const HARVESTER_CRD_MAP = {
|
export const HARVESTER_CRD_MAP = {
|
||||||
node: HCI.HOST,
|
node: HCI.HOST,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ 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',
|
||||||
@ -80,8 +81,10 @@ 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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
|
|||||||
sort: 'status.status',
|
sort: 'status.status',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Forklift table headers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Provider type column in forklift.konveyor.io.provider list page
|
||||||
|
export const FORKLIFT_PROVIDER_TYPE = {
|
||||||
|
name: 'providerType',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
|
||||||
|
value: 'spec.type',
|
||||||
|
sort: 'spec.type',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider URL column in forklift.konveyor.io.provider list page
|
||||||
|
export const FORKLIFT_PROVIDER_URL = {
|
||||||
|
name: 'providerUrl',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
|
||||||
|
value: 'spec.url',
|
||||||
|
sort: 'spec.url',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source provider column in forklift network/storage map list page
|
||||||
|
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
|
||||||
|
name: 'sourceProvider',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
|
||||||
|
value: 'spec.provider.source.name',
|
||||||
|
sort: 'spec.provider.source.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Destination provider column in forklift network/storage map list page
|
||||||
|
export const FORKLIFT_MAP_DEST_PROVIDER = {
|
||||||
|
name: 'destProvider',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
|
||||||
|
value: 'spec.provider.destination.name',
|
||||||
|
sort: 'spec.provider.destination.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Target namespace column in forklift.konveyor.io.plan list page
|
||||||
|
export const FORKLIFT_PLAN_TARGET_NS = {
|
||||||
|
name: 'targetNamespace',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
|
||||||
|
value: 'spec.targetNamespace',
|
||||||
|
sort: 'spec.targetNamespace',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// VM count column in forklift.konveyor.io.plan list page
|
||||||
|
export const FORKLIFT_PLAN_VM_COUNT = {
|
||||||
|
name: 'vmCount',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
|
||||||
|
value: 'spec.vms.length',
|
||||||
|
sort: 'spec.vms.length',
|
||||||
|
align: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plan reference column in forklift.konveyor.io.migration list page
|
||||||
|
export const FORKLIFT_MIGRATION_PLAN = {
|
||||||
|
name: 'plan',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
|
||||||
|
value: 'spec.plan.name',
|
||||||
|
sort: 'spec.plan.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|||||||
@ -15,6 +15,13 @@ 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',
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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';
|
||||||
|
|
||||||
@ -454,10 +455,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.CLUSTER_NETWORK] || 'mgmt';
|
nad?.metadata?.labels?.[HCI_ANNOTATIONS.CLUSTER_NETWORK] || 'mgmt';
|
||||||
|
|
||||||
const nadType =
|
const nadType =
|
||||||
nad?.metadata?.labels?.[HCI.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
|
nad?.metadata?.labels?.[HCI_ANNOTATIONS.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
|
||||||
const overlayNodeId = `overlay-${ provider }`;
|
const overlayNodeId = `overlay-${ provider }`;
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
|
|||||||
133
pkg/harvester/dialog/EnableSriovGpuDevice.vue
Normal file
133
pkg/harvester/dialog/EnableSriovGpuDevice.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<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,13 +2,16 @@
|
|||||||
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 } from '@shell/config/types';
|
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } 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',
|
||||||
@ -16,7 +19,7 @@ export default {
|
|||||||
emits: ['close'],
|
emits: ['close'],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AsyncButton, Card, LabeledInput, LabeledSelect, Banner
|
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
@ -28,14 +31,19 @@ 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: '',
|
||||||
errors: [],
|
shareable: false,
|
||||||
allPVCs: [],
|
errors: [],
|
||||||
|
allPVCs: [],
|
||||||
|
allStorageClasses: [],
|
||||||
|
allVMs: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -50,6 +58,31 @@ 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
|
||||||
@ -61,6 +94,14 @@ 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;
|
||||||
})
|
})
|
||||||
@ -73,19 +114,62 @@ 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 res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false);
|
const input = { volumeSourceName: this.volumeName, diskName: this.diskName };
|
||||||
|
|
||||||
|
if (this.isShareableCapable && this.shareable) {
|
||||||
|
input.shareable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.actionResource.doAction('addVolume', input, {}, false);
|
||||||
|
|
||||||
if (res._status === 200 || res._status === 204) {
|
if (res._status === 200 || res._status === 204) {
|
||||||
this.$store.dispatch('growl/success', {
|
this.$store.dispatch('growl/success', {
|
||||||
@ -141,6 +225,19 @@ 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"
|
||||||
|
|||||||
@ -5,6 +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 { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HarvesterBackupModal',
|
name: 'HarvesterBackupModal',
|
||||||
@ -15,6 +17,7 @@ export default {
|
|||||||
AsyncButton,
|
AsyncButton,
|
||||||
Card,
|
Card,
|
||||||
LabeledInput,
|
LabeledInput,
|
||||||
|
LabeledSelect,
|
||||||
Banner
|
Banner
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -27,8 +30,9 @@ export default {
|
|||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
backUpName: '',
|
backUpName: '',
|
||||||
errors: []
|
errors: [],
|
||||||
|
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -37,12 +41,22 @@ 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');
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -51,7 +65,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const res = await this.actionResource.doAction(
|
const res = await this.actionResource.doAction(
|
||||||
'backup',
|
'backup',
|
||||||
{ name: this.backUpName },
|
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
|
||||||
{},
|
{},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@ -100,9 +114,18 @@ 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"
|
||||||
|
|||||||
@ -5,8 +5,10 @@ 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',
|
||||||
@ -17,6 +19,7 @@ export default {
|
|||||||
AsyncButton,
|
AsyncButton,
|
||||||
Card,
|
Card,
|
||||||
LabeledInput,
|
LabeledInput,
|
||||||
|
LabeledSelect,
|
||||||
Banner
|
Banner
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -31,7 +34,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
snapshotName: '',
|
snapshotName: '',
|
||||||
snapshotNamespace: '',
|
snapshotNamespace: '',
|
||||||
errors: []
|
errors: [],
|
||||||
|
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -40,13 +44,23 @@ 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');
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -60,7 +74,8 @@ export default {
|
|||||||
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
|
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
source: {
|
fsFreezeDeadline: this.fsFreezeDeadline,
|
||||||
|
source: {
|
||||||
apiGroup: 'kubevirt.io',
|
apiGroup: 'kubevirt.io',
|
||||||
kind: 'VirtualMachine',
|
kind: 'VirtualMachine',
|
||||||
name: this.actionResource.metadata.name
|
name: this.actionResource.metadata.name
|
||||||
@ -132,6 +147,14 @@ 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"
|
||||||
|
|||||||
24
pkg/harvester/edit/forklift.konveyor.io.provider.vue
Normal file
24
pkg/harvester/edit/forklift.konveyor.io.provider.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script>
|
||||||
|
import { h } from 'vue';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ForkliftProviderEdit',
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const { namespace, id } = this.$route.params;
|
||||||
|
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
|
||||||
|
|
||||||
|
this.$router.replace({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$route.params.product,
|
||||||
|
cluster: this.$route.params.cluster,
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => h('div'),
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -468,10 +468,15 @@ 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 }`;
|
||||||
}
|
}
|
||||||
@ -481,7 +486,7 @@ export default {
|
|||||||
value: d.id,
|
value: d.id,
|
||||||
action: this.addDisk,
|
action: this.addDisk,
|
||||||
kind: !parentDevice ? 'group' : '',
|
kind: !parentDevice ? 'group' : '',
|
||||||
disabled: !!isChildAdded,
|
disabled: !!isChildAdded || !!mountPoint,
|
||||||
group: parentDevice || devPath,
|
group: parentDevice || devPath,
|
||||||
isParent: !!parentDevice,
|
isParent: !!parentDevice,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
import { VOLUME_MODE, ACCESS_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,6 +10,10 @@ 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',
|
||||||
|
|
||||||
@ -90,10 +94,10 @@ export default {
|
|||||||
|
|
||||||
accessModeOptions() {
|
accessModeOptions() {
|
||||||
return [
|
return [
|
||||||
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
|
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
|
||||||
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
|
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY },
|
||||||
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
|
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY },
|
||||||
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
|
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -26,9 +26,11 @@ 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 } from '@pkg/harvester/config/types';
|
import { VOLUME_MODE, ACCESS_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',
|
||||||
|
|
||||||
@ -83,7 +85,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 = ['ReadWriteMany'];
|
this.value.spec.accessModes = [READ_WRITE_MANY];
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = this.value?.spec?.resources?.requests?.storage || null;
|
const storage = this.value?.spec?.resources?.requests?.storage || null;
|
||||||
@ -160,7 +162,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
accessModeOptions() {
|
accessModeOptions() {
|
||||||
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
|
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
|
||||||
},
|
},
|
||||||
|
|
||||||
imageOption() {
|
imageOption() {
|
||||||
@ -355,7 +357,7 @@ export default {
|
|||||||
|
|
||||||
getAccessMode() {
|
getAccessMode() {
|
||||||
if (!this.longhornV2LVMSupport) {
|
if (!this.longhornV2LVMSupport) {
|
||||||
return ['ReadWriteMany'];
|
return [READ_WRITE_MANY];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
|
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
|
||||||
@ -371,7 +373,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 ? ['ReadWriteOnce'] : ['ReadWriteMany'];
|
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
|
||||||
},
|
},
|
||||||
buildDataVolumeObj() {
|
buildDataVolumeObj() {
|
||||||
const storage = {
|
const storage = {
|
||||||
|
|||||||
@ -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 } from '@shell/utils/object';
|
import { set, remove } 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,10 +142,12 @@ 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) || [];
|
||||||
|
|
||||||
return vlans.map((vlan) => ({
|
const options = vlans.map((vlan) => ({
|
||||||
label: vlan.id,
|
label: vlan.id,
|
||||||
value: vlan.id,
|
value: vlan.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return [{ label: this.t('generic.none'), value: '' }, ...options];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -160,6 +162,14 @@ 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;
|
||||||
@ -283,12 +293,13 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col span-6">
|
<div class="col span-6">
|
||||||
<LabeledSelect
|
<LabeledSelect
|
||||||
v-model:value="value.spec.vlan"
|
: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,12 +68,6 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
osType(neu) {
|
|
||||||
if (neu === 'windows') {
|
|
||||||
this.id = '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
value(neu) {
|
value(neu) {
|
||||||
this.yamlScript = neu;
|
this.yamlScript = neu;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -133,6 +133,10 @@ export default {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
return this.isMasquerade ? masquerade : other;
|
return this.isMasquerade ? masquerade : other;
|
||||||
|
},
|
||||||
|
|
||||||
|
staticIPForVMEnabled() {
|
||||||
|
return this.$store.getters['harvester-common/getFeatureEnabled']('staticIPForVM');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -150,6 +154,7 @@ export default {
|
|||||||
isSingle(neu) {
|
isSingle(neu) {
|
||||||
if (!neu) {
|
if (!neu) {
|
||||||
this.value['macAddress'] = '';
|
this.value['macAddress'] = '';
|
||||||
|
this.value['staticIp'] = '';
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -163,6 +168,7 @@ 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 = '';
|
||||||
} else {
|
} else {
|
||||||
this.value.isPod = false;
|
this.value.isPod = false;
|
||||||
}
|
}
|
||||||
@ -289,9 +295,30 @@ export default {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div
|
||||||
|
v-if="showAdvanced"
|
||||||
|
class="row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="staticIPForVMEnabled"
|
||||||
|
data-testid="input-hen-staticIp"
|
||||||
|
class="col span-6"
|
||||||
|
>
|
||||||
|
<InputOrDisplay
|
||||||
|
:name="t('harvester.fields.staticIp')"
|
||||||
|
:value="value.staticIp"
|
||||||
|
:mode="mode"
|
||||||
|
>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="value.staticIp"
|
||||||
|
label-key="harvester.fields.staticIp"
|
||||||
|
:mode="mode"
|
||||||
|
:tooltip="t('harvester.virtualMachine.volume.staticIpTip', { nic_name: value.name || 'default' })"
|
||||||
|
@update:value="update"
|
||||||
|
/>
|
||||||
|
</InputOrDisplay>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showAdvanced"
|
|
||||||
data-testid="input-hen-macAddress"
|
data-testid="input-hen-macAddress"
|
||||||
class="col span-6"
|
class="col span-6"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -16,10 +16,12 @@ import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
|
|||||||
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
|
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 } from '@pkg/harvester/config/types';
|
import { VOLUME_MODE, ACCESS_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'],
|
emits: ['update:value'],
|
||||||
|
|
||||||
@ -69,7 +71,7 @@ export default {
|
|||||||
|
|
||||||
customAccessMode: {
|
customAccessMode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'ReadWriteMany'
|
default: READ_WRITE_MANY
|
||||||
},
|
},
|
||||||
|
|
||||||
resourceType: {
|
resourceType: {
|
||||||
|
|||||||
@ -5,10 +5,12 @@ 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 } from '@shell/config/types';
|
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } 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';
|
||||||
@ -19,7 +21,7 @@ export default {
|
|||||||
emits: ['update'],
|
emits: ['update'],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
|
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
@ -94,6 +96,30 @@ 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
|
||||||
@ -145,6 +171,7 @@ 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;
|
||||||
this.update();
|
this.update();
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -155,6 +182,13 @@ 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) {
|
||||||
@ -303,6 +337,26 @@ 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"
|
||||||
|
|||||||
@ -14,7 +14,9 @@ import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storagec
|
|||||||
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 } from '@pkg/harvester/config/types';
|
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
|
||||||
|
|
||||||
|
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HarvesterEditVolume',
|
name: 'HarvesterEditVolume',
|
||||||
@ -205,7 +207,7 @@ export default {
|
|||||||
|
|
||||||
getAccessMode(storageClassName) {
|
getAccessMode(storageClassName) {
|
||||||
if (!this.longhornV2LVMSupport) {
|
if (!this.longhornV2LVMSupport) {
|
||||||
return 'ReadWriteMany';
|
return READ_WRITE_MANY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
|
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
|
||||||
@ -216,7 +218,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 ? 'ReadWriteOnce' : 'ReadWriteMany';
|
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
|
||||||
},
|
},
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
|
|||||||
@ -93,6 +93,9 @@ 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 {
|
||||||
@ -484,6 +487,20 @@ 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);
|
||||||
}
|
}
|
||||||
@ -1045,7 +1062,6 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CloudConfig
|
<CloudConfig
|
||||||
v-if="!isWindows"
|
|
||||||
ref="yamlEditor"
|
ref="yamlEditor"
|
||||||
:user-script="userScript"
|
:user-script="userScript"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
|
|||||||
@ -63,6 +63,7 @@ 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: {
|
||||||
@ -190,15 +191,13 @@ export default {
|
|||||||
this['membershipUpdate'] = update;
|
this['membershipUpdate'] = update;
|
||||||
},
|
},
|
||||||
|
|
||||||
removeQuota(key) {
|
validateQuotas(isValid) {
|
||||||
['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => {
|
this.isQuotasValid = isValid;
|
||||||
if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) {
|
},
|
||||||
delete this.value?.spec[specProp]?.limit[key];
|
|
||||||
}
|
onQuotasInput({ projectLimit, nsLimit } = {}) {
|
||||||
if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) {
|
this.value.spec.resourceQuota = { ...this.value.spec.resourceQuota, limit: projectLimit };
|
||||||
delete this.value?.spec[specProp]?.usedLimit[key];
|
this.value.spec.namespaceDefaultResourceQuota = { ...this.value.spec.namespaceDefaultResourceQuota, limit: nsLimit };
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -212,7 +211,7 @@ export default {
|
|||||||
:resource="value"
|
:resource="value"
|
||||||
:subtypes="[]"
|
:subtypes="[]"
|
||||||
:can-yaml="false"
|
:can-yaml="false"
|
||||||
:validation-passed="fvFormIsValid"
|
:validation-passed="fvFormIsValid && isQuotasValid"
|
||||||
@error="e=>errors=e"
|
@error="e=>errors=e"
|
||||||
@finish="save"
|
@finish="save"
|
||||||
@cancel="done"
|
@cancel="done"
|
||||||
@ -267,10 +266,11 @@ export default {
|
|||||||
:weight="9"
|
:weight="9"
|
||||||
>
|
>
|
||||||
<ResourceQuota
|
<ResourceQuota
|
||||||
v-model:value="value"
|
:value="value"
|
||||||
:mode="canEditTabElements"
|
:mode="canEditTabElements"
|
||||||
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
|
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
|
||||||
@remove="removeQuota"
|
@input="onQuotasInput"
|
||||||
|
@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="2"
|
:min="1"
|
||||||
:max="4094"
|
:max="4094"
|
||||||
placeholder="e.g. 2 ~ 4094"
|
placeholder="e.g. 1 ~ 4094"
|
||||||
:label="t('harvester.hostNetworkConfig.vlanID.label')"
|
:label="t('harvester.hostNetworkConfig.vlanID.label')"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
|
import PercentageBar from '@shell/components/PercentageBar';
|
||||||
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: { ConsumptionGauge },
|
components: { PercentageBar },
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
@ -74,54 +74,89 @@ 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>
|
||||||
<ConsumptionGauge
|
<div class="consumption-gauge">
|
||||||
:capacity="cpuTotal"
|
<div class="numbers">
|
||||||
:used="reserved"
|
<span class="mr-5">
|
||||||
: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', amountTemplateValues) }}
|
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
|
||||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</div>
|
||||||
</ConsumptionGauge>
|
<div class="mt-10">
|
||||||
|
<PercentageBar
|
||||||
|
:model-value="reservedPercentage"
|
||||||
|
show-percentage
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showUsed"
|
v-if="showUsed"
|
||||||
class="mt-10"
|
class="consumption-gauge mt-10"
|
||||||
>
|
>
|
||||||
<ConsumptionGauge
|
<div class="numbers">
|
||||||
:capacity="cpuTotal"
|
<span>
|
||||||
:used="used"
|
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
||||||
:units="cpuUnits"
|
</span>
|
||||||
>
|
<span class="precent-data">
|
||||||
<template #title="{amountTemplateValues, formattedPercentage}">
|
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
|
||||||
<span>
|
</span>
|
||||||
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
</div>
|
||||||
</span>
|
<div class="mt-10">
|
||||||
<span class="precent-data">
|
<PercentageBar
|
||||||
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
|
:model-value="usedPercentage"
|
||||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
show-percentage
|
||||||
</span>
|
/>
|
||||||
</span>
|
</div>
|
||||||
</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,6 +36,10 @@ 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)
|
||||||
@ -45,6 +49,24 @@ 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 }"`);
|
||||||
|
|
||||||
@ -110,16 +132,28 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="showIP">
|
<div
|
||||||
<span
|
v-if="showIP"
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
</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 ConsumptionGauge from '@shell/components/ConsumptionGauge';
|
import PercentageBar from '@shell/components/PercentageBar';
|
||||||
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: { ConsumptionGauge },
|
components: { PercentageBar },
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
@ -76,6 +76,30 @@ 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: {
|
||||||
@ -96,50 +120,53 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ConsumptionGauge
|
<div class="consumption-gauge">
|
||||||
:capacity="memoryTotal"
|
<div class="numbers">
|
||||||
:used="reserved"
|
<span class="mr-10">
|
||||||
: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', amountTemplateValues) }}
|
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
|
||||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</div>
|
||||||
</ConsumptionGauge>
|
<div class="mt-10">
|
||||||
|
<PercentageBar
|
||||||
|
:model-value="reservedPercentage"
|
||||||
|
show-percentage
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showUsed"
|
v-if="showUsed"
|
||||||
class="mt-10"
|
class="consumption-gauge mt-10"
|
||||||
>
|
>
|
||||||
<ConsumptionGauge
|
<div class="numbers">
|
||||||
:capacity="memoryTotal"
|
<span>
|
||||||
:used="used"
|
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
||||||
:units="memoryUnits"
|
</span>
|
||||||
:number-formatter="memoryFormatter"
|
<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">/ {{ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
|
import PercentageBar from '@shell/components/PercentageBar';
|
||||||
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: { ConsumptionGauge },
|
components: { PercentageBar },
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
@ -120,6 +120,14 @@ 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: {
|
||||||
@ -144,53 +152,54 @@ export default {
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-if="showAllocated"
|
v-if="showAllocated"
|
||||||
|
class="consumption-gauge"
|
||||||
>
|
>
|
||||||
<ConsumptionGauge
|
<div class="numbers">
|
||||||
:capacity="storageStats.total"
|
<span class="mr-10">
|
||||||
:used="storageStats.scheduled"
|
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
|
||||||
:units="allocatedUnits"
|
</span>
|
||||||
:number-formatter="formatter"
|
<span class="precent-data">
|
||||||
:resource-name="resourceName"
|
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
|
||||||
>
|
</span>
|
||||||
<template #title="{formattedPercentage}">
|
</div>
|
||||||
<span>
|
<div class="mt-10">
|
||||||
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
|
<PercentageBar
|
||||||
</span>
|
:model-value="allocatedPercentage"
|
||||||
<span class="precent-data">
|
show-percentage
|
||||||
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
|
/>
|
||||||
<span class="ml-10 percentage">
|
</div>
|
||||||
/ {{ formattedPercentage }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</ConsumptionGauge>
|
|
||||||
</div>
|
</div>
|
||||||
<ConsumptionGauge
|
<div
|
||||||
:capacity="storageStats.maximum"
|
class="consumption-gauge"
|
||||||
:used="storageStats.used"
|
:class="{ 'mt-10': showAllocated }"
|
||||||
:units="usedUnits"
|
|
||||||
:number-formatter="formatter"
|
|
||||||
:resource-name="showAllocated ? '' : resourceName"
|
|
||||||
:class="{
|
|
||||||
'mt-10': showAllocated,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<template #title="{formattedPercentage}">
|
<div class="numbers">
|
||||||
<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">
|
|
||||||
/ {{ formattedPercentage }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</div>
|
||||||
</ConsumptionGauge>
|
<div class="mt-10">
|
||||||
|
<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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
wizard:
|
||||||
|
create: Create
|
||||||
|
next: Proceed
|
||||||
|
previous: Back
|
||||||
|
|
||||||
generic:
|
generic:
|
||||||
tip: Tip
|
tip: Tip
|
||||||
resourceExternalLinkTips: 'External Link'
|
resourceExternalLinkTips: 'External Link'
|
||||||
@ -8,6 +13,15 @@ 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>'
|
||||||
@ -22,6 +36,8 @@ 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
|
||||||
|
Configurations: Configurations
|
||||||
|
|
||||||
resourceTable:
|
resourceTable:
|
||||||
groupBy:
|
groupBy:
|
||||||
@ -35,6 +51,8 @@ members:
|
|||||||
projectMembership: Project Membership
|
projectMembership: Project Membership
|
||||||
|
|
||||||
asyncButton:
|
asyncButton:
|
||||||
|
createAndStart:
|
||||||
|
action: Create and Start
|
||||||
restart:
|
restart:
|
||||||
action: Save and Restart
|
action: Save and Restart
|
||||||
success: Restarted
|
success: Restarted
|
||||||
@ -75,6 +93,8 @@ 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
|
||||||
@ -214,6 +234,8 @@ 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.
|
||||||
@ -309,7 +331,8 @@ harvester:
|
|||||||
message: Message
|
message: Message
|
||||||
phase: Phase
|
phase: Phase
|
||||||
attachedVM: Attached Virtual Machine
|
attachedVM: Attached Virtual Machine
|
||||||
cpuManager: CPU Manager
|
cpuManager: CPU<br>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
|
||||||
@ -352,6 +375,13 @@ harvester:
|
|||||||
v4ip: V4 IP
|
v4ip: V4 IP
|
||||||
v6ip: V6 IP
|
v6ip: V6 IP
|
||||||
eipName: EIP Name
|
eipName: EIP Name
|
||||||
|
vmMigrationProviderType: Type
|
||||||
|
vmMigrationProviderUrl: URL
|
||||||
|
vmMigrationMapSourceProvider: Source Provider
|
||||||
|
vmMigrationMapDestProvider: Destination Provider
|
||||||
|
vmMigrationPlanTargetNs: Target Namespace
|
||||||
|
vmMigrationPlanVmCount: VM Count
|
||||||
|
vmMigrationMigrationPlan: Plan
|
||||||
tab:
|
tab:
|
||||||
volume: Volumes
|
volume: Volumes
|
||||||
network: Networks
|
network: Networks
|
||||||
@ -376,6 +406,7 @@ 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
|
||||||
@ -457,7 +488,6 @@ 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.'
|
||||||
@ -611,6 +641,7 @@ 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.
|
||||||
@ -750,6 +781,7 @@ 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
|
||||||
@ -766,6 +798,10 @@ harvester:
|
|||||||
setFirst: Set as root volume
|
setFirst: Set as root volume
|
||||||
saveVolume: Update Volume
|
saveVolume: Update Volume
|
||||||
encryption: Encryption
|
encryption: Encryption
|
||||||
|
shareable:
|
||||||
|
label: Shareable
|
||||||
|
tip: Allow multiple virtual machines to attach and write to this volume simultaneously.
|
||||||
|
warning: Multiple virtual machines can write to this volume at the same time. The guest workloads must coordinate access to the shared block device, otherwise concurrent writes will corrupt the data. Workloads that fence through SCSI-3 Persistent Reservations (e.g. Windows Server Failover Clustering) also require persistent reservation support from the storage backend. Host cache and hypervisor disk locking are disabled, and backup or snapshot operations are rejected while the volume is shareable.
|
||||||
vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
|
vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
|
||||||
lockTooltip:
|
lockTooltip:
|
||||||
all: All volumes are encrypted.
|
all: All volumes are encrypted.
|
||||||
@ -799,13 +835,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 Sysprep Configuration
|
title: Windows Unattended & 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 Sysprep Template
|
createNewTitle: Create Windows Unattended & Sysprep Template
|
||||||
xmlContent: autounattend.xml content
|
xmlContent: autounattend.xml content
|
||||||
secret:
|
secret:
|
||||||
label: Windows Sysprep Template
|
label: Windows Unattended & 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'
|
||||||
@ -1067,6 +1103,8 @@ 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.
|
||||||
@ -1799,6 +1837,222 @@ harvester:
|
|||||||
'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.
|
||||||
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
||||||
|
|
||||||
|
vmMigration:
|
||||||
|
generic:
|
||||||
|
unknown: Unknown
|
||||||
|
identifier: Identifier
|
||||||
|
podNetwork: Pod Network
|
||||||
|
ignored: Ignored
|
||||||
|
usedBy: "Used by:"
|
||||||
|
vlan: "VLAN {id}"
|
||||||
|
vCpu: "{count} vCPU"
|
||||||
|
memoryGb: "{value} GB"
|
||||||
|
memoryTb: "{value} TB"
|
||||||
|
elapsed:
|
||||||
|
hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}"
|
||||||
|
hours: "{hours} {hours, plural, one {hour} other {hours}}"
|
||||||
|
minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}"
|
||||||
|
errors:
|
||||||
|
failedLoadPlans: Failed to load migration plans
|
||||||
|
failedLoadVms: Failed to load virtual machines
|
||||||
|
failedRefreshVms: Failed to refresh virtual machines
|
||||||
|
failedLoadProviders: Failed to load providers
|
||||||
|
failedLoadProvider: "Failed to load provider: {error}"
|
||||||
|
failedResolveDetails: Failed to resolve network and datastore details
|
||||||
|
failedDecodeCredentials: Failed to decode provider credentials
|
||||||
|
connectionFailed: Connection failed
|
||||||
|
providerNotReady: Provider not ready
|
||||||
|
labels:
|
||||||
|
dashboard: Migration Overview
|
||||||
|
provider: Providers
|
||||||
|
networkMap: Network Maps
|
||||||
|
storageMap: Storage Maps
|
||||||
|
plan: Migration Plans
|
||||||
|
migration: Migrations
|
||||||
|
providerWizard:
|
||||||
|
title: Create Provider
|
||||||
|
editTitle: Edit Provider
|
||||||
|
wizard:
|
||||||
|
title: VM Migration
|
||||||
|
steps:
|
||||||
|
configureProvider:
|
||||||
|
label: Provider
|
||||||
|
description: Configure Provider
|
||||||
|
selectVms:
|
||||||
|
label: VMs
|
||||||
|
description: Select VMs
|
||||||
|
configureMappings:
|
||||||
|
label: Mappings
|
||||||
|
description: Define Mappings
|
||||||
|
reviewMigration:
|
||||||
|
label: Migration Plan
|
||||||
|
description: Review Migration Plan
|
||||||
|
fields:
|
||||||
|
username: Username
|
||||||
|
password: Password
|
||||||
|
configureProvider:
|
||||||
|
title: Configure Provider
|
||||||
|
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
|
||||||
|
connectionDetails: Connection Details
|
||||||
|
providerSelect: Provider
|
||||||
|
createNew: Create new provider
|
||||||
|
requirementsTitle: Requirements
|
||||||
|
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Harvester Node."
|
||||||
|
name: Name
|
||||||
|
urlLabel: vCenter/ESXi URL
|
||||||
|
urlPlaceholder: "https://vcenter.example.com/sdk"
|
||||||
|
urlHint: Enter the full URL including https://
|
||||||
|
vddkImageLabel: VMware VDDK Image
|
||||||
|
vddkImagePlaceholder: "[your-private-registry]/vddk-image:latest"
|
||||||
|
vddkImageTooltip: Specify the VMware VDDK (Virtual Disk Development Kit) Image to accelerate and optimize VMware virtual machine migration.
|
||||||
|
skipSsl: Skip SSL certificate verification
|
||||||
|
skipSslHint: Not recommended for production environments
|
||||||
|
testConnection:
|
||||||
|
action: Test Connection
|
||||||
|
success: Connection successful
|
||||||
|
error: Connection failed
|
||||||
|
waiting: Testing connection...
|
||||||
|
testSuccess: Connection test passed
|
||||||
|
testFailed: Connection test failed
|
||||||
|
testMissingFields: Please fill in all required fields before testing
|
||||||
|
testTimeout: Connection test timed out — the provider did not report a status in time
|
||||||
|
save: Save Provider and Continue
|
||||||
|
saveExisting: Check Provider and Continue
|
||||||
|
selectVms:
|
||||||
|
title: Select Virtual Machines
|
||||||
|
description: Select the virtual machines you want to migrate from the source provider
|
||||||
|
discovered: "{count} VMs discovered from"
|
||||||
|
lastSynced: "Last synced {time} ago • "
|
||||||
|
refreshNow: Refresh now
|
||||||
|
availableVms: Available Virtual Machines
|
||||||
|
selected: selected
|
||||||
|
clearAll: Clear All
|
||||||
|
saveSelection: Save Selection and Continue
|
||||||
|
selectAllBanner:
|
||||||
|
pageOnly: "Your current selection includes only VMs currently shown on the page."
|
||||||
|
selectAll: "Select all {count} VMs from this provider"
|
||||||
|
allSelected: "Your current selection includes all VMs."
|
||||||
|
clearSelection: "Clear Selection"
|
||||||
|
manualEntry:
|
||||||
|
title: Add Virtual Machines
|
||||||
|
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
|
||||||
|
vmId: VM ID
|
||||||
|
vmIdPlaceholder: "e.g. vm-60527"
|
||||||
|
add: Add
|
||||||
|
added: VMs added
|
||||||
|
columns:
|
||||||
|
vmName: VM Name
|
||||||
|
vmId: VM ID
|
||||||
|
os: Operating System
|
||||||
|
resources: Resources
|
||||||
|
powerState: Power State
|
||||||
|
network: Network
|
||||||
|
datastore: Datastore
|
||||||
|
dashboard:
|
||||||
|
title: Virtual Machine Migration Overview
|
||||||
|
description: "Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester."
|
||||||
|
createPlan: Create Migration
|
||||||
|
tableTitle: Migrations
|
||||||
|
columns:
|
||||||
|
status: Status
|
||||||
|
plan: Migration Plan
|
||||||
|
progress: Progress
|
||||||
|
mappings: Mappings
|
||||||
|
duration: Duration
|
||||||
|
progress:
|
||||||
|
failed: Failed
|
||||||
|
migration: Migration
|
||||||
|
initializingMigration: Initializing migration
|
||||||
|
finishedSuccessfully: Finished Successfully
|
||||||
|
step: "Step {index}"
|
||||||
|
vmCount: "{count, plural, one {# VM} other {# VMs}}"
|
||||||
|
vmId: "id: {id}"
|
||||||
|
checkMigratedVm: Click to check migrated VM details
|
||||||
|
configureMappings:
|
||||||
|
title: Set Mappings
|
||||||
|
description: Map VMware networks and datastores to Harvester and Longhorn target resources
|
||||||
|
save: Save Mappings and Continue
|
||||||
|
noTemplate: Start from scratch
|
||||||
|
removeMap: Remove mapping
|
||||||
|
networkMapping:
|
||||||
|
title: Network Mapping
|
||||||
|
description: Map VMware port groups to Harvester networks
|
||||||
|
placeholder: Choose a Harvester network
|
||||||
|
template: Use existing network mapping as template
|
||||||
|
options:
|
||||||
|
podNetworking: Management Network
|
||||||
|
ignored: Ignored (No NIC will be created)
|
||||||
|
storageMapping:
|
||||||
|
title: Storage Mapping
|
||||||
|
description: Map VMware datastores to Harvester storage classes
|
||||||
|
placeholder: Choose a Harvester Storage Class
|
||||||
|
template: Use existing storage mapping as template
|
||||||
|
storageDefaults:
|
||||||
|
title: 'Storage defaults — {name}'
|
||||||
|
description: 'Set the volume mode and access modes applied to every datastore in this storage map that maps to {name}.'
|
||||||
|
descriptionInherited: 'Applies to every datastore in this storage map that maps to {name}. The provider default for {provider} stays unchanged.'
|
||||||
|
summary: 'Volume mode: {volumeMode} · Access mode: {accessModes}'
|
||||||
|
inherited: 'Inherited from provider {provider}'
|
||||||
|
volumeMode: Volume Mode
|
||||||
|
accessMode: Access Mode
|
||||||
|
edit: Edit
|
||||||
|
cancel: Cancel
|
||||||
|
apply: Apply
|
||||||
|
resetToProviderDefault: Reset to Provider Default
|
||||||
|
applyOverride: Apply Override
|
||||||
|
reviewMigration:
|
||||||
|
title: Review Migration Plan
|
||||||
|
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
|
||||||
|
planName: Name
|
||||||
|
planNamePlaceholder: "e.g. my-migration-plan"
|
||||||
|
planNameProviderConflict: 'The name “{name}” is already used by an existing provider. Please input a different plan name.'
|
||||||
|
planNamePlanConflict: 'The name “{name}” is already used by an existing migration plan. Please input a different plan name.'
|
||||||
|
migrationDetails: Migration Details
|
||||||
|
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
|
||||||
|
totalVms: Total VMs
|
||||||
|
vcpu: vCPU
|
||||||
|
memory: Memory
|
||||||
|
storage: Storage
|
||||||
|
source: Source
|
||||||
|
provider: Provider
|
||||||
|
targetNamespace: Namespace
|
||||||
|
targetNamespacePlaceholder: "Select target namespace"
|
||||||
|
migrationMode: Migration Mode
|
||||||
|
coldMigration: Cold Migration
|
||||||
|
coldMigrationDescription: All source VMs will be powered off during migration.
|
||||||
|
coldMigrationHint: Plan a maintenance window and notify relevant stakeholders before starting.
|
||||||
|
virtualMachines: Virtual Machines
|
||||||
|
editSelection: Edit selection
|
||||||
|
noVmSelected: "No VMs selected. Return to "
|
||||||
|
noVmSelectedLink: "Step 2 to select virtual machines."
|
||||||
|
columns:
|
||||||
|
vmName: VM Name
|
||||||
|
os: Operating System
|
||||||
|
resources: Resources
|
||||||
|
powerState: Power State
|
||||||
|
networkMapping: Network Mapping
|
||||||
|
storageMapping: Storage Mapping
|
||||||
|
sourceTarget: "Source \u2192 Target"
|
||||||
|
actions:
|
||||||
|
menuLabel: Actions
|
||||||
|
editMappings: Edit Mappings
|
||||||
|
removeFromPlan: Remove from Plan
|
||||||
|
warningTitle: "Cold Migration Warning"
|
||||||
|
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
|
||||||
|
startMigration: Start Migration Plan
|
||||||
|
plan:
|
||||||
|
states:
|
||||||
|
error: Error
|
||||||
|
canceled: Canceled
|
||||||
|
inProgress: In Progress
|
||||||
|
active: Succeeded
|
||||||
|
actions:
|
||||||
|
start: Start
|
||||||
|
restart: Restart
|
||||||
|
stop: Stop
|
||||||
|
deleteDialog:
|
||||||
|
warning: Deleting this migration plan will also delete the following related resources
|
||||||
|
|
||||||
vmImport:
|
vmImport:
|
||||||
titles:
|
titles:
|
||||||
basic: Basic
|
basic: Basic
|
||||||
@ -2047,6 +2301,12 @@ 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
|
||||||
@ -2434,3 +2694,28 @@ typeLabel:
|
|||||||
one { Virtual Machine Import }
|
one { Virtual Machine Import }
|
||||||
other { Virtual Machine Imports }
|
other { Virtual Machine Imports }
|
||||||
}
|
}
|
||||||
|
forklift.konveyor.io.provider: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Provider }
|
||||||
|
other { Providers }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.networkmap: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Network Map }
|
||||||
|
other { Network Maps }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.storagemap: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Storage Map }
|
||||||
|
other { Storage Maps }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.plan: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Migration Plan }
|
||||||
|
other { Migration Plans }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.migration: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Migration }
|
||||||
|
other { Migrations }
|
||||||
|
}
|
||||||
|
|||||||
@ -86,6 +86,13 @@ 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,7 +53,6 @@ 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) => {
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
...NAME,
|
...NAME,
|
||||||
width: 130,
|
width: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: 150,
|
width: 75,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.hasLonghornSchema) {
|
if (this.hasLonghornSchema) {
|
||||||
out.push({
|
out.push({
|
||||||
name: 'diskState',
|
name: 'diskState',
|
||||||
labelKey: 'tableHeaders.diskState',
|
labelKey: 'harvester.tableHeaders.diskState',
|
||||||
value: 'diskState',
|
value: 'diskState',
|
||||||
formatter: 'HarvesterDiskState',
|
formatter: 'HarvesterDiskState',
|
||||||
width: 130,
|
width: 130,
|
||||||
@ -156,7 +156,6 @@ export default {
|
|||||||
name: 'console',
|
name: 'console',
|
||||||
label: ' ',
|
label: ' ',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: 80,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
@ -239,6 +238,8 @@ 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>
|
||||||
@ -249,5 +250,13 @@ 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;
|
return configs.filter((config) => config.spec?.clusterNetwork !== 'mgmt');
|
||||||
},
|
},
|
||||||
|
|
||||||
vlanConfigSchema() {
|
vlanConfigSchema() {
|
||||||
@ -121,6 +121,22 @@ 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: {
|
||||||
@ -224,6 +240,29 @@ 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"
|
||||||
@ -273,6 +312,21 @@ 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">
|
||||||
|
|||||||
@ -38,6 +38,10 @@ 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 || [];
|
||||||
@ -52,6 +56,14 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,9 @@ 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, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
|
import { VOLUME_MODE, ACCESS_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';
|
||||||
|
|
||||||
@ -162,6 +164,12 @@ export default {
|
|||||||
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,
|
||||||
@ -270,7 +278,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
customAccessMode() {
|
customAccessMode() {
|
||||||
return this.storageClassSetting.accessModes || 'ReadWriteMany';
|
return this.storageClassSetting.accessModes || READ_WRITE_MANY;
|
||||||
},
|
},
|
||||||
|
|
||||||
isWindows() {
|
isWindows() {
|
||||||
@ -498,7 +506,7 @@ export default {
|
|||||||
id: randomStr(5),
|
id: randomStr(5),
|
||||||
source: SOURCE_TYPE.IMAGE,
|
source: SOURCE_TYPE.IMAGE,
|
||||||
name: 'disk-0',
|
name: 'disk-0',
|
||||||
accessMode: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX
|
accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX
|
||||||
bus,
|
bus,
|
||||||
volumeName: '',
|
volumeName: '',
|
||||||
size,
|
size,
|
||||||
@ -564,7 +572,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] || 'ReadWriteMany';
|
accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY;
|
||||||
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;
|
||||||
@ -610,6 +618,7 @@ export default {
|
|||||||
type,
|
type,
|
||||||
storageClassName,
|
storageClassName,
|
||||||
hotpluggable,
|
hotpluggable,
|
||||||
|
shareable: DISK.shareable || false,
|
||||||
volumeStatus,
|
volumeStatus,
|
||||||
dataSource,
|
dataSource,
|
||||||
namespace,
|
namespace,
|
||||||
@ -630,6 +639,8 @@ 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);
|
||||||
@ -646,6 +657,7 @@ 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 }`] || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -747,6 +759,8 @@ 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];
|
||||||
@ -768,6 +782,53 @@ 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
|
||||||
@ -837,18 +898,30 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.needNewSecret || !this.secretName) {
|
// Regenerate the cloud-init secret name whenever it hasn't been generated yet, or the VM
|
||||||
|
// name has changed since it was last generated (e.g. a previous create attempt failed with
|
||||||
|
// an invalid/duplicate name and the user corrected it). Without this, a stale secret name
|
||||||
|
// derived from a rejected attempt gets reused for the VM that's actually created.
|
||||||
|
// See harvester/harvester#11174.
|
||||||
|
if (this.needNewSecret || !this.secretName || (this.isCreate && this.secretNamePrefixUsed !== this.secretNamePrefix)) {
|
||||||
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.
|
// If we have content but no secret name, it's a new secret that needs a name. Also
|
||||||
if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) {
|
// regenerate when creating a VM if the name prefix has changed since it was last
|
||||||
|
// generated.
|
||||||
|
const sysprepNeedsNewSecretName = !this.sysprep.secretName?.trim?.() ||
|
||||||
|
(this.isCreate && this.sysprepSecretNamePrefixUsed !== '' && this.sysprepSecretNamePrefixUsed !== this.secretNamePrefix);
|
||||||
|
|
||||||
|
if (hasSysprepContent && sysprepNeedsNewSecretName) {
|
||||||
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.
|
||||||
@ -867,38 +940,44 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
|
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
|
||||||
if (!this.isWindows) {
|
disks.push({
|
||||||
disks.push({
|
name: 'cloudinitdisk',
|
||||||
name: 'cloudinitdisk',
|
disk: { bus: 'virtio' }
|
||||||
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) => {
|
||||||
|
if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) {
|
||||||
|
delete disk.shareable;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let spec = {
|
let spec = {
|
||||||
...this.spec,
|
...this.spec,
|
||||||
runStrategy: this.runStrategy,
|
runStrategy: this.runStrategy,
|
||||||
@ -1095,6 +1174,16 @@ 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);
|
||||||
@ -1122,9 +1211,10 @@ 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 (userDataYaml === '{}\n') {
|
if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) {
|
||||||
// When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned.
|
// Empty userData should not create cloud-init content like `null`.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1173,6 +1263,10 @@ export default {
|
|||||||
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;
|
||||||
@ -1297,13 +1391,22 @@ export default {
|
|||||||
*/
|
*/
|
||||||
deleteYamlDocProp(doc, paths) {
|
deleteYamlDocProp(doc, paths) {
|
||||||
try {
|
try {
|
||||||
const item = doc.getIn([])?.items[0];
|
const items = doc.getIn([])?.items;
|
||||||
const key = item?.key;
|
const firstKey = items?.[0]?.key;
|
||||||
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
|
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
|
||||||
const isMatchProp = key.source === paths[paths.length - 1];
|
const isFirstProp = firstKey?.source === paths[paths.length - 1];
|
||||||
|
|
||||||
if (key && hasCloudConfigComment && isMatchProp) {
|
if (firstKey && hasCloudConfigComment && isFirstProp) {
|
||||||
// Comments are mounted on the next node and we should not delete the node containing cloud-config
|
const comment = firstKey.commentBefore;
|
||||||
|
|
||||||
|
doc.deleteIn(paths);
|
||||||
|
|
||||||
|
// Move the comment to the new first key; if no keys remain the comment is lost.
|
||||||
|
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
|
||||||
|
|
||||||
|
if (newFirstKey) {
|
||||||
|
newFirstKey.commentBefore = comment;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
doc.deleteIn(paths);
|
doc.deleteIn(paths);
|
||||||
}
|
}
|
||||||
@ -1354,7 +1457,6 @@ 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']);
|
||||||
}
|
}
|
||||||
@ -1369,7 +1471,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteQGA(config) {
|
deleteQGA(config) {
|
||||||
const { osType, userDataDoc, deletePackage = false } = config;
|
const { osType, userDataDoc } = 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 || '';
|
||||||
|
|
||||||
@ -1378,6 +1480,15 @@ 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);
|
||||||
|
|
||||||
@ -1403,7 +1514,6 @@ 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']);
|
||||||
}
|
}
|
||||||
@ -1436,7 +1546,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async saveSecret(vm) {
|
async saveSecret(vm) {
|
||||||
if (!vm?.spec || !this.secretName || this.isWindows) {
|
if (!vm?.spec || !this.secretName) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1759,7 +1869,18 @@ export default {
|
|||||||
const specDevice = specDeviceMap.get(device.name);
|
const specDevice = specDeviceMap.get(device.name);
|
||||||
|
|
||||||
if (specDevice) {
|
if (specDevice) {
|
||||||
return { ...specDevice, ...device };
|
const merged = { ...specDevice, ...device };
|
||||||
|
|
||||||
|
// A disk entry must be either `disk` or `cdrom`, never both.
|
||||||
|
const type = device?.cdrom ? CD_ROM : device?.disk ? HARD_DISK : '';
|
||||||
|
|
||||||
|
if (type === CD_ROM) {
|
||||||
|
delete merged.disk;
|
||||||
|
} else if (type === HARD_DISK) {
|
||||||
|
delete merged.cdrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
return device;
|
return device;
|
||||||
@ -1807,6 +1928,15 @@ 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)) {
|
||||||
@ -1837,8 +1967,6 @@ 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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1898,9 +2026,8 @@ export default {
|
|||||||
|
|
||||||
osType(neu, old) {
|
osType(neu, old) {
|
||||||
this.installAgent = old === 'windows' ? true : this.installAgent;
|
this.installAgent = old === 'windows' ? true : this.installAgent;
|
||||||
const out = old === 'windows' ? this.getInitUserData({ osType: neu }) : this.getUserData({ installAgent: this.installAgent, osType: neu });
|
this.userScript = this.getUserData({ installAgent: this.installAgent, osType: neu });
|
||||||
|
|
||||||
this['userScript'] = out;
|
|
||||||
this.refreshYamlEditor();
|
this.refreshYamlEditor();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -69,16 +69,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableDevice() {
|
get isTimesliced() {
|
||||||
try {
|
return this.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true';
|
||||||
this.spec.enabled = true;
|
}
|
||||||
await this.save();
|
|
||||||
} catch (err) {
|
enableDevice() {
|
||||||
this.$dispatch('growl/fromError', {
|
this.$dispatch('promptModal', {
|
||||||
title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }),
|
resources: [this],
|
||||||
err,
|
component: 'EnableSriovGpuDevice'
|
||||||
}, { root: true });
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableDevice() {
|
async disableDevice() {
|
||||||
|
|||||||
286
pkg/harvester/models/forklift.konveyor.io.plan.js
Normal file
286
pkg/harvester/models/forklift.konveyor.io.plan.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import HarvesterResource from './harvester';
|
||||||
|
import { HCI } from '../types';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
|
||||||
|
export default class ForkliftPlan extends HarvesterResource {
|
||||||
|
get listLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base HarvesterResource sets `doneOverride` to `listLocation`, which would
|
||||||
|
* force every single-item delete to redirect to the VM Migration dashboard.
|
||||||
|
* Return undefined so the delete flow stays on the current page (list/dashboard)
|
||||||
|
* and only falls back to the shell's default navigation when a now-invalid
|
||||||
|
* detail page is being viewed.
|
||||||
|
*/
|
||||||
|
get doneOverride() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get planFailed() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get planCritical() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.category === 'Critical' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get criticalMessages() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
.filter((c) => c.category === 'Critical' && c.status === 'True')
|
||||||
|
.map((c) => c.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateDescription() {
|
||||||
|
if (this.metadata?.deletionTimestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message;
|
||||||
|
|
||||||
|
if (failedMsg) {
|
||||||
|
messages.push(failedMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
messages.push(...this.criticalMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.length > 0 ? messages.join(' ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateObj() {
|
||||||
|
return {
|
||||||
|
error: this.planFailed || this.planCritical,
|
||||||
|
transitioning: this.isMigrating || this.isPendingMigrationStart || !!this.metadata?.deletionTimestamp,
|
||||||
|
message: this.stateDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMigrating() {
|
||||||
|
const migration = this.status?.migration;
|
||||||
|
|
||||||
|
return !!migration?.started && !migration?.completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right after creating a plan, controller status can lag briefly.
|
||||||
|
// Treat this window as in-progress so we don't flash a succeeded badge.
|
||||||
|
get isPendingMigrationStart() {
|
||||||
|
const vmCount = this.spec?.vms?.length || 0;
|
||||||
|
|
||||||
|
return vmCount > 0 && !this.planFailed && !this.planCritical && !this.planCanceled && !this.planSucceeded && !this.isMigrating;
|
||||||
|
}
|
||||||
|
|
||||||
|
get planCanceled() {
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
const conditions = latest.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get planSucceeded() {
|
||||||
|
const migration = this.status?.migration;
|
||||||
|
|
||||||
|
return !!migration?.completed && !this.planFailed && !this.planCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateDisplay() {
|
||||||
|
if (this.metadata?.deletionTimestamp) {
|
||||||
|
return 'Terminating';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCanceled) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.canceled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMigrating) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPendingMigrationStart) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.active');
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateBackground() {
|
||||||
|
if (this.metadata?.deletionTimestamp) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCanceled) {
|
||||||
|
return 'bg-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMigrating) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPendingMigrationStart) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isForkliftDashboard() {
|
||||||
|
const route = this.currentRouter()?.currentRoute?.value;
|
||||||
|
|
||||||
|
return route?.name?.endsWith('-vm-migration');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _availableActions() {
|
||||||
|
const canStop = this.isMigrating && !this.planCanceled;
|
||||||
|
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (canStart) {
|
||||||
|
out.push({
|
||||||
|
action: 'startMigration',
|
||||||
|
enabled: true,
|
||||||
|
icon: 'icon icon-play',
|
||||||
|
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStop) {
|
||||||
|
out.push({
|
||||||
|
action: 'stopMigration',
|
||||||
|
enabled: true,
|
||||||
|
icon: 'icon icon-pause',
|
||||||
|
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isForkliftDashboard) {
|
||||||
|
out.push({
|
||||||
|
action: 'promptRemove',
|
||||||
|
altAction: 'remove',
|
||||||
|
label: this.t('action.remove'),
|
||||||
|
icon: 'icon icon-trash',
|
||||||
|
bulkable: true,
|
||||||
|
enabled: this.canDelete,
|
||||||
|
bulkAction: 'promptRemove',
|
||||||
|
weight: -10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
} else {
|
||||||
|
out.push(...super._availableActions);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopMigration() {
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
const migrationName = latest.migration?.name;
|
||||||
|
|
||||||
|
if (migrationName) {
|
||||||
|
const selfUrl = this.linkFor('self');
|
||||||
|
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
|
||||||
|
|
||||||
|
await this.$dispatch('request', { url, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startMigration() {
|
||||||
|
const namespace = this.metadata.namespace;
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
// Delete previous migrations before starting a new one
|
||||||
|
for (const entry of history) {
|
||||||
|
const name = entry.migration?.name;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const selfUrl = this.linkFor('self');
|
||||||
|
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.$dispatch('request', { url, method: 'DELETE' });
|
||||||
|
} catch (e) {
|
||||||
|
// Migration may already be gone — ignore 404s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = await this.$dispatch('create', {
|
||||||
|
type: HCI.FORKLIFT_MIGRATION,
|
||||||
|
metadata: {
|
||||||
|
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
|
||||||
|
namespace,
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Plan',
|
||||||
|
name: this.metadata.name,
|
||||||
|
uid: this.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
plan: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Plan',
|
||||||
|
name: this.metadata.name,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await migration.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlan() {
|
||||||
|
await this.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
pkg/harvester/models/forklift.konveyor.io.provider.js
Normal file
36
pkg/harvester/models/forklift.konveyor.io.provider.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import HarvesterResource from './harvester';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
|
||||||
|
export default class ForkliftProvider extends HarvesterResource {
|
||||||
|
get _createLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get _editLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
},
|
||||||
|
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a Provider cascades via ownerReferences set at creation time.
|
||||||
|
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
|
||||||
|
* Use foreground propagation to ensure children are deleted before the parent.
|
||||||
|
*/
|
||||||
|
remove(opt = {}) {
|
||||||
|
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
|
||||||
|
|
||||||
|
return this._remove(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -69,6 +69,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "harvester",
|
"name": "harvester",
|
||||||
"description": "Rancher UI Extension for Harvester",
|
"description": "Rancher UI Extension for Harvester",
|
||||||
"version": "1.9.0-dev",
|
"version": "1.10.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.14.0-0",
|
"catalog.cattle.io/rancher-version": ">= 2.15.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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
<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, Loading },
|
components: { SerialConsole },
|
||||||
|
|
||||||
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 });
|
||||||
@ -38,16 +37,44 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Loading v-if="$fetchState.pending" />
|
<div class="serial-console-page">
|
||||||
<SerialConsole
|
<SerialConsole
|
||||||
v-else
|
ref="serialConsole"
|
||||||
ref="serialConsole"
|
:value="vmi || {}"
|
||||||
v-model:value="vmi"
|
/>
|
||||||
/>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss">
|
||||||
body, #__nuxt, #__layout, main {
|
html,
|
||||||
|
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>
|
||||||
|
|||||||
558
pkg/harvester/pages/c/_cluster/vm-migration/index.vue
Normal file
558
pkg/harvester/pages/c/_cluster/vm-migration/index.vue
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||||
|
import ResourceTable from '@shell/components/ResourceTable';
|
||||||
|
import PercentageBar from '@shell/components/PercentageBar';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import MappingsCell from '../../../../components/MappingsCell';
|
||||||
|
import { SCHEMA } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../../../types';
|
||||||
|
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||||
|
import { ADD_ONS } from '../../../../config/harvester-map';
|
||||||
|
import { currentRouter } from '../../../../utils/router';
|
||||||
|
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
|
||||||
|
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
id: HCI.FORKLIFT_PLAN,
|
||||||
|
type: SCHEMA,
|
||||||
|
attributes: {
|
||||||
|
kind: HCI.FORKLIFT_PLAN,
|
||||||
|
namespaced: true
|
||||||
|
},
|
||||||
|
metadata: { name: HCI.FORKLIFT_PLAN },
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const inStore = computed(() => store.getters['currentProduct'].inStore);
|
||||||
|
|
||||||
|
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
|
||||||
|
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
|
||||||
|
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
|
||||||
|
const allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
|
||||||
|
|
||||||
|
const escapeHtml = (value = '') => String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
return allPlans.value.map((plan) => {
|
||||||
|
const netMapName = plan.spec?.map?.network?.name;
|
||||||
|
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
|
||||||
|
const storMapName = plan.spec?.map?.storage?.name;
|
||||||
|
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
|
||||||
|
|
||||||
|
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
|
||||||
|
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
|
||||||
|
|
||||||
|
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking') : (e.destination?.name || 'Ignored') }`);
|
||||||
|
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' } → ${ e.destination?.storageClass || 'Ignored' }`);
|
||||||
|
|
||||||
|
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
|
||||||
|
|
||||||
|
const vms = plan.status?.migration?.vms || [];
|
||||||
|
|
||||||
|
plan.vmProgress = vms.map((vm) => {
|
||||||
|
const pipeline = vm.pipeline || [];
|
||||||
|
const totalSteps = pipeline.length || 1;
|
||||||
|
let overallProgress = 0;
|
||||||
|
let currentStep = '';
|
||||||
|
let errorMsg = '';
|
||||||
|
|
||||||
|
pipeline.forEach((step, idx) => {
|
||||||
|
const stepWeight = 100 / totalSteps;
|
||||||
|
|
||||||
|
if (step.phase === 'Completed') {
|
||||||
|
overallProgress += stepWeight;
|
||||||
|
} else {
|
||||||
|
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
|
||||||
|
|
||||||
|
overallProgress += (stepPct / 100) * stepWeight;
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.error && !errorMsg) {
|
||||||
|
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
|
||||||
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.phase === 'Failed' && !errorMsg) {
|
||||||
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to VM-level error if no step-level error found
|
||||||
|
if (!errorMsg && vm.error) {
|
||||||
|
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
|
||||||
|
|
||||||
|
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
overallProgress = Math.round(overallProgress * 10) / 10;
|
||||||
|
|
||||||
|
// If no step-level error but the plan itself is failed, surface it.
|
||||||
|
// Skip VMs that already reached 100% (they finished successfully before
|
||||||
|
// the plan failed) so their bar stays green instead of turning red.
|
||||||
|
if (!errorMsg && plan.planFailed && overallProgress < 100) {
|
||||||
|
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmNamespace = vm.namespace || plan.spec?.targetNamespace || plan.metadata?.namespace;
|
||||||
|
const routeParams = currentRouter().currentRoute?.value?.params || {};
|
||||||
|
const product = routeParams.product || store.getters['productId'];
|
||||||
|
const cluster = routeParams.cluster || store.getters['clusterId'];
|
||||||
|
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
|
||||||
|
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
|
||||||
|
// A VM that individually reached 100% without error should be navigable
|
||||||
|
// even when the overall plan failed (other VMs in the plan may have
|
||||||
|
// failed); `overallProgress >= 100 && !errorMsg && targetVm` already
|
||||||
|
// guarantees this VM migrated successfully.
|
||||||
|
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planCanceled && !!targetVm && !!product && !!cluster;
|
||||||
|
const vmDetailLocation = canNavigateToVm ? {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
|
||||||
|
params: {
|
||||||
|
product,
|
||||||
|
cluster,
|
||||||
|
resource: HCI.VM,
|
||||||
|
namespace: targetVm.metadata.namespace,
|
||||||
|
id: targetVm.metadata.name,
|
||||||
|
}
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
vmId: vm.id || '',
|
||||||
|
progress: overallProgress,
|
||||||
|
currentStep,
|
||||||
|
pipelineTooltip: formatPipelineTooltip(pipeline),
|
||||||
|
errorMsg,
|
||||||
|
canceled: plan.planCanceled,
|
||||||
|
vmDetailLocation,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no migration progress but we have VMs in the spec, show them at 0%
|
||||||
|
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
|
||||||
|
plan.vmProgress = plan.spec.vms.map((vm) => ({
|
||||||
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
vmId: vm.id || '',
|
||||||
|
progress: 0,
|
||||||
|
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
|
||||||
|
pipelineTooltip: formatPipelineTooltip([]),
|
||||||
|
errorMsg: '',
|
||||||
|
canceled: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
|
||||||
|
|
||||||
|
const durationResult = formatDuration(plan.status?.migration?.started, plan.status?.migration?.completed);
|
||||||
|
|
||||||
|
plan.duration = durationResult.display;
|
||||||
|
plan.durationSeconds = durationResult.seconds;
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLocation = computed(() => ({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
|
||||||
|
{
|
||||||
|
...NAME_COL,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
|
||||||
|
},
|
||||||
|
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
|
||||||
|
{
|
||||||
|
name: 'progress',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
|
||||||
|
value: 'progress',
|
||||||
|
width: 450,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mappings',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duration',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
|
||||||
|
value: 'duration',
|
||||||
|
sort: 'durationSeconds',
|
||||||
|
width: 90,
|
||||||
|
},
|
||||||
|
{ ...AGE },
|
||||||
|
];
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
try {
|
||||||
|
// Guard: the Forklift dashboard is only available when the Forklift addon is
|
||||||
|
// enabled. Redirect back to the Harvester dashboard otherwise (e.g. direct URL).
|
||||||
|
let forkliftEnabled = false;
|
||||||
|
|
||||||
|
if (store.getters[`${ inStore.value }/schemaFor`](HCI.ADD_ONS)) {
|
||||||
|
const addons = await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.ADD_ONS });
|
||||||
|
|
||||||
|
forkliftEnabled = addons.find((a) => a.metadata?.name === ADD_ONS.FORKLIFT_OPERATOR)?.spec?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forkliftEnabled) {
|
||||||
|
await currentRouter().replace({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
resource: HCI.DASHBOARD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// VMs are only needed to conditionally show the post-migration VM-detail
|
||||||
|
// link, so load them separately and non-fatally: a failure here (or the
|
||||||
|
// cost of findAll on large clusters) should not block plans/maps from
|
||||||
|
// rendering.
|
||||||
|
try {
|
||||||
|
await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.VM });
|
||||||
|
} catch (e) {
|
||||||
|
// Intentionally ignored: the dashboard still works without the VM list,
|
||||||
|
// the migrated VM-detail link simply won't be shown.
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div v-else>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<Masthead
|
||||||
|
:schema="schema"
|
||||||
|
:resource="schema.id"
|
||||||
|
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
|
||||||
|
>
|
||||||
|
<template #subHeader>
|
||||||
|
<div class="mmt-5">
|
||||||
|
<p
|
||||||
|
v-clean-html="t('harvester.addons.vmMigration.dashboard.description')"
|
||||||
|
class="text-muted"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #createButton>
|
||||||
|
<router-link
|
||||||
|
:to="createLocation"
|
||||||
|
class="btn role-primary"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</Masthead>
|
||||||
|
|
||||||
|
<ResourceTable
|
||||||
|
:schema="schema"
|
||||||
|
:rows="rows"
|
||||||
|
:headers="headers"
|
||||||
|
:groupable="false"
|
||||||
|
:table-actions="false"
|
||||||
|
:search="false"
|
||||||
|
key-field="_key"
|
||||||
|
>
|
||||||
|
<template #cell:name="{ row }">
|
||||||
|
<div class="plan-name-cell">
|
||||||
|
<router-link
|
||||||
|
v-if="row.detailLocation"
|
||||||
|
:to="row.detailLocation"
|
||||||
|
class="plan-name"
|
||||||
|
>
|
||||||
|
{{ row.metadata.name }}
|
||||||
|
</router-link>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="plan-name"
|
||||||
|
>
|
||||||
|
{{ row.metadata.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmCount="{ row }">
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
|
||||||
|
</template>
|
||||||
|
<template #cell:progress="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="row.vmProgress && row.vmProgress.length"
|
||||||
|
class="progress-cells"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="vm in row.vmProgress"
|
||||||
|
:key="vm.vmId"
|
||||||
|
class="vm-progress"
|
||||||
|
>
|
||||||
|
<div class="vm-progress-header">
|
||||||
|
<div class="vm-name-block">
|
||||||
|
<span class="vm-name">
|
||||||
|
{{ vm.vmName }}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vm-pct-block">
|
||||||
|
<div
|
||||||
|
v-clean-tooltip="{ content: vm.pipelineTooltip, html: true }"
|
||||||
|
class="vm-bar"
|
||||||
|
>
|
||||||
|
<PercentageBar
|
||||||
|
:model-value="vm.progress"
|
||||||
|
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
|
||||||
|
preferred-direction="MORE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="vm.progress >= 100"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.errorMsg"
|
||||||
|
class="step-label text-error"
|
||||||
|
>
|
||||||
|
{{ vm.errorMsg }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.canceled"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.currentStep"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ vm.currentStep }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="!row.vmProgress.length">-</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:mappings="{ row }">
|
||||||
|
<MappingsCell
|
||||||
|
:network-entries="row.networkEntries"
|
||||||
|
:storage-entries="row.storageEntries"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:duration="{ row }">
|
||||||
|
<span v-if="row.duration">{{ row.duration }}</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-muted"
|
||||||
|
>—</span>
|
||||||
|
</template>
|
||||||
|
</ResourceTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.plan-name-cell {
|
||||||
|
.plan-name {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-vms {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-cells {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-detail-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pct {
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pct-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-bar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&.text-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
289
pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue
Normal file
289
pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import CruResource from '@shell/components/CruResource';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { SECRET } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
||||||
|
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
|
||||||
|
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
|
||||||
|
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||||
|
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
|
||||||
|
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
|
||||||
|
import { HCI } from '@pkg/harvester/types';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const route = currentRoute();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const cruRef = ref(null);
|
||||||
|
const providerStepRef = ref(null);
|
||||||
|
const mappingsStepRef = ref(null);
|
||||||
|
|
||||||
|
const providerName = ref('');
|
||||||
|
const provider = ref(null);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const providerReady = ref(false);
|
||||||
|
const providerFormValid = ref(false);
|
||||||
|
const providerTesting = ref(false);
|
||||||
|
const mappingsReady = ref(false);
|
||||||
|
|
||||||
|
const existingNetworkMap = ref(null);
|
||||||
|
const existingStorageMap = ref(null);
|
||||||
|
const initialLoading = ref(true);
|
||||||
|
|
||||||
|
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||||
|
|
||||||
|
const providerId = route.query?.providerId || null;
|
||||||
|
const isEditMode = !!providerId;
|
||||||
|
|
||||||
|
const stepData = reactive({
|
||||||
|
provider: {
|
||||||
|
selectedProvider: isEditMode ? '' : '__create_new__',
|
||||||
|
providerName: '',
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
skipTlsVerify: false,
|
||||||
|
vddkInitImage: '',
|
||||||
|
testPassed: false,
|
||||||
|
testResult: null,
|
||||||
|
testError: null,
|
||||||
|
createdProvider: null,
|
||||||
|
createdSecret: null,
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
networkEntries: [],
|
||||||
|
storageEntries: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'configure-provider',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'configure-mappings',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch([providerFormValid, providerTesting], () => {
|
||||||
|
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||||
|
}, { immediate: true });
|
||||||
|
watch(mappingsReady, (val) => {
|
||||||
|
steps[1].ready = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||||
|
// "test connection before advancing" handshake, since the provider must be verified
|
||||||
|
// before the user can leave the first step.
|
||||||
|
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||||
|
|
||||||
|
const pendingProceed = ref(false);
|
||||||
|
|
||||||
|
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||||
|
// 1. When the user tries to advance off the provider step before it's ready,
|
||||||
|
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||||
|
// programmatically trigger the async "Test connection" button.
|
||||||
|
// 2. Once the test flips `providerReady`, mark the step ready and advance
|
||||||
|
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
|
||||||
|
watch(
|
||||||
|
() => wizardComponent.value?.activeStepIndex,
|
||||||
|
(newIdx, oldIdx) => {
|
||||||
|
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||||
|
wizardComponent.value.goToStep(1);
|
||||||
|
pendingProceed.value = true;
|
||||||
|
providerStepRef.value.clickTestButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(providerReady, (val) => {
|
||||||
|
if (val && pendingProceed.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
steps[0].ready = true;
|
||||||
|
wizardComponent.value.goToStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const wizardTitle = computed(() => {
|
||||||
|
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProviderComplete = (data) => {
|
||||||
|
providerName.value = data.providerName;
|
||||||
|
provider.value = data.provider;
|
||||||
|
providerReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderFormValid = (valid) => {
|
||||||
|
providerFormValid.value = valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderTesting = (testing) => {
|
||||||
|
providerTesting.value = testing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderReady = (ready) => {
|
||||||
|
providerReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMappingsReady = (ready) => {
|
||||||
|
mappingsReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear mappings when provider changes
|
||||||
|
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerListLocation = {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
resource: HCI.FORKLIFT_PROVIDER,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async(buttonCb) => {
|
||||||
|
try {
|
||||||
|
await mappingsStepRef.value.saveMappings();
|
||||||
|
buttonCb(true);
|
||||||
|
currentRouter().push(providerListLocation);
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
currentRouter().push(providerListLocation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
if (!isEditMode) {
|
||||||
|
initialLoading.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
id: providerId,
|
||||||
|
opt: { force: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.value = fetchedProvider;
|
||||||
|
providerName.value = fetchedProvider.metadata.name;
|
||||||
|
|
||||||
|
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
|
||||||
|
stepData.provider.providerName = fetchedProvider.metadata.name;
|
||||||
|
stepData.provider.url = fetchedProvider.spec?.url || '';
|
||||||
|
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
|
||||||
|
stepData.provider.createdProvider = fetchedProvider;
|
||||||
|
|
||||||
|
const secretRef = fetchedProvider.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
|
||||||
|
type: SECRET,
|
||||||
|
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = allSecrets.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret?.data) {
|
||||||
|
stepData.provider.username = decodeSecretValue(secret.data.user);
|
||||||
|
stepData.provider.password = decodeSecretValue(secret.data.password);
|
||||||
|
stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||||
|
stepData.provider.createdSecret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing default maps for this provider
|
||||||
|
try {
|
||||||
|
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||||
|
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||||
|
|
||||||
|
existingNetworkMap.value = allNetworkMaps.find(
|
||||||
|
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
existingStorageMap.value = allStorageMaps.find(
|
||||||
|
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
|
||||||
|
) || null;
|
||||||
|
} catch (e) {
|
||||||
|
// Maps may not exist yet
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })];
|
||||||
|
}
|
||||||
|
|
||||||
|
initialLoading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="initialLoading" />
|
||||||
|
<CruResource
|
||||||
|
v-else
|
||||||
|
ref="cruRef"
|
||||||
|
:resource="dummyResource"
|
||||||
|
:mode="isEditMode ? 'edit' : 'create'"
|
||||||
|
:steps="steps"
|
||||||
|
:errors="errors"
|
||||||
|
:validation-passed="true"
|
||||||
|
:can-yaml="false"
|
||||||
|
:cancel-event="true"
|
||||||
|
:title="wizardTitle"
|
||||||
|
finish-mode="finish"
|
||||||
|
class="wizard"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<template #configure-provider>
|
||||||
|
<ConfigureProviderStep
|
||||||
|
ref="providerStepRef"
|
||||||
|
:step-data="stepData.provider"
|
||||||
|
:create-only="true"
|
||||||
|
:edit-mode="isEditMode"
|
||||||
|
@complete="onProviderComplete"
|
||||||
|
@ready="onProviderReady"
|
||||||
|
@form-valid="onProviderFormValid"
|
||||||
|
@testing="onProviderTesting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #configure-mappings>
|
||||||
|
<ConfigureMappingsStep
|
||||||
|
ref="mappingsStepRef"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:step-data="stepData.mappings"
|
||||||
|
:use-all-provider-data="true"
|
||||||
|
:existing-network-map="existingNetworkMap"
|
||||||
|
:existing-storage-map="existingStorageMap"
|
||||||
|
@ready="onMappingsReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CruResource>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,318 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import CruResource from '@shell/components/CruResource';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
|
||||||
|
import SelectVmsStep from '@pkg/harvester/components/vm-migration/SelectVmsStep.vue';
|
||||||
|
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
|
||||||
|
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
|
||||||
|
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||||
|
import { currentRouter } from '@pkg/harvester/utils/router';
|
||||||
|
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const cruRef = ref(null);
|
||||||
|
const providerStepRef = ref(null);
|
||||||
|
const reviewStepRef = ref(null);
|
||||||
|
|
||||||
|
const providerName = ref('');
|
||||||
|
const provider = ref(null);
|
||||||
|
const selectedVMs = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const providerReady = ref(false);
|
||||||
|
const providerFormValid = ref(false);
|
||||||
|
const providerTesting = ref(false);
|
||||||
|
const vmsReady = ref(false);
|
||||||
|
const vmsLoading = ref(true);
|
||||||
|
const mappingsReady = ref(false);
|
||||||
|
const reviewReady = ref(false);
|
||||||
|
|
||||||
|
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||||
|
|
||||||
|
const stepData = reactive({
|
||||||
|
provider: {
|
||||||
|
selectedProvider: '__create_new__',
|
||||||
|
providerName: '',
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
skipTlsVerify: false,
|
||||||
|
vddkInitImage: '',
|
||||||
|
testPassed: false,
|
||||||
|
testResult: null,
|
||||||
|
testError: null,
|
||||||
|
createdProvider: null,
|
||||||
|
createdSecret: null,
|
||||||
|
},
|
||||||
|
vms: {
|
||||||
|
discoveredVMs: [],
|
||||||
|
selectedVMIds: new Set(),
|
||||||
|
tableRows: [],
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
networkEntries: [],
|
||||||
|
storageEntries: [],
|
||||||
|
},
|
||||||
|
review: { planName: '', targetNamespace: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'configure-provider',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'select-vms',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.selectVms.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.selectVms.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'configure-mappings',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'review-migration',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch([providerFormValid, providerTesting], () => {
|
||||||
|
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||||
|
}, { immediate: true });
|
||||||
|
watch([vmsReady, vmsLoading], () => {
|
||||||
|
steps[1].ready = vmsReady.value && !vmsLoading.value;
|
||||||
|
});
|
||||||
|
watch(mappingsReady, (val) => {
|
||||||
|
steps[2].ready = val;
|
||||||
|
});
|
||||||
|
watch(reviewReady, (val) => {
|
||||||
|
steps[3].ready = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||||
|
// "test connection before advancing" handshake, since the provider must be verified
|
||||||
|
// before the user can leave the first step.
|
||||||
|
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||||
|
|
||||||
|
const pendingProceed = ref(false);
|
||||||
|
|
||||||
|
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||||
|
// 1. When the user tries to advance off the provider step before it's ready,
|
||||||
|
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||||
|
// programmatically trigger the async "Test connection" button.
|
||||||
|
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
|
||||||
|
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
|
||||||
|
// watcher clears `pendingProceed` and the user stays put.
|
||||||
|
watch(
|
||||||
|
() => wizardComponent.value?.activeStepIndex,
|
||||||
|
(newIdx, oldIdx) => {
|
||||||
|
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||||
|
wizardComponent.value.goToStep(1);
|
||||||
|
pendingProceed.value = true;
|
||||||
|
providerStepRef.value.clickTestButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(providerReady, (val) => {
|
||||||
|
if (val && pendingProceed.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
steps[0].ready = true;
|
||||||
|
wizardComponent.value.goToStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(providerTesting, (testing) => {
|
||||||
|
if (!testing && pendingProceed.value && !providerReady.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProviderComplete = (data) => {
|
||||||
|
providerName.value = data.providerName;
|
||||||
|
provider.value = data.provider;
|
||||||
|
providerReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderFormValid = (valid) => {
|
||||||
|
providerFormValid.value = valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderTesting = (testing) => {
|
||||||
|
providerTesting.value = testing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderReady = (ready) => {
|
||||||
|
providerReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVmsComplete = (data) => {
|
||||||
|
selectedVMs.value = data.selectedVMs;
|
||||||
|
vmsReady.value = data.selectedVMs.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVmsLoading = (val) => {
|
||||||
|
vmsLoading.value = val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMappingsReady = (ready) => {
|
||||||
|
mappingsReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReviewReady = (ready) => {
|
||||||
|
reviewReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditSelection = () => {
|
||||||
|
wizardComponent.value?.goToStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditMappings = () => {
|
||||||
|
wizardComponent.value?.goToStep(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemoveVm = (id) => {
|
||||||
|
selectedVMs.value = selectedVMs.value.filter((vm) => vm.id !== id);
|
||||||
|
stepData.vms.selectedVMIds.delete(id);
|
||||||
|
|
||||||
|
vmsReady.value = selectedVMs.value.length > 0;
|
||||||
|
if (!vmsReady.value) {
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear downstream data when provider changes
|
||||||
|
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
stepData.vms.discoveredVMs = [];
|
||||||
|
stepData.vms.selectedVMIds = new Set();
|
||||||
|
stepData.vms.tableRows = [];
|
||||||
|
selectedVMs.value = [];
|
||||||
|
vmsReady.value = false;
|
||||||
|
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
|
||||||
|
stepData.review.planName = '';
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear mappings and review when VMs are added to the selection. Pure removals
|
||||||
|
// (e.g. "Remove from Plan" on the review step) keep the existing mappings and plan name.
|
||||||
|
watch(selectedVMs, (newVal, oldVal) => {
|
||||||
|
const oldIds = new Set(oldVal.map((v) => v.id));
|
||||||
|
const added = newVal.some((v) => !oldIds.has(v.id));
|
||||||
|
|
||||||
|
if (oldVal.length > 0 && added) {
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
|
||||||
|
stepData.review.planName = '';
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrationListLocation = {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async(buttonCb) => {
|
||||||
|
try {
|
||||||
|
await reviewStepRef.value.startMigration();
|
||||||
|
buttonCb(true);
|
||||||
|
currentRouter().push(migrationListLocation);
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = exceptionToErrorsArray(err).map((e) => {
|
||||||
|
if (typeof e === 'string') {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringify(e);
|
||||||
|
});
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
currentRouter().push(migrationListLocation);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CruResource
|
||||||
|
ref="cruRef"
|
||||||
|
:resource="dummyResource"
|
||||||
|
:mode="'create'"
|
||||||
|
:steps="steps"
|
||||||
|
:errors="errors"
|
||||||
|
:validation-passed="true"
|
||||||
|
:can-yaml="false"
|
||||||
|
:cancel-event="true"
|
||||||
|
finish-mode="finish"
|
||||||
|
finish-button-mode="createAndStart"
|
||||||
|
class="wizard"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<template #configure-provider>
|
||||||
|
<ConfigureProviderStep
|
||||||
|
ref="providerStepRef"
|
||||||
|
:step-data="stepData.provider"
|
||||||
|
@complete="onProviderComplete"
|
||||||
|
@ready="onProviderReady"
|
||||||
|
@form-valid="onProviderFormValid"
|
||||||
|
@testing="onProviderTesting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #select-vms>
|
||||||
|
<SelectVmsStep
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:step-data="stepData.vms"
|
||||||
|
@complete="onVmsComplete"
|
||||||
|
@loading="onVmsLoading"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #configure-mappings>
|
||||||
|
<ConfigureMappingsStep
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:selected-vms="selectedVMs"
|
||||||
|
:step-data="stepData.mappings"
|
||||||
|
@ready="onMappingsReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #review-migration>
|
||||||
|
<ReviewMigrationStep
|
||||||
|
ref="reviewStepRef"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:selected-vms="selectedVMs"
|
||||||
|
:mapping-entries="stepData.mappings"
|
||||||
|
:step-data="stepData.review"
|
||||||
|
@ready="onReviewReady"
|
||||||
|
@edit-selection="onEditSelection"
|
||||||
|
@edit-mappings="onEditMappings"
|
||||||
|
@remove-vm="onRemoveVm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CruResource>
|
||||||
|
</template>
|
||||||
330
pkg/harvester/promptRemove/forklift.konveyor.io.plan.vue
Normal file
330
pkg/harvester/promptRemove/forklift.konveyor.io.plan.vue
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
<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>
|
||||||
@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
|
|||||||
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
||||||
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
||||||
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
||||||
|
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
|
||||||
|
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
|
||||||
|
import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@ -83,6 +86,18 @@ const routes = [
|
|||||||
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
||||||
path: `/:product/c/:cluster/projectsnamespaces`,
|
path: `/:product/c/:cluster/projectsnamespaces`,
|
||||||
component: ProjectNamespaces,
|
component: ProjectNamespaces,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration`,
|
||||||
|
component: ForkliftDashboard,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration/wizard`,
|
||||||
|
component: ForkliftVmMigrationWizard,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration/provider-wizard`,
|
||||||
|
component: ForkliftProviderWizard,
|
||||||
}, {
|
}, {
|
||||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
path: `/:product/c/:cluster/:resource`,
|
path: `/:product/c/:cluster/:resource`,
|
||||||
|
|||||||
@ -5,77 +5,82 @@
|
|||||||
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
|
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
|
||||||
|
|
||||||
export const HCI = {
|
export const HCI = {
|
||||||
VM: 'kubevirt.io.virtualmachine',
|
VM: 'kubevirt.io.virtualmachine',
|
||||||
VMI: 'kubevirt.io.virtualmachineinstance',
|
VMI: 'kubevirt.io.virtualmachineinstance',
|
||||||
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
|
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
|
||||||
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
|
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
|
||||||
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
|
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
|
||||||
IMAGE: 'harvesterhci.io.virtualmachineimage',
|
IMAGE: 'harvesterhci.io.virtualmachineimage',
|
||||||
SSH: 'harvesterhci.io.keypair',
|
SSH: 'harvesterhci.io.keypair',
|
||||||
VOLUME: 'harvesterhci.io.volume',
|
VOLUME: 'harvesterhci.io.volume',
|
||||||
USER: 'harvesterhci.io.user',
|
USER: 'harvesterhci.io.user',
|
||||||
SETTING: 'harvesterhci.io.setting',
|
SETTING: 'harvesterhci.io.setting',
|
||||||
UPGRADE: 'harvesterhci.io.upgrade',
|
UPGRADE: 'harvesterhci.io.upgrade',
|
||||||
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
|
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
|
||||||
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
|
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
|
||||||
BACKUP: 'harvesterhci.io.virtualmachinebackup',
|
BACKUP: 'harvesterhci.io.virtualmachinebackup',
|
||||||
RESTORE: 'harvesterhci.io.virtualmachinerestore',
|
RESTORE: 'harvesterhci.io.virtualmachinerestore',
|
||||||
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
|
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
|
||||||
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
|
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
|
||||||
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
|
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
|
||||||
SUBNET: 'kubeovn.io.subnet',
|
SUBNET: 'kubeovn.io.subnet',
|
||||||
VPC: 'kubeovn.io.vpc',
|
VPC: 'kubeovn.io.vpc',
|
||||||
IP: 'kubeovn.io.ip',
|
IP: 'kubeovn.io.ip',
|
||||||
VLAN: 'kubeovn.io.vlan',
|
VLAN: 'kubeovn.io.vlan',
|
||||||
IPTABLES_EIP: 'kubeovn.io.iptableseip',
|
IPTABLES_EIP: 'kubeovn.io.iptableseip',
|
||||||
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
|
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
|
||||||
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
|
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
|
||||||
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
|
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
|
||||||
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
|
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
|
||||||
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
|
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
|
||||||
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
|
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
|
||||||
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
||||||
CLUSTER: 'harvesterhci.io.management.cluster',
|
CLUSTER: 'harvesterhci.io.management.cluster',
|
||||||
DASHBOARD: 'harvesterhci.io.dashboard',
|
DASHBOARD: 'harvesterhci.io.dashboard',
|
||||||
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
||||||
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
||||||
HOST: 'harvesterhci.io.host',
|
HOST: 'harvesterhci.io.host',
|
||||||
VERSION: 'harvesterhci.io.version',
|
VERSION: 'harvesterhci.io.version',
|
||||||
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
|
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
|
||||||
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
|
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
|
||||||
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
|
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
|
||||||
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
|
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
|
||||||
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
|
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
|
||||||
FLOW: 'harvesterhci.io.logging.flow',
|
FLOW: 'harvesterhci.io.logging.flow',
|
||||||
OUTPUT: 'harvesterhci.io.logging.output',
|
OUTPUT: 'harvesterhci.io.logging.output',
|
||||||
STORAGE: 'harvesterhci.io.storage',
|
STORAGE: 'harvesterhci.io.storage',
|
||||||
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
|
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
|
||||||
KSTUNED: 'node.harvesterhci.io.ksmtuned',
|
KSTUNED: 'node.harvesterhci.io.ksmtuned',
|
||||||
HUGEPAGES: 'node.harvesterhci.io.hugepage',
|
HUGEPAGES: 'node.harvesterhci.io.hugepage',
|
||||||
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
|
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
|
||||||
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
|
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
|
||||||
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
|
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
|
||||||
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
|
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
|
||||||
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
|
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
|
||||||
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
|
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
|
||||||
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
|
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
|
||||||
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
|
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
|
||||||
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
|
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
|
||||||
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
|
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
|
||||||
ADD_ONS: 'harvesterhci.io.addon',
|
ADD_ONS: 'harvesterhci.io.addon',
|
||||||
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
|
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
|
||||||
SECRET: 'harvesterhci.io.secret',
|
SECRET: 'harvesterhci.io.secret',
|
||||||
INVENTORY: 'metal.harvesterhci.io.inventory',
|
INVENTORY: 'metal.harvesterhci.io.inventory',
|
||||||
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
|
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
|
||||||
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
|
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
|
||||||
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
|
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
|
||||||
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
|
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
|
||||||
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
|
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
|
||||||
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
|
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
|
||||||
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
||||||
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
||||||
MIGRATION: 'migration.harvesterhci.io',
|
MIGRATION: 'migration.harvesterhci.io',
|
||||||
|
// Forklift CRDs
|
||||||
|
FORKLIFT_PROVIDER: 'forklift.konveyor.io.provider',
|
||||||
|
FORKLIFT_NETWORK_MAP: 'forklift.konveyor.io.networkmap',
|
||||||
|
FORKLIFT_STORAGE_MAP: 'forklift.konveyor.io.storagemap',
|
||||||
|
FORKLIFT_PLAN: 'forklift.konveyor.io.plan',
|
||||||
|
FORKLIFT_MIGRATION: 'forklift.konveyor.io.migration',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
|
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
|
||||||
|
|||||||
43
pkg/harvester/utils/backup.js
Normal file
43
pkg/harvester/utils/backup.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -8,9 +8,11 @@
|
|||||||
* @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
|
addonName, resourceType, navGroup, types, requireSchema = true
|
||||||
}) {
|
}) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@ -22,13 +24,14 @@ export function registerAddonSideNav(store, productName, {
|
|||||||
const kickSideNav = () => {
|
const kickSideNav = () => {
|
||||||
const TRIGGER = 'ui.refresh.trigger';
|
const TRIGGER = 'ui.refresh.trigger';
|
||||||
|
|
||||||
store.dispatch('type-map/addFavorite', TRIGGER);
|
// Toggle the trigger a few times so an early kick (fired before the SideNav
|
||||||
|
// has mounted on first login) is retried once the component is listening.
|
||||||
// SideNav component seem to ignore rapid state changes.
|
[0, 600, 1500].forEach((delay) => {
|
||||||
// Wait 600ms to ensure the toggle event triggers a re-render.
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
store.dispatch('type-map/addFavorite', TRIGGER);
|
||||||
store.dispatch('type-map/removeFavorite', TRIGGER);
|
setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
|
||||||
}, 600);
|
}, delay);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAccessibleSchema = (t) => {
|
const hasAccessibleSchema = (t) => {
|
||||||
@ -57,7 +60,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 ? types.filter(hasAccessibleSchema) : [];
|
const accessibleTypes = visible ? (requireSchema ? types.filter(hasAccessibleSchema) : types) : [];
|
||||||
|
|
||||||
// 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).
|
||||||
@ -90,16 +93,25 @@ export function registerAddonSideNav(store, productName, {
|
|||||||
// Store is ready. Stop polling.
|
// Store is ready. Stop polling.
|
||||||
clearInterval(waitForStore);
|
clearInterval(waitForStore);
|
||||||
|
|
||||||
// Watch the specific addon resource for changes to its enabled status.
|
// Watch the addon's enabled status together with the schema availability
|
||||||
|
// of the gated types. Schemas (e.g. forklift CRDs) can load after the
|
||||||
|
// addon is already enabled, so the watcher must also re-run when they
|
||||||
|
// become accessible; otherwise the menu never updates until a refresh.
|
||||||
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;
|
||||||
|
|
||||||
return addon?.spec?.enabled === true;
|
const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true;
|
||||||
|
|
||||||
|
return `${ isEnabled }:${ schemaReady }`;
|
||||||
},
|
},
|
||||||
(isEnabled) => {
|
() => {
|
||||||
setMenuVisibility(isEnabled);
|
const addons = store.getters[`${ productName }/all`](resourceType);
|
||||||
|
const addon = addons.find((a) => a.metadata.name === addonName);
|
||||||
|
|
||||||
|
setMenuVisibility(addon?.spec?.enabled === true);
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
|
|||||||
93
pkg/harvester/utils/forklift.js
Normal file
93
pkg/harvester/utils/forklift.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// Shared helpers for the Forklift VM-migration feature.
|
||||||
|
// Keep the map-spec builders here so the wizard step and the review step
|
||||||
|
// (and any future edit flow) stay in sync instead of drifting apart.
|
||||||
|
|
||||||
|
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
|
||||||
|
*/
|
||||||
|
export function decodeSecretValue(val) {
|
||||||
|
try {
|
||||||
|
return atob(val || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes → whole GiB (rounded). Returns 0 for falsy input.
|
||||||
|
*/
|
||||||
|
export function bytesToGB(bytes) {
|
||||||
|
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MiB → whole GiB (rounded). Returns 0 for falsy input.
|
||||||
|
*/
|
||||||
|
export function mbToGB(mb) {
|
||||||
|
return Math.round((mb || 0) / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
|
||||||
|
* Entries without a chosen target are dropped so we never emit an invalid
|
||||||
|
* multus destination with an empty name.
|
||||||
|
*
|
||||||
|
* @param {Array} entries network mapping entries ({ name, id, target })
|
||||||
|
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
|
||||||
|
*/
|
||||||
|
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !!entry.target)
|
||||||
|
.map((entry) => {
|
||||||
|
const source = { name: entry.name, id: entry.id };
|
||||||
|
|
||||||
|
if (entry.target === 'pod') {
|
||||||
|
return { source, destination: { type: 'pod' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.target === 'ignored') {
|
||||||
|
return { source, destination: { type: 'ignored' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = entry.target.split('/');
|
||||||
|
const name = parts.length > 1 ? parts[1] : parts[0];
|
||||||
|
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
destination: {
|
||||||
|
type: 'multus', name, namespace
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
|
||||||
|
* Entries without a chosen target are dropped.
|
||||||
|
*
|
||||||
|
* @param {Array} entries storage mapping entries ({ name, id, target })
|
||||||
|
*/
|
||||||
|
export function buildStorageMapEntries(entries = []) {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !!entry.target)
|
||||||
|
.map((entry) => {
|
||||||
|
const destination = { storageClass: entry.target };
|
||||||
|
|
||||||
|
if (entry.volumeMode) {
|
||||||
|
destination.volumeMode = entry.volumeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forklift StorageMap destination expects a single `accessMode` value.
|
||||||
|
if (Array.isArray(entry.accessModes) && entry.accessModes.length) {
|
||||||
|
destination.accessMode = entry.accessModes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: { name: entry.name, id: entry.id },
|
||||||
|
destination,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
11
pkg/harvester/utils/router.js
Normal file
11
pkg/harvester/utils/router.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Mirrors Resource class methods from @shell/plugins/dashboard-store/resource-class.js
|
||||||
|
// useRouter()/useRoute() from vue-router don't work in extension mode,
|
||||||
|
// so we use the same global access pattern the shell uses internally.
|
||||||
|
|
||||||
|
export function currentRouter() {
|
||||||
|
return window.$globalApp.$router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentRoute() {
|
||||||
|
return window.$globalApp.$route;
|
||||||
|
}
|
||||||
@ -69,17 +69,11 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
|
|||||||
validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType);
|
validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType);
|
||||||
});
|
});
|
||||||
|
|
||||||
let hasBootableVolume = false;
|
|
||||||
|
|
||||||
_volumes.forEach((V, idx) => {
|
_volumes.forEach((V, idx) => {
|
||||||
const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value);
|
const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value);
|
||||||
|
|
||||||
const prefix = V.name || idx + 1;
|
const prefix = V.name || idx + 1;
|
||||||
|
|
||||||
if ([SOURCE_TYPE.IMAGE, SOURCE_TYPE.ATTACH_VOLUME, SOURCE_TYPE.CONTAINER].includes(type)) {
|
|
||||||
hasBootableVolume = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) {
|
if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) {
|
||||||
if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) {
|
if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) {
|
||||||
const key = getters['i18n/t']('harvester.fields.size');
|
const key = getters['i18n/t']('harvester.fields.size');
|
||||||
@ -136,13 +130,6 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* At least one bootable volume must be provided. (Verify only when create.)
|
|
||||||
*/
|
|
||||||
if (!hasBootableVolume && !value.links) {
|
|
||||||
errors.push(getters['i18n/t']('harvester.validation.vm.volume.needAtLeastOneBootable'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,11 @@ if (process.argv.length < 3) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prTitle = process.argv[2];
|
const prTitle = process.argv[2];
|
||||||
const prLabel = prTitle.split(':')[0].trim();
|
|
||||||
|
// Parse the conventional-commit prefix `type(scope)!:`, keeping only the type
|
||||||
|
// and dropping an optional scope and breaking-change marker.
|
||||||
|
const prefixMatch = prTitle.match(/^\s*([a-zA-Z]+)(?:\([^)]*\))?!?:/);
|
||||||
|
const prLabel = prefixMatch ? prefixMatch[1].toLowerCase() : '';
|
||||||
|
|
||||||
const config = await load({ extends: ["./commitlint.config.js"] });
|
const config = await load({ extends: ["./commitlint.config.js"] });
|
||||||
const commitPrefix = config?.rules?.['type-enum']?.[2] || [];
|
const commitPrefix = config?.rules?.['type-enum']?.[2] || [];
|
||||||
|
|||||||
315
yarn.lock
315
yarn.lock
@ -2181,15 +2181,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
|
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
|
||||||
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
||||||
|
|
||||||
"@rancher/icons@2.0.60":
|
"@rancher/icons@2.0.62":
|
||||||
version "2.0.60"
|
version "2.0.62"
|
||||||
resolved "https://registry.yarnpkg.com/@rancher/icons/-/icons-2.0.60.tgz#a7d8bd39a007ef8ecf1cb7bfe27d82f64f9cd75c"
|
resolved "https://registry.yarnpkg.com/@rancher/icons/-/icons-2.0.62.tgz#42d3aa89d03a2e0e551332e99c8102dfdd422746"
|
||||||
integrity sha512-5bbXVOc4Uk2OmACX67i1e1jOTeLg87T22u+alhwEZX+/WzBEH/B0DPVqSbuYQtKYkH0Me4ojnOVCzZ2HuuLHWQ==
|
integrity sha512-ywGk1NSrIdN1zllpbDRU3ESVW/D0ExRZFBz+YPGAo9G08VzWxKIcT5GG5Nd2GXLTcUestlq1JwkKDaVAM8hkmQ==
|
||||||
|
|
||||||
"@rancher/shell@3.0.12-rc.3":
|
"@rancher/shell@3.0.12-rc.7":
|
||||||
version "3.0.12-rc.3"
|
version "3.0.12-rc.7"
|
||||||
resolved "https://registry.yarnpkg.com/@rancher/shell/-/shell-3.0.12-rc.3.tgz#566ea39fac704fbed2e0bc070f47b5bb3b6a92fb"
|
resolved "https://registry.yarnpkg.com/@rancher/shell/-/shell-3.0.12-rc.7.tgz#509b42d1a40fc8f399dc8c15fd07f2ea4361af54"
|
||||||
integrity sha512-4mKX3Ou2t/P8ko5Tb8UuuxgttgQPPX66Rlq8vC6IftGowJ+ExtM6FJDg4bKy1Lu3DrDERHoQLmTHehDScRnSEw==
|
integrity sha512-aA2uX5roVhJtj8p09OQNREhFraa9MqSrjXjbAC+3uRLniQkhqx+INKpyBDvhkDy0wd7XWIGbX3cfl+Kz6mwSYA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/client-ec2" "3.1041.0"
|
"@aws-sdk/client-ec2" "3.1041.0"
|
||||||
"@aws-sdk/client-eks" "3.1041.0"
|
"@aws-sdk/client-eks" "3.1041.0"
|
||||||
@ -2201,21 +2201,28 @@
|
|||||||
"@babel/preset-typescript" "7.16.7"
|
"@babel/preset-typescript" "7.16.7"
|
||||||
"@novnc/novnc" "1.2.0"
|
"@novnc/novnc" "1.2.0"
|
||||||
"@popperjs/core" "2.11.8"
|
"@popperjs/core" "2.11.8"
|
||||||
"@rancher/icons" "2.0.60"
|
"@rancher/icons" "2.0.62"
|
||||||
"@smithy/fetch-http-handler" "5.1.1"
|
"@smithy/fetch-http-handler" "5.1.1"
|
||||||
"@types/is-url" "1.2.30"
|
"@types/is-url" "1.2.30"
|
||||||
"@types/node" "25.3.3"
|
"@types/node" "25.3.3"
|
||||||
"@types/semver" "^7.5.8"
|
"@types/semver" "^7.5.8"
|
||||||
"@typescript-eslint/eslint-plugin" "5.62.0"
|
"@typescript-eslint/eslint-plugin" "5.62.0"
|
||||||
"@typescript-eslint/parser" "5.62.0"
|
"@typescript-eslint/parser" "5.62.0"
|
||||||
|
"@vee-validate/zod" "4.15.0"
|
||||||
"@vue/cli-plugin-babel" "~5.0.0"
|
"@vue/cli-plugin-babel" "~5.0.0"
|
||||||
"@vue/cli-plugin-typescript" "~5.0.0"
|
"@vue/cli-plugin-typescript" "~5.0.0"
|
||||||
"@vue/cli-service" "5.0.8"
|
"@vue/cli-service" "5.0.8"
|
||||||
"@vue/test-utils" "2.4.6"
|
"@vue/test-utils" "2.4.6"
|
||||||
"@vue/vue3-jest" "27.0.0"
|
"@vue/vue3-jest" "27.0.0"
|
||||||
|
"@xterm/addon-canvas" "0.7.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/xterm" "6.0.0"
|
||||||
add "2.0.6"
|
add "2.0.6"
|
||||||
ansi_up "5.0.0"
|
ansi_up "5.0.0"
|
||||||
axios "1.15.2"
|
axios "1.16.0"
|
||||||
axios-retry "3.1.9"
|
axios-retry "3.1.9"
|
||||||
babel-eslint "10.1.0"
|
babel-eslint "10.1.0"
|
||||||
babel-preset-vue "2.0.2"
|
babel-preset-vue "2.0.2"
|
||||||
@ -2248,14 +2255,13 @@
|
|||||||
eslint-plugin-n "15.2.0"
|
eslint-plugin-n "15.2.0"
|
||||||
eslint-plugin-vue "9.32.0"
|
eslint-plugin-vue "9.32.0"
|
||||||
event-target-shim "5.0.1"
|
event-target-shim "5.0.1"
|
||||||
express "4.17.1"
|
express "4.22.2"
|
||||||
file-saver "2.0.2"
|
file-saver "2.0.5"
|
||||||
floating-vue "5.2.2"
|
floating-vue "5.2.2"
|
||||||
focus-trap "7.6.5"
|
focus-trap "7.6.5"
|
||||||
frontmatter-markdown-loader "3.7.0"
|
frontmatter-markdown-loader "3.7.0"
|
||||||
identicon.js "2.3.3"
|
identicon.js "2.3.3"
|
||||||
intl-messageformat "7.8.4"
|
intl-messageformat "7.8.4"
|
||||||
ip "2.0.1"
|
|
||||||
is-url "1.2.4"
|
is-url "1.2.4"
|
||||||
jest "27.5.1"
|
jest "27.5.1"
|
||||||
jest-serializer-vue "2.0.2"
|
jest-serializer-vue "2.0.2"
|
||||||
@ -2292,13 +2298,8 @@
|
|||||||
webpack-virtual-modules "0.6.2"
|
webpack-virtual-modules "0.6.2"
|
||||||
worker-loader "3.0.8"
|
worker-loader "3.0.8"
|
||||||
xmlbuilder2 "4.0.1"
|
xmlbuilder2 "4.0.1"
|
||||||
xterm "5.2.1"
|
|
||||||
xterm-addon-canvas "0.5.0"
|
|
||||||
xterm-addon-fit "0.8.0"
|
|
||||||
xterm-addon-search "0.13.0"
|
|
||||||
xterm-addon-web-links "0.9.0"
|
|
||||||
xterm-addon-webgl "0.16.0"
|
|
||||||
yarn "1.22.22"
|
yarn "1.22.22"
|
||||||
|
zod "3.24.3"
|
||||||
|
|
||||||
"@rtsao/scc@^1.1.0":
|
"@rtsao/scc@^1.1.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
@ -2824,10 +2825,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||||
|
|
||||||
"@types/lodash@4.17.24":
|
"@types/lodash@4.17.25":
|
||||||
version "4.17.24"
|
version "4.17.25"
|
||||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.24.tgz#4ae334fc62c0e915ca8ed8e35dcc6d4eeb29215f"
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.25.tgz#69765ac7bcddb0eb072961cf292524a8f5b3c2c0"
|
||||||
integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==
|
integrity sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==
|
||||||
|
|
||||||
"@types/mime@^1":
|
"@types/mime@^1":
|
||||||
version "1.3.5"
|
version "1.3.5"
|
||||||
@ -2846,10 +2847,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/node@*", "@types/node@25.3.3", "@types/node@25.9.4":
|
"@types/node@*", "@types/node@25.3.3", "@types/node@25.9.5":
|
||||||
version "25.9.4"
|
version "25.9.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.4.tgz#18b63c47f88c1fbbed9d55ea2b66ffd494a47001"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.5.tgz#0fefc09e6e82e94cde291bacf43522e989eb01a4"
|
||||||
integrity sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==
|
integrity sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types ">=7.24.0 <7.24.7"
|
undici-types ">=7.24.0 <7.24.7"
|
||||||
|
|
||||||
@ -3098,6 +3099,14 @@
|
|||||||
"@typescript-eslint/types" "5.62.0"
|
"@typescript-eslint/types" "5.62.0"
|
||||||
eslint-visitor-keys "^3.3.0"
|
eslint-visitor-keys "^3.3.0"
|
||||||
|
|
||||||
|
"@vee-validate/zod@4.15.0":
|
||||||
|
version "4.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vee-validate/zod/-/zod-4.15.0.tgz#2de193da114eda9f873b5a677f13c23bcecb58aa"
|
||||||
|
integrity sha512-MpvIKiyg9X5yD8bJW0no2AU7wtR2T5mrvD9tuPRiie951sU2n6QKgMV38qKKOiqFBCxsMSjIuLLLV3V5kVE4nQ==
|
||||||
|
dependencies:
|
||||||
|
type-fest "^4.8.3"
|
||||||
|
vee-validate "4.15.0"
|
||||||
|
|
||||||
"@vue-flow/background@^1.3.0":
|
"@vue-flow/background@^1.3.0":
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/@vue-flow/background/-/background-1.3.2.tgz#0c90cd05e5d60da017bbaf5a1c3eb6af7ed9b778"
|
resolved "https://registry.yarnpkg.com/@vue-flow/background/-/background-1.3.2.tgz#0c90cd05e5d60da017bbaf5a1c3eb6af7ed9b778"
|
||||||
@ -3690,6 +3699,36 @@
|
|||||||
"@webassemblyjs/ast" "1.14.1"
|
"@webassemblyjs/ast" "1.14.1"
|
||||||
"@xtuc/long" "4.2.2"
|
"@xtuc/long" "4.2.2"
|
||||||
|
|
||||||
|
"@xterm/addon-canvas@0.7.0":
|
||||||
|
version "0.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz#d3a3835f3634e0106e108661315ae14da23e8dd7"
|
||||||
|
integrity sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==
|
||||||
|
|
||||||
|
"@xterm/addon-fit@0.11.0":
|
||||||
|
version "0.11.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz#ba4778b69fcc9044a060c2176bbe077657d7b37e"
|
||||||
|
integrity sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==
|
||||||
|
|
||||||
|
"@xterm/addon-search@0.16.0":
|
||||||
|
version "0.16.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xterm/addon-search/-/addon-search-0.16.0.tgz#ddadcbc6e556e45fc4b153f8a0efdf2aa1456b82"
|
||||||
|
integrity sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==
|
||||||
|
|
||||||
|
"@xterm/addon-web-links@0.12.0":
|
||||||
|
version "0.12.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz#bc34ae46f4c12012256d451ac2b9be791c0b6bfa"
|
||||||
|
integrity sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==
|
||||||
|
|
||||||
|
"@xterm/addon-webgl@0.19.0":
|
||||||
|
version "0.19.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz#02533c3f7c0af3ac9a44bbb095cbd47d48e90c25"
|
||||||
|
integrity sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==
|
||||||
|
|
||||||
|
"@xterm/xterm@6.0.0":
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-6.0.0.tgz#93637b0f2ee3a70718b5746a27c9c506af16745b"
|
||||||
|
integrity sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==
|
||||||
|
|
||||||
"@xtuc/ieee754@^1.2.0":
|
"@xtuc/ieee754@^1.2.0":
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
|
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
|
||||||
@ -3730,7 +3769,7 @@ abort-controller@^3.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim "^5.0.0"
|
event-target-shim "^5.0.0"
|
||||||
|
|
||||||
accepts@~1.3.7, accepts@~1.3.8:
|
accepts@~1.3.8:
|
||||||
version "1.3.8"
|
version "1.3.8"
|
||||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
||||||
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
|
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
|
||||||
@ -4093,12 +4132,12 @@ axios-retry@3.1.9:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-retry-allowed "^1.1.0"
|
is-retry-allowed "^1.1.0"
|
||||||
|
|
||||||
axios@1.15.2:
|
axios@1.16.0:
|
||||||
version "1.15.2"
|
version "1.16.0"
|
||||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b"
|
resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.0.tgz#f8e5dd931cef2a5f8c32216d5784eda2f8750eb7"
|
||||||
integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==
|
integrity sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects "^1.15.11"
|
follow-redirects "^1.16.0"
|
||||||
form-data "^4.0.5"
|
form-data "^4.0.5"
|
||||||
proxy-from-env "^2.1.0"
|
proxy-from-env "^2.1.0"
|
||||||
|
|
||||||
@ -4328,26 +4367,10 @@ bn.js@^5.2.1, bn.js@^5.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e"
|
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e"
|
||||||
integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==
|
integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==
|
||||||
|
|
||||||
body-parser@1.19.0:
|
body-parser@~1.20.5:
|
||||||
version "1.19.0"
|
version "1.20.6"
|
||||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
|
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.6.tgz#60c789c78e0992d906da0a29d71ae01d15c1ed76"
|
||||||
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
|
integrity sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==
|
||||||
dependencies:
|
|
||||||
bytes "3.1.0"
|
|
||||||
content-type "~1.0.4"
|
|
||||||
debug "2.6.9"
|
|
||||||
depd "~1.1.2"
|
|
||||||
http-errors "1.7.2"
|
|
||||||
iconv-lite "0.4.24"
|
|
||||||
on-finished "~2.3.0"
|
|
||||||
qs "6.7.0"
|
|
||||||
raw-body "2.4.0"
|
|
||||||
type-is "~1.6.17"
|
|
||||||
|
|
||||||
body-parser@~1.20.3:
|
|
||||||
version "1.20.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f"
|
|
||||||
integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==
|
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes "~3.1.2"
|
bytes "~3.1.2"
|
||||||
content-type "~1.0.5"
|
content-type "~1.0.5"
|
||||||
@ -4357,7 +4380,7 @@ body-parser@~1.20.3:
|
|||||||
http-errors "~2.0.1"
|
http-errors "~2.0.1"
|
||||||
iconv-lite "~0.4.24"
|
iconv-lite "~0.4.24"
|
||||||
on-finished "~2.4.1"
|
on-finished "~2.4.1"
|
||||||
qs "~6.14.0"
|
qs "~6.15.1"
|
||||||
raw-body "~2.5.3"
|
raw-body "~2.5.3"
|
||||||
type-is "~1.6.18"
|
type-is "~1.6.18"
|
||||||
unpipe "~1.0.0"
|
unpipe "~1.0.0"
|
||||||
@ -4535,11 +4558,6 @@ builtins@^4.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver "^7.0.0"
|
semver "^7.0.0"
|
||||||
|
|
||||||
bytes@3.1.0:
|
|
||||||
version "3.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
|
||||||
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
|
|
||||||
|
|
||||||
bytes@3.1.2, bytes@~3.1.2:
|
bytes@3.1.2, bytes@~3.1.2:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||||
@ -5026,13 +5044,6 @@ constants-browserify@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
|
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
|
||||||
integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==
|
integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==
|
||||||
|
|
||||||
content-disposition@0.5.3:
|
|
||||||
version "0.5.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
|
|
||||||
integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
|
|
||||||
dependencies:
|
|
||||||
safe-buffer "5.1.2"
|
|
||||||
|
|
||||||
content-disposition@~0.5.4:
|
content-disposition@~0.5.4:
|
||||||
version "0.5.4"
|
version "0.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||||
@ -5079,7 +5090,7 @@ convert-source-map@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
||||||
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
|
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
|
||||||
|
|
||||||
cookie-signature@1.0.6, cookie-signature@~1.0.6:
|
cookie-signature@~1.0.6:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||||
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
|
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
|
||||||
@ -5092,7 +5103,7 @@ cookie-universal@2.2.2:
|
|||||||
"@types/cookie" "^0.3.3"
|
"@types/cookie" "^0.3.3"
|
||||||
cookie "^0.4.0"
|
cookie "^0.4.0"
|
||||||
|
|
||||||
cookie@0.4.0, cookie@^0.4.0:
|
cookie@^0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
||||||
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
||||||
@ -6656,50 +6667,14 @@ expect@^27.5.1:
|
|||||||
jest-matcher-utils "^27.5.1"
|
jest-matcher-utils "^27.5.1"
|
||||||
jest-message-util "^27.5.1"
|
jest-message-util "^27.5.1"
|
||||||
|
|
||||||
express@4.17.1:
|
express@4.22.2, express@^4.17.3:
|
||||||
version "4.17.1"
|
version "4.22.2"
|
||||||
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
|
resolved "https://registry.yarnpkg.com/express/-/express-4.22.2.tgz#c17ae0981e5efc24b22272f0e041c4662503b700"
|
||||||
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
|
integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==
|
||||||
dependencies:
|
|
||||||
accepts "~1.3.7"
|
|
||||||
array-flatten "1.1.1"
|
|
||||||
body-parser "1.19.0"
|
|
||||||
content-disposition "0.5.3"
|
|
||||||
content-type "~1.0.4"
|
|
||||||
cookie "0.4.0"
|
|
||||||
cookie-signature "1.0.6"
|
|
||||||
debug "2.6.9"
|
|
||||||
depd "~1.1.2"
|
|
||||||
encodeurl "~1.0.2"
|
|
||||||
escape-html "~1.0.3"
|
|
||||||
etag "~1.8.1"
|
|
||||||
finalhandler "~1.1.2"
|
|
||||||
fresh "0.5.2"
|
|
||||||
merge-descriptors "1.0.1"
|
|
||||||
methods "~1.1.2"
|
|
||||||
on-finished "~2.3.0"
|
|
||||||
parseurl "~1.3.3"
|
|
||||||
path-to-regexp "0.1.7"
|
|
||||||
proxy-addr "~2.0.5"
|
|
||||||
qs "6.7.0"
|
|
||||||
range-parser "~1.2.1"
|
|
||||||
safe-buffer "5.1.2"
|
|
||||||
send "0.17.1"
|
|
||||||
serve-static "1.14.1"
|
|
||||||
setprototypeof "1.1.1"
|
|
||||||
statuses "~1.5.0"
|
|
||||||
type-is "~1.6.18"
|
|
||||||
utils-merge "1.0.1"
|
|
||||||
vary "~1.1.2"
|
|
||||||
|
|
||||||
express@^4.17.3:
|
|
||||||
version "4.22.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069"
|
|
||||||
integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==
|
|
||||||
dependencies:
|
dependencies:
|
||||||
accepts "~1.3.8"
|
accepts "~1.3.8"
|
||||||
array-flatten "1.1.1"
|
array-flatten "1.1.1"
|
||||||
body-parser "~1.20.3"
|
body-parser "~1.20.5"
|
||||||
content-disposition "~0.5.4"
|
content-disposition "~0.5.4"
|
||||||
content-type "~1.0.4"
|
content-type "~1.0.4"
|
||||||
cookie "~0.7.1"
|
cookie "~0.7.1"
|
||||||
@ -6718,7 +6693,7 @@ express@^4.17.3:
|
|||||||
parseurl "~1.3.3"
|
parseurl "~1.3.3"
|
||||||
path-to-regexp "~0.1.12"
|
path-to-regexp "~0.1.12"
|
||||||
proxy-addr "~2.0.7"
|
proxy-addr "~2.0.7"
|
||||||
qs "~6.14.0"
|
qs "~6.15.1"
|
||||||
range-parser "~1.2.1"
|
range-parser "~1.2.1"
|
||||||
safe-buffer "5.2.1"
|
safe-buffer "5.2.1"
|
||||||
send "~0.19.0"
|
send "~0.19.0"
|
||||||
@ -6825,10 +6800,10 @@ file-entry-cache@^6.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flat-cache "^3.0.4"
|
flat-cache "^3.0.4"
|
||||||
|
|
||||||
file-saver@2.0.2:
|
file-saver@2.0.5:
|
||||||
version "2.0.2"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.2.tgz#06d6e728a9ea2df2cce2f8d9e84dfcdc338ec17a"
|
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
|
||||||
integrity sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw==
|
integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
|
||||||
|
|
||||||
filelist@^1.0.4:
|
filelist@^1.0.4:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
@ -6844,19 +6819,6 @@ fill-range@^7.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range "^5.0.1"
|
to-regex-range "^5.0.1"
|
||||||
|
|
||||||
finalhandler@~1.1.2:
|
|
||||||
version "1.1.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
|
|
||||||
integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
|
|
||||||
dependencies:
|
|
||||||
debug "2.6.9"
|
|
||||||
encodeurl "~1.0.2"
|
|
||||||
escape-html "~1.0.3"
|
|
||||||
on-finished "~2.3.0"
|
|
||||||
parseurl "~1.3.3"
|
|
||||||
statuses "~1.5.0"
|
|
||||||
unpipe "~1.0.0"
|
|
||||||
|
|
||||||
finalhandler@~1.3.1:
|
finalhandler@~1.3.1:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88"
|
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88"
|
||||||
@ -6937,7 +6899,7 @@ focus-trap@7.6.5:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tabbable "^6.2.0"
|
tabbable "^6.2.0"
|
||||||
|
|
||||||
follow-redirects@1.16.0, follow-redirects@^1.0.0, follow-redirects@^1.15.11:
|
follow-redirects@1.16.0, follow-redirects@^1.0.0, follow-redirects@^1.16.0:
|
||||||
version "1.16.0"
|
version "1.16.0"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
|
||||||
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
|
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
|
||||||
@ -7433,9 +7395,9 @@ html-tags@^2.0.0:
|
|||||||
integrity sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g==
|
integrity sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g==
|
||||||
|
|
||||||
html-webpack-plugin@^5.0.0, html-webpack-plugin@^5.1.0:
|
html-webpack-plugin@^5.0.0, html-webpack-plugin@^5.1.0:
|
||||||
version "5.6.7"
|
version "5.6.8"
|
||||||
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz#429bab4e12abf3c07e1c608886608e2df2c06b11"
|
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz#5425dbc63eb533ffa59e92342be94894a125fc23"
|
||||||
integrity sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==
|
integrity sha512-MZmKQcTnhEh1SPSyMiEytIeDZDUoBZVorNHivQGXMASHf/BSGGOrKa2xQ5bGx3TCe1n109ecCt+cpww7wwWhKA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/html-minifier-terser" "^6.0.0"
|
"@types/html-minifier-terser" "^6.0.0"
|
||||||
html-minifier-terser "^6.0.2"
|
html-minifier-terser "^6.0.2"
|
||||||
@ -7458,7 +7420,7 @@ http-deceiver@^1.2.7:
|
|||||||
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
|
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
|
||||||
integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==
|
integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==
|
||||||
|
|
||||||
http-errors@1.7.2, http-errors@~1.7.2:
|
http-errors@~1.7.2:
|
||||||
version "1.7.2"
|
version "1.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
|
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
|
||||||
integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
|
integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
|
||||||
@ -9103,11 +9065,6 @@ meow@^12.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6"
|
resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6"
|
||||||
integrity sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==
|
integrity sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==
|
||||||
|
|
||||||
merge-descriptors@1.0.1:
|
|
||||||
version "1.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
|
|
||||||
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==
|
|
||||||
|
|
||||||
merge-descriptors@1.0.3:
|
merge-descriptors@1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
|
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
|
||||||
@ -9876,11 +9833,6 @@ path-parse@^1.0.7:
|
|||||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||||
|
|
||||||
path-to-regexp@0.1.7:
|
|
||||||
version "0.1.7"
|
|
||||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
|
|
||||||
integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==
|
|
||||||
|
|
||||||
path-to-regexp@~0.1.12:
|
path-to-regexp@~0.1.12:
|
||||||
version "0.1.13"
|
version "0.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d"
|
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d"
|
||||||
@ -10317,7 +10269,7 @@ proto-list@~1.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
|
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
|
||||||
integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==
|
integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==
|
||||||
|
|
||||||
proxy-addr@~2.0.5, proxy-addr@~2.0.7:
|
proxy-addr@~2.0.7:
|
||||||
version "2.0.7"
|
version "2.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
|
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
|
||||||
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
|
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
|
||||||
@ -10377,7 +10329,7 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
qs@6.15.3, qs@6.7.0, qs@^6.12.3, qs@~6.14.0:
|
qs@6.15.3, qs@^6.12.3, qs@~6.15.1:
|
||||||
version "6.15.3"
|
version "6.15.3"
|
||||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b"
|
||||||
integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==
|
integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==
|
||||||
@ -10420,16 +10372,6 @@ range-parser@^1.2.1, range-parser@~1.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
||||||
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
||||||
|
|
||||||
raw-body@2.4.0:
|
|
||||||
version "2.4.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
|
|
||||||
integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
|
|
||||||
dependencies:
|
|
||||||
bytes "3.1.0"
|
|
||||||
http-errors "1.7.2"
|
|
||||||
iconv-lite "0.4.24"
|
|
||||||
unpipe "1.0.0"
|
|
||||||
|
|
||||||
raw-body@~2.5.3:
|
raw-body@~2.5.3:
|
||||||
version "2.5.3"
|
version "2.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2"
|
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2"
|
||||||
@ -10697,10 +10639,10 @@ ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3:
|
|||||||
hash-base "^3.1.2"
|
hash-base "^3.1.2"
|
||||||
inherits "^2.0.4"
|
inherits "^2.0.4"
|
||||||
|
|
||||||
roarr@7.21.6:
|
roarr@7.21.7:
|
||||||
version "7.21.6"
|
version "7.21.7"
|
||||||
resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.21.6.tgz#91c7f0c4b83de49a76b801e73782086baf4a7c8d"
|
resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.21.7.tgz#b77c762660378234e1370c3e333596a744c0f6b1"
|
||||||
integrity sha512-bE9gaDGzWu4BxkyxUEVHfnHruvF4gX9NAZBz86EVJoun2J18exVR/3Wwk0hhu8MWXtabkTVveNcOk1lzHaZbVw==
|
integrity sha512-6X9ID9jb83kPkDxm5xLb9rZdGm6ksdDjpeL0iW5weKelJFi+NVBSowRpEuH8CdpX2x+7RW0TZaezibE7yQB7pg==
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-printf "^1.6.9"
|
fast-printf "^1.6.9"
|
||||||
safe-stable-stringify "^2.4.3"
|
safe-stable-stringify "^2.4.3"
|
||||||
@ -10734,16 +10676,16 @@ safe-array-concat@^1.1.3:
|
|||||||
has-symbols "^1.1.0"
|
has-symbols "^1.1.0"
|
||||||
isarray "^2.0.5"
|
isarray "^2.0.5"
|
||||||
|
|
||||||
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
|
||||||
version "5.1.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
|
||||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
|
||||||
|
|
||||||
safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0:
|
safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0:
|
||||||
version "5.2.1"
|
version "5.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||||
|
|
||||||
|
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||||
|
version "5.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||||
|
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||||
|
|
||||||
safe-push-apply@^1.0.0:
|
safe-push-apply@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5"
|
resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5"
|
||||||
@ -11775,7 +11717,7 @@ type-fest@^4.4.0, type-fest@^4.8.3:
|
|||||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"
|
||||||
integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
|
integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
|
||||||
|
|
||||||
type-is@~1.6.17, type-is@~1.6.18:
|
type-is@~1.6.18:
|
||||||
version "1.6.18"
|
version "1.6.18"
|
||||||
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
||||||
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
||||||
@ -11918,7 +11860,7 @@ universalify@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
|
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
|
||||||
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
||||||
|
|
||||||
unpipe@1.0.0, unpipe@~1.0.0:
|
unpipe@~1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||||
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||||
@ -12012,6 +11954,14 @@ vary@~1.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||||
|
|
||||||
|
vee-validate@4.15.0:
|
||||||
|
version "4.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vee-validate/-/vee-validate-4.15.0.tgz#eb77a9c867669d34abbc33ca5e16f2a991eb7ad5"
|
||||||
|
integrity sha512-PGJh1QCFwCBjbHu5aN6vB8macYVWrajbDvgo1Y/8fz9n/RVIkLmZCJDpUgu7+mUmCOPMxeyq7vXUOhbwAqdXcA==
|
||||||
|
dependencies:
|
||||||
|
"@vue/devtools-api" "^7.5.2"
|
||||||
|
type-fest "^4.8.3"
|
||||||
|
|
||||||
vee-validate@^4.15.1:
|
vee-validate@^4.15.1:
|
||||||
version "4.15.1"
|
version "4.15.1"
|
||||||
resolved "https://registry.yarnpkg.com/vee-validate/-/vee-validate-4.15.1.tgz#66e787e88916baa05f42e53bc0848e0878d50f91"
|
resolved "https://registry.yarnpkg.com/vee-validate/-/vee-validate-4.15.1.tgz#66e787e88916baa05f42e53bc0848e0878d50f91"
|
||||||
@ -12542,36 +12492,6 @@ xtend@^4.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||||
|
|
||||||
xterm-addon-canvas@0.5.0:
|
|
||||||
version "0.5.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/xterm-addon-canvas/-/xterm-addon-canvas-0.5.0.tgz#95d056cec6da42a51b2c47746a011409020c388c"
|
|
||||||
integrity sha512-QOo/eZCMrCleAgMimfdbaZCgmQRWOml63Ued6RwQ+UTPvQj3Av9QKx3xksmyYrDGRO/AVRXa9oNuzlYvLdmoLQ==
|
|
||||||
|
|
||||||
xterm-addon-fit@0.8.0:
|
|
||||||
version "0.8.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz#48ca99015385141918f955ca7819e85f3691d35f"
|
|
||||||
integrity sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==
|
|
||||||
|
|
||||||
xterm-addon-search@0.13.0:
|
|
||||||
version "0.13.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz#21286f4db48aa949fbefce34bb8bc0c9d3cec627"
|
|
||||||
integrity sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==
|
|
||||||
|
|
||||||
xterm-addon-web-links@0.9.0:
|
|
||||||
version "0.9.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz#c65b18588d1f613e703eb6feb7f129e7ff1c63e7"
|
|
||||||
integrity sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==
|
|
||||||
|
|
||||||
xterm-addon-webgl@0.16.0:
|
|
||||||
version "0.16.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz#9872d08a64136f893b27ef9a6412136d3bf563c4"
|
|
||||||
integrity sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==
|
|
||||||
|
|
||||||
xterm@5.2.1:
|
|
||||||
version "5.2.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.2.1.tgz#b3fea7bdb55b9be1d4b31f4cd1091f26ac42afb8"
|
|
||||||
integrity sha512-cs5Y1fFevgcdoh2hJROMVIWwoBHD80P1fIP79gopLHJIE4kTzzblanoivxTiQ4+92YM9IxS36H1q0MxIJXQBcA==
|
|
||||||
|
|
||||||
y18n@^4.0.0:
|
y18n@^4.0.0:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|
||||||
@ -12677,3 +12597,8 @@ yocto-queue@^1.0.0:
|
|||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00"
|
||||||
integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==
|
integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==
|
||||||
|
|
||||||
|
zod@3.24.3:
|
||||||
|
version "3.24.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
|
||||||
|
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user