mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-01 11:46:06 +08:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3f9ff4ef | ||
|
|
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 | ||
|
|
495e80267d | ||
|
|
479c298f4a | ||
|
|
3fdde25d17 | ||
|
|
5090d8ecad | ||
|
|
b9334bafb7 | ||
|
|
9a675da756 | ||
|
|
8c0c36e022 | ||
|
|
140a85f6a8 | ||
|
|
e62ae7e1d8 | ||
|
|
d03cff645b | ||
|
|
34dfe4027e | ||
|
|
135d520b8d | ||
|
|
62a19ee3fb | ||
|
|
64f0f5fb87 | ||
|
|
93a692ff0c | ||
|
|
5e3f12de35 | ||
|
|
f115261889 | ||
|
|
5985913f5e | ||
|
|
661ab995f6 | ||
|
|
d3f63df883 | ||
|
|
44ef9195eb | ||
|
|
b18aebbbd4 | ||
|
|
d2db23d69a |
@@ -1,14 +1,23 @@
|
||||
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
|
||||
### Summary
|
||||
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
|
||||
|
||||
### PR 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 ?
|
||||
- [ ] Yes, the backend owner is:
|
||||
- [ ] Yes, the backend owner is @
|
||||
- [ ] No, frontend-only.
|
||||
|
||||
### Related Issue #
|
||||
<!-- Define findings related to the feature or bug issue. -->
|
||||
### Related Issue
|
||||
<!-- Link the issue as harvester/harvester#<issue_number> -->
|
||||
harvester/harvester#<issue_number>
|
||||
|
||||
|
||||
### Test Steps
|
||||
<!-- Provide the test steps for reviewer to verify the issue -->
|
||||
1. Go to ... page
|
||||
|
||||
### Test screenshot or video
|
||||
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
|
||||
<!-- Attach screenshot or video of the changes and eventual comparison -->
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
"semanticCommits": "enabled",
|
||||
"semanticCommitType": "deps",
|
||||
"prHourlyLimit": 12,
|
||||
"digest": {
|
||||
"enabled": false
|
||||
},
|
||||
"timezone": "Asia/Taipei",
|
||||
"schedule": ["after 10am on sunday"],
|
||||
"postUpdateOptions": ["yarnDedupeFewer"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["digest"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
@@ -45,14 +52,6 @@
|
||||
"groupName": "patch dependencies",
|
||||
"labels": ["patch-update"],
|
||||
"reviewers": ["a110605", "houhoucoop"]
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["digest", "pinDigest"],
|
||||
"automerge": false,
|
||||
"groupName": "digest dependencies",
|
||||
"labels": ["digest-update"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"reviewers": ["a110605", "houhoucoop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,12 +43,10 @@ jobs:
|
||||
needs:
|
||||
- setup-release-tag
|
||||
- check-version
|
||||
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@3b26a36bad555e5e2b8634b24823be29732f287c # master
|
||||
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
deployments: write
|
||||
pages: write
|
||||
pull-requests: write
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Build Standalone on PR Merge
|
||||
|
||||
on:
|
||||
workflow_dispatch: # manually trigger on Github Actions only support PR branch pushed to harvester/harvester-ui-extension
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -29,3 +30,35 @@ jobs:
|
||||
id-token: write
|
||||
with:
|
||||
CI_BRANCH: ${{github.ref_name}}
|
||||
comment-on-pr:
|
||||
name: Comment build result on PR
|
||||
# Only for manual runs so the existing push / pull_request flows are untouched.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Add build artifact comment to the PR opened from this branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Match scripts/version: sanitize the branch and map main -> latest.
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
DIR="latest"
|
||||
else
|
||||
DIR=$(printf '%s' "$BRANCH" | sed -E 's/[^a-zA-Z0-9.-]+/-/g')
|
||||
fi
|
||||
URL="https://releases.rancher.com/harvester-ui/dashboard/${DIR}/index.html"
|
||||
|
||||
PR=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$BRANCH" --json number --jq '.[0].number // empty')
|
||||
if [ -z "$PR" ]; then
|
||||
echo "No open PR found for branch $BRANCH, skipping comment."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BODY=$(printf '🚀 **UI Artifact Build Status:** `SUCCESS` ✅\n> ui-index: %s\n> ui-source: external' "$URL")
|
||||
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$BODY"
|
||||
|
||||
@@ -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
|
||||
@@ -28,9 +28,7 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
|
||||
- Run `yarn lint:fix` before commits.
|
||||
- Use conventional commit format:
|
||||
```
|
||||
<type>:
|
||||
|
||||
<description>
|
||||
<type>: <description>
|
||||
```
|
||||
- Follow existing naming conventions (PascalCase for components, camelCase for functions).
|
||||
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
|
||||
@@ -130,8 +128,8 @@ To get started, follow the `Development Setup` section.
|
||||
- `Vue.js`:
|
||||
- Composition API components are preferred over Options API.
|
||||
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
|
||||
- Place source tag above template above style.
|
||||
- style tag should contain `lang='scss' scoped`.
|
||||
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
|
||||
- The `<style>` tag should include `lang="scss" scoped`.
|
||||
- `Linting`: Follow the ESLint configuration in the root.
|
||||
|
||||
- **File Structure:**
|
||||
@@ -163,24 +161,26 @@ To get started, follow the `Development Setup` section.
|
||||
|
||||
## Harvester UI Extension Development Guide
|
||||
|
||||
1. Backward Compatibility
|
||||
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
|
||||
### Backward Compatibility
|
||||
|
||||
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
|
||||
|
||||
### Implementation Steps for New Features
|
||||
|
||||
2. Implementation Steps for New Features
|
||||
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]');
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
```
|
||||
computed: {
|
||||
newFeatureEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
|
||||
},
|
||||
},
|
||||
```
|
||||
Render: Use the result of the check to conditionally render the UI components.
|
||||
3. **Render**: Use the result of the check to conditionally render the UI components.
|
||||
|
||||
|
||||
## E2E Tests (Cypress)
|
||||
@@ -228,7 +228,7 @@ For the Renovate config, see `.github/renovate.json`.
|
||||
### Commit Message Format
|
||||
All commit messages must follow the conventional commit format:
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
<type>: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
|
||||
> **Note:**
|
||||
> This extension is available starting from **Rancher 2.10.0**. Ensure your Rancher version is **2.10.0 or later** to access Harvester integration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Commit Message Guidelines](#commit-message-guidelines)
|
||||
- [Branch Structure](#branch-structure)
|
||||
- [Testing Guidelines](#testing-guidelines)
|
||||
- [Release](#release)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
|
||||
@@ -12,7 +25,7 @@ For Harvester UI extension installation instructions, please refer to the page *
|
||||
|
||||
## 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
|
||||
|
||||
@@ -157,6 +170,13 @@ To test the standalone UI, configure Harvester to load the UI from an external s
|
||||
2. Set **ui-source** to `External`
|
||||
3. Set **ui-index** to the desired URL
|
||||
|
||||
|
||||
## Release
|
||||
|
||||
The Harvester UI Extension follows the [Harvester](https://github.com/harvester/harvester) release cycle. After RC1 is cut for a new Harvester version, we usually create and work from the corresponding release branch (for example, `release-harvester-v1.8`). The remaining RC builds and the final official release are published from that branch.
|
||||
|
||||
After Harvester releases a new version, update the Harvester entry in rancher/ui-plugin-charts [manifest.json](https://github.com/rancher/ui-plugin-charts/blob/aafd215debbc6cb3100e7ba4b0a542c932397acd/manifest.json#L133-L151). This ensures air-gapped users can pull the new Harvester UI Extension image.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute, start by reading this document, then visit our [Getting Started guide](https://extensions.rancher.io/extensions/next/extensions-getting-started) to learn how to develop and submit changes.
|
||||
|
||||
@@ -32,10 +32,11 @@ module.exports = {
|
||||
'subject-empty': [2, 'never'],
|
||||
'subject-full-stop': [2, 'never', '.'],
|
||||
'subject-max-length': [0, 'never'],
|
||||
'header-max-length': [0, 'always', 100],
|
||||
'body-leading-blank': [2, 'always'],
|
||||
'body-max-line-length': [0, 'always', 100],
|
||||
'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
|
||||
ignores: [
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
- Run `yarn lint:fix` before commits.
|
||||
- Use conventional commit format:
|
||||
```
|
||||
<type>:
|
||||
|
||||
<description>
|
||||
<type>: <description>
|
||||
```
|
||||
- Follow existing naming conventions (PascalCase for components, camelCase for functions).
|
||||
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
|
||||
|
||||
@@ -16,8 +16,8 @@ To get started, follow the `Development Setup` section.
|
||||
- `Vue.js`:
|
||||
- Composition API components are preferred over Options API.
|
||||
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
|
||||
- Place source tag above template above style.
|
||||
- style tag should contain `lang='scss' scoped`.
|
||||
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
|
||||
- The `<style>` tag should include `lang="scss" scoped`.
|
||||
- `Linting`: Follow the ESLint configuration in the root.
|
||||
|
||||
- **File Structure:**
|
||||
@@ -49,22 +49,24 @@ To get started, follow the `Development Setup` section.
|
||||
|
||||
## Harvester UI Extension Development Guide
|
||||
|
||||
1. Backward Compatibility
|
||||
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
|
||||
### Backward Compatibility
|
||||
|
||||
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
|
||||
|
||||
### Implementation Steps for New Features
|
||||
|
||||
2. Implementation Steps for New Features
|
||||
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]');
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
```
|
||||
computed: {
|
||||
newFeatureEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
|
||||
},
|
||||
},
|
||||
```
|
||||
Render: Use the result of the check to conditionally render the UI components.
|
||||
3. **Render**: Use the result of the check to conditionally render the UI components.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
### Commit Message Format
|
||||
All commit messages must follow the conventional commit format:
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
<type>: <description>
|
||||
|
||||
[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
|
||||
|
||||
+14
-8
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "harvester-ui-extension",
|
||||
"version": "1.9.0-dev",
|
||||
"version": "1.10.0-dev",
|
||||
"private": false,
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-class-static-block": "7.28.6",
|
||||
"@rancher/shell": "3.0.12-rc.3",
|
||||
"@babel/plugin-transform-class-static-block": "7.29.7",
|
||||
"@rancher/shell": "3.0.12-rc.7",
|
||||
"@vue-flow/background": "^1.3.0",
|
||||
"@vue-flow/controls": "^1.1.1",
|
||||
"@vue-flow/core": "^1.33.5",
|
||||
@@ -15,13 +15,19 @@
|
||||
"cache-loader": "^4.1.0",
|
||||
"color": "4.2.3",
|
||||
"ip": "2.0.1",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-web-links": "0.12.0",
|
||||
"@xterm/addon-webgl": "0.19.0",
|
||||
"@xterm/addon-canvas": "0.7.0",
|
||||
"node-polyfill-webpack-plugin": "^3.0.0",
|
||||
"elkjs": "^0.11.0",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "25.9.1",
|
||||
"@types/node": "25.9.5",
|
||||
"cronstrue": "2.59.0",
|
||||
"d3-color": "3.1.0",
|
||||
"ejs": "3.1.10",
|
||||
@@ -29,13 +35,13 @@
|
||||
"glob": "7.2.3",
|
||||
"glob-parent": "6.0.2",
|
||||
"json5": "2.2.3",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/lodash": "4.17.25",
|
||||
"merge": "2.1.1",
|
||||
"node-forge": "1.4.0",
|
||||
"nth-check": "2.1.1",
|
||||
"qs": "6.15.2",
|
||||
"roarr": "7.21.5",
|
||||
"semver": "7.8.1",
|
||||
"qs": "6.15.3",
|
||||
"roarr": "7.21.7",
|
||||
"semver": "7.8.5",
|
||||
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -271,4 +271,9 @@ export default {
|
||||
.filter-label .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fixed-header-actions .middle .filter + .btn-group {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -116,6 +116,11 @@ export default {
|
||||
.vm-schedule-dropdown .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fixed-header-actions .middle .vm-schedule-filter + .btn-group {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script>
|
||||
import { Banner } from '@components/Banner';
|
||||
import MatchExpressions from '@shell/components/form/MatchExpressions';
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { convert, simplify, matching as selectorMatching } from '@shell/utils/selector';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { NODE } from '@shell/config/types';
|
||||
import { NAME, AGE } from '@shell/config/table-headers';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterNodeSelector',
|
||||
|
||||
components: {
|
||||
Banner,
|
||||
MatchExpressions,
|
||||
ResourceTable,
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
this.updateMatchingResources();
|
||||
},
|
||||
|
||||
data() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return {
|
||||
matchingResources: {
|
||||
matched: 0,
|
||||
matches: [],
|
||||
none: true,
|
||||
sample: null,
|
||||
total: 0,
|
||||
},
|
||||
tableHeaders: [
|
||||
NAME,
|
||||
{
|
||||
name: 'host-ip',
|
||||
labelKey: 'tableHeaders.hostIp',
|
||||
search: ['internalIp'],
|
||||
value: 'internalIp',
|
||||
sort: ['internalIp'],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
name: 'cpuManager',
|
||||
labelKey: 'harvester.tableHeaders.cpuManager',
|
||||
value: 'id',
|
||||
formatter: 'HarvesterCPUPinning',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
name: 'diskState',
|
||||
labelKey: 'tableHeaders.diskState',
|
||||
value: 'diskState',
|
||||
formatter: 'HarvesterDiskState',
|
||||
width: 130,
|
||||
},
|
||||
AGE,
|
||||
],
|
||||
inStore,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler: 'updateMatchingResources',
|
||||
deep: true,
|
||||
},
|
||||
allResourcesInScope() {
|
||||
this.updateMatchingResources();
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
schema() {
|
||||
return this.$store.getters[`${ this.inStore }/schemaFor`](NODE);
|
||||
},
|
||||
|
||||
selectorExpressions: {
|
||||
get() {
|
||||
return convert(
|
||||
this.value.matchLabels || {},
|
||||
this.value.matchExpressions || []
|
||||
);
|
||||
},
|
||||
set(selectorExpressions) {
|
||||
const { matchLabels, matchExpressions } = simplify(selectorExpressions);
|
||||
|
||||
this.value['matchLabels'] = matchLabels;
|
||||
this.value['matchExpressions'] = matchExpressions;
|
||||
this.updateMatchingResources();
|
||||
},
|
||||
},
|
||||
|
||||
allNodes() {
|
||||
return this.$store.getters[`${ this.inStore }/all`](NODE) || [];
|
||||
},
|
||||
|
||||
allResourcesInScope() {
|
||||
return this.allNodes.length;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateMatchingResources: throttle(function() {
|
||||
const expressions = this.selectorExpressions;
|
||||
const allNodes = this.allNodes;
|
||||
|
||||
// Empty expressions with no key = no match
|
||||
const hasValidExpression = expressions.length > 0 && expressions.every((e) => !!e.key);
|
||||
|
||||
if (!hasValidExpression) {
|
||||
this.matchingResources = {
|
||||
matched: 0,
|
||||
matches: [],
|
||||
none: true,
|
||||
sample: null,
|
||||
total: allNodes.length,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = selectorMatching(allNodes, expressions, 'metadata.labels');
|
||||
|
||||
this.matchingResources = {
|
||||
matched: matches.length,
|
||||
matches,
|
||||
none: matches.length === 0,
|
||||
sample: matches[0]?.nameDisplay || null,
|
||||
total: allNodes.length,
|
||||
};
|
||||
}, 100, { trailing: true })
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<MatchExpressions
|
||||
v-model:value="selectorExpressions"
|
||||
:mode="mode"
|
||||
:show-remove="false"
|
||||
:type="'node'"
|
||||
:target-resources="allResourcesInScope"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner :color="(matchingResources.none ? 'warning' : 'success')">
|
||||
<span v-clean-html="t('generic.selectors.matchingResources.matchesSome', matchingResources)" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<ResourceTable
|
||||
:rows="matchingResources.matches"
|
||||
:headers="tableHeaders"
|
||||
key-field="id"
|
||||
:table-actions="false"
|
||||
:row-actions="false"
|
||||
:schema="schema"
|
||||
:groupable="false"
|
||||
:search="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,8 +55,12 @@ export default {
|
||||
!this.latestUpgrade.isUpgradeFailed;
|
||||
},
|
||||
|
||||
canUpgrade() {
|
||||
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
|
||||
},
|
||||
|
||||
versionOptions() {
|
||||
const versions = this.$store.getters['harvester/all'](HCI.VERSION);
|
||||
const versions = this.$store.getters['harvester/all'](HCI.VERSION) || [];
|
||||
|
||||
return versions.map((V) => V.metadata.name);
|
||||
},
|
||||
@@ -154,14 +158,23 @@ export default {
|
||||
:cluster="currentCluster.nameDisplay"
|
||||
/>
|
||||
</h1>
|
||||
<button
|
||||
v-if="versionOptions.length && !isUpgradeInProgress"
|
||||
type="button"
|
||||
class="btn bg-warning btn-sm"
|
||||
@click="open"
|
||||
<span
|
||||
v-clean-tooltip="{
|
||||
content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'),
|
||||
triggers: ['hover', 'focus', 'touch'],
|
||||
}"
|
||||
class="upgrade-btn-wrap"
|
||||
:tabindex="canUpgrade ? -1 : 0"
|
||||
>
|
||||
<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>
|
||||
|
||||
<ModalWithCard
|
||||
@@ -296,6 +309,18 @@ export default {
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
.upgrade-btn-wrap {
|
||||
display: inline-flex;
|
||||
|
||||
button {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.currentVersion {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import Socket, {
|
||||
EVENT_CONNECTED,
|
||||
EVENT_CONNECTING,
|
||||
@@ -22,74 +21,98 @@ export default {
|
||||
|
||||
data() {
|
||||
return {
|
||||
socket: null,
|
||||
terminal: null,
|
||||
fitAddon: null,
|
||||
searchAddon: null,
|
||||
webglAddon: null,
|
||||
isOpen: false,
|
||||
isOpening: false,
|
||||
backlog: [],
|
||||
firstTime: true,
|
||||
queue: []
|
||||
socket: null,
|
||||
terminal: null,
|
||||
textDecoder: null,
|
||||
fitAddon: null,
|
||||
searchAddon: null,
|
||||
webglAddon: null,
|
||||
onResize: null,
|
||||
isOpen: false,
|
||||
isOpening: false,
|
||||
backlog: [],
|
||||
firstTime: true,
|
||||
queue: [],
|
||||
isDraining: false,
|
||||
drainScheduled: false,
|
||||
drainTimer: null,
|
||||
showSearch: false,
|
||||
searchQuery: '',
|
||||
searchOptions: {
|
||||
caseSensitive: false,
|
||||
regex: false,
|
||||
wholeWord: false,
|
||||
incremental: true,
|
||||
decorations: {
|
||||
matchBackground: '#B45309',
|
||||
matchBorder: '#FDBA74',
|
||||
matchOverviewRuler: '#FDBA74',
|
||||
activeMatchBackground: '#EA580C',
|
||||
activeMatchBorder: '#FED7AA',
|
||||
activeMatchColorOverviewRuler: '#FED7AA',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
serialConsoleUrl() {
|
||||
return this.value?.getSerialConsolePath || '';
|
||||
},
|
||||
|
||||
xtermConfig() {
|
||||
return {
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
cursorWidth: 2,
|
||||
scrollback: 5000,
|
||||
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() {
|
||||
this.close();
|
||||
},
|
||||
|
||||
watch: {
|
||||
serialConsoleUrl() {
|
||||
if (this.terminal) {
|
||||
this.connect();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
this.textDecoder = new TextDecoder('utf-8');
|
||||
await this.setupTerminal();
|
||||
await this.connect();
|
||||
this.onResize = debounce(() => this.fit(), 100);
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.scheduleFit();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async setupTerminal() {
|
||||
const docStyle = getComputedStyle(document.querySelector('body'));
|
||||
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm');
|
||||
const xterm = await import(/* webpackChunkName: "xterm" */ '@xterm/xterm');
|
||||
|
||||
const addons = await allHash({
|
||||
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
|
||||
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
|
||||
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
|
||||
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
|
||||
fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'),
|
||||
webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'),
|
||||
weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'),
|
||||
search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'),
|
||||
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
|
||||
});
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
theme: {
|
||||
background: docStyle.getPropertyValue('--terminal-bg').trim(),
|
||||
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
|
||||
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
|
||||
foreground: docStyle.getPropertyValue('--terminal-text').trim()
|
||||
},
|
||||
...this.xtermConfig,
|
||||
@@ -97,25 +120,48 @@ export default {
|
||||
|
||||
this.fitAddon = new addons.fit.FitAddon();
|
||||
this.searchAddon = new addons.search.SearchAddon();
|
||||
|
||||
try {
|
||||
this.webglAddon = new addons.webgl.WebGlAddon();
|
||||
} catch (e) {
|
||||
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
|
||||
this.webglAddon = null;
|
||||
}
|
||||
|
||||
terminal.loadAddon(this.fitAddon);
|
||||
terminal.loadAddon(this.searchAddon);
|
||||
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
|
||||
terminal.open(this.$refs.xterm);
|
||||
|
||||
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);
|
||||
|
||||
// Detect the silent "attaches but never paints" failure after a render frame.
|
||||
requestAnimationFrame(() => {
|
||||
if (this.isUnmounted || !this.terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = this.$refs.xterm?.querySelector('.xterm-helper-textarea');
|
||||
const hasPainted = textarea && textarea.getBoundingClientRect().height > 0;
|
||||
|
||||
if (this.webglAddon && !hasPainted) {
|
||||
this.fallbackToCanvas(terminal, addons);
|
||||
this.fit();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Some browsers (e.g. Safari) don't support the webgl renderer, so don't use it.
|
||||
this.fallbackToCanvas(terminal, addons);
|
||||
}
|
||||
|
||||
this.fit();
|
||||
this.flush();
|
||||
this.scheduleDrainQueue();
|
||||
|
||||
terminal.onData((input) => {
|
||||
const msg = this.str2ab(input);
|
||||
@@ -123,9 +169,132 @@ export default {
|
||||
this.write(msg);
|
||||
});
|
||||
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key?.toLowerCase();
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && key === 'f') {
|
||||
event.preventDefault();
|
||||
this.openSearch();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key === 'escape' && this.showSearch) {
|
||||
event.preventDefault();
|
||||
this.closeSearch();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.terminal = terminal;
|
||||
},
|
||||
|
||||
openSearch() {
|
||||
this.showSearch = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
const input = this.$refs.searchInput;
|
||||
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
closeSearch() {
|
||||
this.showSearch = false;
|
||||
this.searchQuery = '';
|
||||
this.clearSearchHighlights();
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
clearSearchHighlights() {
|
||||
this.searchAddon?.clearActiveDecoration();
|
||||
this.searchAddon?.clearDecorations();
|
||||
this.terminal?.clearSelection();
|
||||
|
||||
// Disposing the search decorations doesn't repaint the webgl/canvas
|
||||
// layer, so force a refresh to remove the lingering highlight.
|
||||
if (this.terminal) {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
}
|
||||
},
|
||||
|
||||
findNext() {
|
||||
if (!this.searchAddon || !this.searchQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchAddon.findNext(this.searchQuery, this.searchOptions);
|
||||
},
|
||||
|
||||
findPrevious() {
|
||||
if (!this.searchAddon || !this.searchQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchAddon.findPrevious(this.searchQuery, this.searchOptions);
|
||||
},
|
||||
|
||||
onSearchInput() {
|
||||
if (!this.searchQuery) {
|
||||
this.clearSearchHighlights();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.findNext();
|
||||
},
|
||||
|
||||
onSearchKeydown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
this.findPrevious();
|
||||
} else {
|
||||
this.findNext();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closeSearch();
|
||||
}
|
||||
},
|
||||
|
||||
scheduleFit() {
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.fit();
|
||||
|
||||
// Re-fit after layout settles to avoid undersized rows on first paint.
|
||||
setTimeout(() => this.fit(), 120);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Dispose the webgl renderer (if any) and switch to the canvas renderer.
|
||||
// Used when webgl fails to construct, loses its context, or silently never paints.
|
||||
fallbackToCanvas(terminal, addons) {
|
||||
if (this.isUnmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.webglAddon?.dispose();
|
||||
this.webglAddon = null;
|
||||
|
||||
if (!this.canvasAddon) {
|
||||
this.canvasAddon = new addons.canvas.CanvasAddon();
|
||||
terminal.loadAddon(this.canvasAddon);
|
||||
}
|
||||
this.fit();
|
||||
},
|
||||
|
||||
str2ab(str) {
|
||||
const enc = new TextEncoder();
|
||||
|
||||
@@ -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() {
|
||||
this.terminal.clear();
|
||||
},
|
||||
|
||||
getSocketUrl() {
|
||||
return `${ this.value?.getSerialConsolePath }`;
|
||||
return this.serialConsoleUrl;
|
||||
},
|
||||
|
||||
async connect() {
|
||||
@@ -181,6 +469,7 @@ export default {
|
||||
this.fit();
|
||||
this.flush();
|
||||
}
|
||||
this.scheduleFit();
|
||||
|
||||
if (this.firstTime) {
|
||||
this.socket.send(this.str2ab('\n'));
|
||||
@@ -195,7 +484,7 @@ export default {
|
||||
});
|
||||
|
||||
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
|
||||
this.queue.push(e.detail.data.text());
|
||||
this.enqueueMessage(this.decodeMessageData(e.detail.data));
|
||||
});
|
||||
|
||||
this.socket.connect();
|
||||
@@ -218,22 +507,19 @@ export default {
|
||||
}
|
||||
|
||||
this.fitAddon.fit();
|
||||
|
||||
const { rows, cols } = this.fitAddon.proposeDimensions();
|
||||
|
||||
if ( !this.isOpen ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify({
|
||||
Width: cols,
|
||||
Height: rows
|
||||
});
|
||||
|
||||
this.socket.send(this.str2ab(message));
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.onResize) {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.onResize = null;
|
||||
}
|
||||
|
||||
if (this.drainTimer) {
|
||||
cancelAnimationFrame(this.drainTimer);
|
||||
this.drainTimer = null;
|
||||
}
|
||||
|
||||
if ( this.socket ) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
@@ -248,6 +534,44 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="harvester-shell-container">
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="shell-search"
|
||||
>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="shell-search-input"
|
||||
placeholder="Search in terminal"
|
||||
@input="onSearchInput"
|
||||
@keydown="onSearchKeydown"
|
||||
>
|
||||
<button
|
||||
class="btn role-secondary btn-sm shell-search-btn"
|
||||
type="button"
|
||||
title="Shift+Enter"
|
||||
@click="findPrevious"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="btn role-secondary btn-sm shell-search-btn"
|
||||
type="button"
|
||||
title="Enter"
|
||||
@click="findNext"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
class="btn role-secondary btn-sm shell-search-btn"
|
||||
type="button"
|
||||
title="Esc"
|
||||
@click="closeSearch"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="xterm"
|
||||
class="shell-body"
|
||||
@@ -257,18 +581,73 @@ export default {
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../../node_modules/xterm/css/xterm.css';
|
||||
@import '../../../../node_modules/@xterm/xterm/css/xterm.css';
|
||||
|
||||
body, #__nuxt, #__layout, MAIN {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.harvester-shell-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.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%;
|
||||
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>
|
||||
|
||||
@@ -55,6 +55,10 @@ export default {
|
||||
const host = window.location.host;
|
||||
const prefix = window.location.pathname.replace(this.$route.path, '');
|
||||
const params = this.$route?.params;
|
||||
const popupWidth = Math.max(800, screen.width - 200);
|
||||
const popupHeight = Math.max(600, screen.height - 200);
|
||||
const popupLeft = Math.max(0, window.screenX + Math.floor((window.outerWidth - popupWidth) / 2));
|
||||
const popupTop = Math.max(0, window.screenY + Math.floor((window.outerHeight - popupHeight) / 2));
|
||||
|
||||
const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
|
||||
|
||||
@@ -63,7 +67,7 @@ export default {
|
||||
window.open(
|
||||
url,
|
||||
'_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`
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -64,6 +64,9 @@ export default {
|
||||
|
||||
try {
|
||||
parsedDefaultValue = JSON.parse(this.value.value);
|
||||
if (typeof parsedDefaultValue.exclusiveVlan !== 'boolean') {
|
||||
parsedDefaultValue.exclusiveVlan = false;
|
||||
}
|
||||
networkType = 'vlan' in parsedDefaultValue ? L2VLAN : UNTAGGED; // backend doesn't provide networkType, so we check if vlan is provided instead
|
||||
openVlan = true;
|
||||
} catch (error) {
|
||||
@@ -72,7 +75,8 @@ export default {
|
||||
vlan: '',
|
||||
clusterNetwork: '',
|
||||
range: '',
|
||||
exclude: []
|
||||
exclude: [],
|
||||
exclusiveVlan: false
|
||||
};
|
||||
}
|
||||
const exclude = parsedDefaultValue?.exclude?.toString().split(',') || [];
|
||||
@@ -94,6 +98,10 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
showExclusiveVlan() {
|
||||
return this.networkType === L2VLAN &&
|
||||
Number(this.parsedDefaultValue.vlan) !== 1;
|
||||
},
|
||||
showVlan() {
|
||||
return this.networkType === L2VLAN;
|
||||
},
|
||||
@@ -132,6 +140,18 @@ export default {
|
||||
};
|
||||
});
|
||||
},
|
||||
exclusiveVlanOptions() {
|
||||
return [
|
||||
{
|
||||
label: this.t('generic.enabled'),
|
||||
value: true
|
||||
},
|
||||
{
|
||||
label: this.t('generic.disabled'),
|
||||
value: false
|
||||
}
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -174,7 +194,8 @@ export default {
|
||||
vlan: '',
|
||||
clusterNetwork: '',
|
||||
range: '',
|
||||
exclude: []
|
||||
exclude: [],
|
||||
exclusiveVlan: false
|
||||
};
|
||||
},
|
||||
|
||||
@@ -280,7 +301,15 @@ export default {
|
||||
label-key="harvester.setting.storageNetwork.vlan"
|
||||
@update:value="inputVlan"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="showExclusiveVlan"
|
||||
v-model:value="parsedDefaultValue.exclusiveVlan"
|
||||
class="mb-20"
|
||||
:options="exclusiveVlanOptions"
|
||||
:mode="mode"
|
||||
label-key="harvester.setting.storageNetwork.exclusiveVlan"
|
||||
@update:value="update"
|
||||
/>
|
||||
<LabeledSelect
|
||||
v-model:value="parsedDefaultValue.clusterNetwork"
|
||||
label-key="harvester.setting.storageNetwork.clusterNetwork"
|
||||
|
||||
@@ -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_MODES = [ACCESS_MODE.READ_WRITE_MANY];
|
||||
|
||||
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_MODES],
|
||||
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
|
||||
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_MODES],
|
||||
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,175 @@
|
||||
<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 ACCESS_MODE_OPTIONS = [ACCESS_MODE.READ_WRITE_ONCE, ACCESS_MODE.READ_WRITE_MANY, ACCESS_MODE.READ_ONLY_MANY];
|
||||
|
||||
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: () => [ACCESS_MODE.READ_WRITE_MANY] },
|
||||
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
|
||||
});
|
||||
|
||||
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] || ACCESS_MODE.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] || ACCESS_MODE.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>
|
||||
@@ -72,7 +72,10 @@ const FEATURE_FLAGS = {
|
||||
'v1.9.0': [
|
||||
'supportFilesystem',
|
||||
'disableResourcePooling',
|
||||
'expandOnlineEncryptedVolume'
|
||||
'expandOnlineEncryptedVolume',
|
||||
'longhornV2HugepageSettings',
|
||||
'staticIPForVM',
|
||||
'fsFreezeDeadline',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ import {
|
||||
VM_IMPORT_SOURCE_O_STATUS,
|
||||
VM_IMPORT_SOURCE_OVA_URL,
|
||||
VM_IMPORT_SOURCE_OVA_STATUS,
|
||||
FORKLIFT_PROVIDER_TYPE,
|
||||
FORKLIFT_PROVIDER_URL,
|
||||
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||
FORKLIFT_MAP_DEST_PROVIDER,
|
||||
FORKLIFT_PLAN_TARGET_NS,
|
||||
FORKLIFT_PLAN_VM_COUNT,
|
||||
FORKLIFT_MIGRATION_PLAN,
|
||||
} from './table-headers';
|
||||
import { ADD_ONS } from './harvester-map';
|
||||
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
||||
@@ -54,6 +61,14 @@ import { registerAddonSideNav } from '../utils/dynamic-nav';
|
||||
const TEMPLATE = HCI.VM_VERSION;
|
||||
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
|
||||
const LOGGING_GROUP = 'Monitoring & Logging::Logging';
|
||||
const OVERLAY_NETWORKS_GROUP = 'Overlay Networks';
|
||||
const UNDERLAY_NETWORKS_GROUP = 'Underlay Networks';
|
||||
const NAT_INTERNET_GROUP = `${ OVERLAY_NETWORKS_GROUP }::NAT & Internet`;
|
||||
const GATEWAYS_GROUP = `${ NAT_INTERNET_GROUP }::Gateways`;
|
||||
const EXTERNAL_IPS_GROUP = `${ NAT_INTERNET_GROUP }::External IPs`;
|
||||
const RULES_GROUP = `${ NAT_INTERNET_GROUP }::Rules`;
|
||||
const SOURCE_RULES_GROUP = `${ RULES_GROUP }::Source Rules`;
|
||||
const DESTINATION_RULES_GROUP = `${ RULES_GROUP }::Destination Rules`;
|
||||
|
||||
export const PRODUCT_NAME = 'harvester';
|
||||
|
||||
@@ -581,14 +596,53 @@ export function init($plugin, store) {
|
||||
[
|
||||
HCI.CLUSTER_NETWORK,
|
||||
HCI.NETWORK_ATTACHMENT,
|
||||
HCI.VPC,
|
||||
NETWORK_POLICY,
|
||||
HCI.HOST_NETWORK_CONFIG,
|
||||
HCI.LB,
|
||||
HCI.IP_POOL,
|
||||
],
|
||||
'networks'
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.VPC],
|
||||
OVERLAY_NETWORKS_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[NETWORK_POLICY],
|
||||
OVERLAY_NETWORKS_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.VPC_NAT_GATEWAY],
|
||||
GATEWAYS_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.IPTABLES_EIP],
|
||||
EXTERNAL_IPS_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.IPTABLES_SNAT_RULE],
|
||||
SOURCE_RULES_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.IPTABLES_DNAT_RULE],
|
||||
DESTINATION_RULES_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.PROVIDER_NETWORK],
|
||||
UNDERLAY_NETWORKS_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[HCI.VLAN],
|
||||
UNDERLAY_NETWORKS_GROUP
|
||||
);
|
||||
|
||||
basicType(
|
||||
[
|
||||
HCI.SCHEDULE_VM_BACKUP,
|
||||
@@ -600,7 +654,11 @@ export function init($plugin, store) {
|
||||
);
|
||||
|
||||
weightGroup('networks', 494, true);
|
||||
weightGroup('backupAndSnapshot', 493, true);
|
||||
weightGroup('Overlay Networks', 493, true);
|
||||
weightGroup('NAT & Internet', 492, true);
|
||||
weightGroup('Rules', 491, true);
|
||||
weightGroup('Underlay Networks', 490, true);
|
||||
weightGroup('backupAndSnapshot', 489, true);
|
||||
|
||||
basicType(
|
||||
[
|
||||
@@ -679,7 +737,7 @@ export function init($plugin, store) {
|
||||
name: HCI.CLUSTER_NETWORK,
|
||||
ifHaveType: HCI.CLUSTER_NETWORK,
|
||||
namespaced: false,
|
||||
weight: 189,
|
||||
weight: 484,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.CLUSTER_NETWORK }
|
||||
@@ -701,7 +759,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.network.label',
|
||||
name: HCI.NETWORK_ATTACHMENT,
|
||||
namespaced: true,
|
||||
weight: 188,
|
||||
weight: 485,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.NETWORK_ATTACHMENT }
|
||||
@@ -715,7 +773,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.vpc.label',
|
||||
name: HCI.VPC,
|
||||
namespaced: true,
|
||||
weight: 187,
|
||||
weight: 195,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VPC }
|
||||
@@ -724,13 +782,73 @@ export function init($plugin, store) {
|
||||
ifHaveType: HCI.VPC,
|
||||
});
|
||||
|
||||
configureType(HCI.VPC_NAT_GATEWAY, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.natGateway.label',
|
||||
name: HCI.VPC_NAT_GATEWAY,
|
||||
namespaced: false,
|
||||
weight: 193,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VPC_NAT_GATEWAY }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.VPC_NAT_GATEWAY,
|
||||
});
|
||||
|
||||
configureType(HCI.IPTABLES_EIP, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.externalIP.label',
|
||||
name: HCI.IPTABLES_EIP,
|
||||
namespaced: false,
|
||||
weight: 192,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.IPTABLES_EIP }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.IPTABLES_EIP,
|
||||
});
|
||||
|
||||
configureType(HCI.IPTABLES_SNAT_RULE, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.snat.label',
|
||||
name: HCI.IPTABLES_SNAT_RULE,
|
||||
namespaced: false,
|
||||
weight: 191,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.IPTABLES_SNAT_RULE }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.IPTABLES_SNAT_RULE,
|
||||
});
|
||||
|
||||
configureType(HCI.IPTABLES_DNAT_RULE, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.dnat.label',
|
||||
name: HCI.IPTABLES_DNAT_RULE,
|
||||
namespaced: false,
|
||||
weight: 190,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.IPTABLES_DNAT_RULE }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.IPTABLES_DNAT_RULE,
|
||||
});
|
||||
|
||||
configureType(NETWORK_POLICY, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.networkPolicy.label',
|
||||
name: NETWORK_POLICY,
|
||||
namespaced: true,
|
||||
weight: 186,
|
||||
weight: 194,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: NETWORK_POLICY }
|
||||
@@ -739,6 +857,53 @@ export function init($plugin, store) {
|
||||
ifHaveType: NETWORK_POLICY,
|
||||
});
|
||||
|
||||
configureType(HCI.PROVIDER_NETWORK, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.providerNetwork.label',
|
||||
name: HCI.PROVIDER_NETWORK,
|
||||
namespaced: false,
|
||||
weight: 189,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.PROVIDER_NETWORK }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.PROVIDER_NETWORK,
|
||||
});
|
||||
|
||||
configureType(HCI.VLAN, { hiddenNamespaceGroupButton: true, canYaml: false });
|
||||
|
||||
headers(HCI.VLAN, [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
{
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
value: 'spec.id',
|
||||
sort: 'spec.id'
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
labelKey: 'harvester.subnet.provider.label',
|
||||
value: 'spec.provider',
|
||||
sort: 'spec.provider'
|
||||
}
|
||||
]);
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.vlanNetwork.label',
|
||||
name: HCI.VLAN,
|
||||
namespaced: false,
|
||||
weight: 188,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VLAN }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.VLAN,
|
||||
});
|
||||
|
||||
configureType(HCI.SNAPSHOT, {
|
||||
isCreatable: false,
|
||||
location: {
|
||||
@@ -1093,7 +1258,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.loadBalancer.label',
|
||||
name: HCI.LB,
|
||||
namespaced: true,
|
||||
weight: 185,
|
||||
weight: 483,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.LB }
|
||||
@@ -1132,7 +1297,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.ipPool.label',
|
||||
name: HCI.IP_POOL,
|
||||
namespaced: false,
|
||||
weight: 184,
|
||||
weight: 482,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.IP_POOL }
|
||||
@@ -1141,4 +1306,207 @@ export function init($plugin, store) {
|
||||
ifHaveType: HCI.IP_POOL,
|
||||
});
|
||||
headers(HCI.IP_POOL, IP_POOL_HEADERS);
|
||||
|
||||
configureType(HCI.HOST_NETWORK_CONFIG, {
|
||||
location: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.HOST_NETWORK_CONFIG }
|
||||
},
|
||||
canYaml: false,
|
||||
});
|
||||
virtualType({
|
||||
labelKey: 'harvester.hostNetworkConfig.label',
|
||||
name: HCI.HOST_NETWORK_CONFIG,
|
||||
namespaced: false,
|
||||
weight: 481,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.HOST_NETWORK_CONFIG }
|
||||
},
|
||||
exact: false,
|
||||
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
||||
});
|
||||
// ===========================================================================
|
||||
// Forklift Addon UI Flow
|
||||
// ===========================================================================
|
||||
weightGroup('vmMigration', 0, false);
|
||||
|
||||
// Provider
|
||||
headers(HCI.FORKLIFT_PROVIDER, [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
NAMESPACE_COL,
|
||||
FORKLIFT_PROVIDER_TYPE,
|
||||
FORKLIFT_PROVIDER_URL,
|
||||
AGE
|
||||
]);
|
||||
configureType(HCI.FORKLIFT_PROVIDER, {
|
||||
resource: HCI.FORKLIFT_PROVIDER,
|
||||
location: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||
}
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_PROVIDER,
|
||||
labelKey: 'harvester.addons.vmMigration.labels.provider',
|
||||
group: 'vmMigration',
|
||||
namespaced: true,
|
||||
weight: 100,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||
}
|
||||
});
|
||||
|
||||
// NetworkMap
|
||||
headers(HCI.FORKLIFT_NETWORK_MAP, [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
NAMESPACE_COL,
|
||||
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||
FORKLIFT_MAP_DEST_PROVIDER,
|
||||
AGE
|
||||
]);
|
||||
configureType(HCI.FORKLIFT_NETWORK_MAP, {
|
||||
resource: HCI.FORKLIFT_NETWORK_MAP,
|
||||
location: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||
}
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_NETWORK_MAP,
|
||||
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
|
||||
group: 'vmMigration::Configurations',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||
}
|
||||
});
|
||||
|
||||
// StorageMap
|
||||
headers(HCI.FORKLIFT_STORAGE_MAP, [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
NAMESPACE_COL,
|
||||
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||
FORKLIFT_MAP_DEST_PROVIDER,
|
||||
AGE
|
||||
]);
|
||||
configureType(HCI.FORKLIFT_STORAGE_MAP, {
|
||||
resource: HCI.FORKLIFT_STORAGE_MAP,
|
||||
location: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||
}
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_STORAGE_MAP,
|
||||
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
|
||||
group: 'vmMigration::Configurations',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||
}
|
||||
});
|
||||
|
||||
// Plan
|
||||
headers(HCI.FORKLIFT_PLAN, [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
NAMESPACE_COL,
|
||||
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||
FORKLIFT_PLAN_TARGET_NS,
|
||||
FORKLIFT_PLAN_VM_COUNT,
|
||||
AGE
|
||||
]);
|
||||
configureType(HCI.FORKLIFT_PLAN, {
|
||||
resource: HCI.FORKLIFT_PLAN,
|
||||
location: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_PLAN }
|
||||
}
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_PLAN,
|
||||
labelKey: 'harvester.addons.vmMigration.labels.plan',
|
||||
group: 'vmMigration::Configurations',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_PLAN }
|
||||
}
|
||||
});
|
||||
|
||||
// Migration
|
||||
headers(HCI.FORKLIFT_MIGRATION, [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
NAMESPACE_COL,
|
||||
FORKLIFT_MIGRATION_PLAN,
|
||||
AGE
|
||||
]);
|
||||
configureType(HCI.FORKLIFT_MIGRATION, {
|
||||
resource: HCI.FORKLIFT_MIGRATION,
|
||||
location: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||
}
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_MIGRATION,
|
||||
labelKey: 'harvester.addons.vmMigration.labels.migration',
|
||||
group: 'vmMigration::Configurations',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||
}
|
||||
});
|
||||
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
|
||||
virtualType({
|
||||
name: 'forklift-create',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
|
||||
group: 'vmMigration',
|
||||
namespaced: true,
|
||||
weight: 200,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||
params: {}
|
||||
}
|
||||
});
|
||||
|
||||
// Enable SideNav based on Forklift Addon Status
|
||||
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
|
||||
// is registered with requireSchema:false to still be gated by the addon status.
|
||||
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||
resourceType: HCI.ADD_ONS,
|
||||
navGroup: 'vmMigration',
|
||||
requireSchema: false,
|
||||
types: ['forklift-create']
|
||||
});
|
||||
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||
resourceType: HCI.ADD_ONS,
|
||||
navGroup: 'vmMigration',
|
||||
types: [
|
||||
HCI.FORKLIFT_PROVIDER,
|
||||
]
|
||||
});
|
||||
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||
resourceType: HCI.ADD_ONS,
|
||||
navGroup: 'vmMigration::Configurations',
|
||||
types: [
|
||||
HCI.FORKLIFT_NETWORK_MAP,
|
||||
HCI.FORKLIFT_STORAGE_MAP,
|
||||
HCI.FORKLIFT_PLAN,
|
||||
HCI.FORKLIFT_MIGRATION,
|
||||
]
|
||||
});
|
||||
// ===========================================================================
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export const ADD_ONS = {
|
||||
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
||||
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
||||
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
||||
FORKLIFT_OPERATOR: 'forklift-operator',
|
||||
};
|
||||
|
||||
export const CSI_SECRETS = {
|
||||
@@ -92,6 +93,9 @@ export const CSI_SECRETS = {
|
||||
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
||||
};
|
||||
|
||||
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||
export const FORKLIFT_NAMESPACE = 'forklift';
|
||||
|
||||
// Some harvester CRD type is not equal to model file name, define the mapping here
|
||||
export const HARVESTER_CRD_MAP = {
|
||||
node: HCI.HOST,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const HCI = {
|
||||
CREATOR: 'harvesterhci.io/creator',
|
||||
OS: 'harvesterhci.io/os',
|
||||
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
|
||||
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
|
||||
NETWORK_TYPE: 'network.harvesterhci.io/type',
|
||||
VM_NAME: 'harvesterhci.io/vmName',
|
||||
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
|
||||
@@ -80,6 +81,10 @@ export const HCI = {
|
||||
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
|
||||
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
|
||||
MAC_ADDRESS: 'harvesterhci.io/mac-address',
|
||||
STATIC_IP: 'static-ip.harvesterhci.io',
|
||||
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
|
||||
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
|
||||
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
|
||||
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
|
||||
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
|
||||
};
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
export const HCI_SETTING = {
|
||||
BACKUP_TARGET: 'backup-target',
|
||||
CONTAINERD_REGISTRY: 'containerd-registry',
|
||||
LOG_LEVEL: 'log-level',
|
||||
SERVER_VERSION: 'server-version',
|
||||
UI_INDEX: 'ui-index',
|
||||
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
|
||||
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
|
||||
VLAN: 'vlan',
|
||||
UI_SOURCE: 'ui-source',
|
||||
UI_PL: 'ui-pl',
|
||||
HTTP_PROXY: 'http-proxy',
|
||||
ADDITIONAL_CA: 'additional-ca',
|
||||
OVERCOMMIT_CONFIG: 'overcommit-config',
|
||||
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
|
||||
DEFAULT_STORAGE_CLASS: 'default-storage-class',
|
||||
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
|
||||
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
|
||||
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
|
||||
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
|
||||
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
|
||||
STORAGE_NETWORK: 'storage-network',
|
||||
RWX_NETWORK: 'rwx-network',
|
||||
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
|
||||
SSL_CERTIFICATES: 'ssl-certificates',
|
||||
SSL_PARAMETERS: 'ssl-parameters',
|
||||
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
|
||||
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
|
||||
RELEASE_DOWNLOAD_URL: 'release-download-url',
|
||||
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
|
||||
CSI_DRIVER_CONFIG: 'csi-driver-config',
|
||||
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
|
||||
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
|
||||
NTP_SERVERS: 'ntp-servers',
|
||||
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
|
||||
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
|
||||
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
|
||||
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
|
||||
UPGRADE_CONFIG: 'upgrade-config',
|
||||
VM_MIGRATION_NETWORK: 'vm-migration-network',
|
||||
RANCHER_CLUSTER: 'rancher-cluster',
|
||||
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
|
||||
KUBEVIRT_MIGRATION: 'kubevirt-migration',
|
||||
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
|
||||
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
|
||||
BACKUP_TARGET: 'backup-target',
|
||||
CONTAINERD_REGISTRY: 'containerd-registry',
|
||||
LOG_LEVEL: 'log-level',
|
||||
SERVER_VERSION: 'server-version',
|
||||
UI_INDEX: 'ui-index',
|
||||
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
|
||||
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
|
||||
VLAN: 'vlan',
|
||||
UI_SOURCE: 'ui-source',
|
||||
UI_PL: 'ui-pl',
|
||||
HTTP_PROXY: 'http-proxy',
|
||||
ADDITIONAL_CA: 'additional-ca',
|
||||
OVERCOMMIT_CONFIG: 'overcommit-config',
|
||||
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
|
||||
DEFAULT_STORAGE_CLASS: 'default-storage-class',
|
||||
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
|
||||
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
|
||||
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
|
||||
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
|
||||
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
|
||||
STORAGE_NETWORK: 'storage-network',
|
||||
RWX_NETWORK: 'rwx-network',
|
||||
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
|
||||
SSL_CERTIFICATES: 'ssl-certificates',
|
||||
SSL_PARAMETERS: 'ssl-parameters',
|
||||
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
|
||||
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
|
||||
RELEASE_DOWNLOAD_URL: 'release-download-url',
|
||||
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
|
||||
CSI_DRIVER_CONFIG: 'csi-driver-config',
|
||||
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
|
||||
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
|
||||
NTP_SERVERS: 'ntp-servers',
|
||||
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
|
||||
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
|
||||
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
|
||||
LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED: 'longhorn-v2-data-engine-hugepage-enabled',
|
||||
LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE: 'longhorn-v2-data-engine-memory-size',
|
||||
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
|
||||
UPGRADE_CONFIG: 'upgrade-config',
|
||||
VM_MIGRATION_NETWORK: 'vm-migration-network',
|
||||
RANCHER_CLUSTER: 'rancher-cluster',
|
||||
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
|
||||
KUBEVIRT_MIGRATION: 'kubevirt-migration',
|
||||
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
|
||||
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
|
||||
};
|
||||
|
||||
export const HCI_ALLOWED_SETTINGS = {
|
||||
@@ -112,6 +114,14 @@ export const HCI_ALLOWED_SETTINGS = {
|
||||
experimental: true,
|
||||
featureFlag: 'longhornV2LVMSupport'
|
||||
},
|
||||
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: {
|
||||
kind: 'boolean',
|
||||
featureFlag: 'longhornV2HugepageSettings'
|
||||
},
|
||||
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: {
|
||||
kind: 'number',
|
||||
featureFlag: 'longhornV2HugepageSettings'
|
||||
},
|
||||
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
|
||||
[HCI_SETTING.UPGRADE_CONFIG]: {
|
||||
kind: 'json',
|
||||
|
||||
@@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
|
||||
sort: 'status.status',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// Forklift table headers
|
||||
// ========================================
|
||||
|
||||
// Provider type column in forklift.konveyor.io.provider list page
|
||||
export const FORKLIFT_PROVIDER_TYPE = {
|
||||
name: 'providerType',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
|
||||
value: 'spec.type',
|
||||
sort: 'spec.type',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
// Provider URL column in forklift.konveyor.io.provider list page
|
||||
export const FORKLIFT_PROVIDER_URL = {
|
||||
name: 'providerUrl',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
|
||||
value: 'spec.url',
|
||||
sort: 'spec.url',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
// Source provider column in forklift network/storage map list page
|
||||
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
|
||||
name: 'sourceProvider',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
|
||||
value: 'spec.provider.source.name',
|
||||
sort: 'spec.provider.source.name',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
// Destination provider column in forklift network/storage map list page
|
||||
export const FORKLIFT_MAP_DEST_PROVIDER = {
|
||||
name: 'destProvider',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
|
||||
value: 'spec.provider.destination.name',
|
||||
sort: 'spec.provider.destination.name',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
// Target namespace column in forklift.konveyor.io.plan list page
|
||||
export const FORKLIFT_PLAN_TARGET_NS = {
|
||||
name: 'targetNamespace',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
|
||||
value: 'spec.targetNamespace',
|
||||
sort: 'spec.targetNamespace',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
// VM count column in forklift.konveyor.io.plan list page
|
||||
export const FORKLIFT_PLAN_VM_COUNT = {
|
||||
name: 'vmCount',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
|
||||
value: 'spec.vms.length',
|
||||
sort: 'spec.vms.length',
|
||||
align: 'center',
|
||||
};
|
||||
|
||||
// Plan reference column in forklift.konveyor.io.migration list page
|
||||
export const FORKLIFT_MIGRATION_PLAN = {
|
||||
name: 'plan',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
|
||||
value: 'spec.plan.name',
|
||||
sort: 'spec.plan.name',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
@@ -15,6 +15,13 @@ export const VOLUME_MODE = {
|
||||
FILE_SYSTEM: 'Filesystem'
|
||||
};
|
||||
|
||||
export const ACCESS_MODE = {
|
||||
READ_WRITE_MANY: 'ReadWriteMany',
|
||||
READ_WRITE_ONCE: 'ReadWriteOnce',
|
||||
READ_ONLY_MANY: 'ReadOnlyMany',
|
||||
READ_WRITE_ONCE_POD: 'ReadWriteOncePod',
|
||||
};
|
||||
|
||||
export const NETWORK_PROTOCOL = {
|
||||
IPv4: 'IPv4',
|
||||
IPv6: 'IPv6',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MiniMap } from '@vue-flow/minimap';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { HCI } from '../types';
|
||||
import { NETWORK_TYPE } from '../config/types';
|
||||
|
||||
@@ -454,10 +455,10 @@ export default {
|
||||
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
|
||||
|
||||
const clusterNetwork =
|
||||
nad?.metadata?.labels?.[HCI.CLUSTER_NETWORK] || 'mgmt';
|
||||
nad?.metadata?.labels?.[HCI_ANNOTATIONS.CLUSTER_NETWORK] || 'mgmt';
|
||||
|
||||
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 }`;
|
||||
|
||||
nodes.push({
|
||||
|
||||
@@ -98,9 +98,9 @@ export default {
|
||||
methods: {
|
||||
escapeHtml,
|
||||
|
||||
close() {
|
||||
close(data) {
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
this.$emit('close', data);
|
||||
},
|
||||
|
||||
async apply(buttonDone) {
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
await resource.doActionGrowl(this.modalData.action, {});
|
||||
}
|
||||
buttonDone(true);
|
||||
this.close();
|
||||
this.close({ performCallback: true, clearTableSelection: true });
|
||||
} catch (e) {
|
||||
this.errors = exceptionToErrorsArray(e);
|
||||
buttonDone(false);
|
||||
|
||||
@@ -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 { sortBy } from '@shell/utils/sort';
|
||||
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 { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { HCI } from '@pkg/harvester/types';
|
||||
import { Card } from '@components/Card';
|
||||
import { Banner } from '@components/Banner';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
|
||||
export default {
|
||||
name: 'HotplugVolumeModal',
|
||||
@@ -16,7 +19,7 @@ export default {
|
||||
emits: ['close'],
|
||||
|
||||
components: {
|
||||
AsyncButton, Card, LabeledInput, LabeledSelect, Banner
|
||||
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
|
||||
},
|
||||
|
||||
props: {
|
||||
@@ -28,14 +31,19 @@ export default {
|
||||
|
||||
async fetch() {
|
||||
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
|
||||
this.allStorageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
|
||||
this.allVMs = await this.$store.dispatch('harvester/findAll', { type: HCI.VM });
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
diskName: '',
|
||||
volumeName: '',
|
||||
errors: [],
|
||||
allPVCs: [],
|
||||
diskName: '',
|
||||
volumeName: '',
|
||||
shareable: false,
|
||||
errors: [],
|
||||
allPVCs: [],
|
||||
allStorageClasses: [],
|
||||
allVMs: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -50,6 +58,31 @@ export default {
|
||||
return this.resources[0];
|
||||
},
|
||||
|
||||
// claim names already attached to the VM this modal was opened for
|
||||
currentVMClaimNames() {
|
||||
const volumes = this.actionResource?.spec?.template?.spec?.volumes || [];
|
||||
|
||||
return volumes.map((vol) => vol.persistentVolumeClaim?.claimName).filter((name) => !!name);
|
||||
},
|
||||
|
||||
// claim names attached to other VMs in the same namespace
|
||||
otherVMClaimNames() {
|
||||
const out = [];
|
||||
|
||||
this.allVMs.forEach((vm) => {
|
||||
if (vm.metadata.namespace !== this.actionResource?.metadata?.namespace || vm.id === this.actionResource?.id) {
|
||||
return;
|
||||
}
|
||||
(vm.spec?.template?.spec?.volumes || []).forEach((vol) => {
|
||||
if (vol.persistentVolumeClaim?.claimName) {
|
||||
out.push(vol.persistentVolumeClaim.claimName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
volumeOption() {
|
||||
return sortBy(
|
||||
this.PVCs
|
||||
@@ -61,6 +94,14 @@ export default {
|
||||
if (pvc.isGoldenImageVolume) {
|
||||
return false;
|
||||
}
|
||||
// a volume attached to this VM can never be attached to it again
|
||||
if (this.currentVMClaimNames.includes(pvc.metadata.name)) {
|
||||
return false;
|
||||
}
|
||||
// a volume attached to another VM can only be re-attached as a shareable disk
|
||||
if (this.otherVMClaimNames.includes(pvc.metadata.name) && !this.isShareableCapablePVC(pvc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
@@ -73,19 +114,62 @@ export default {
|
||||
'label'
|
||||
);
|
||||
},
|
||||
|
||||
selectedPVC() {
|
||||
return this.PVCs.find((P) => P.metadata.name === this.volumeName);
|
||||
},
|
||||
|
||||
isShareableCapable() {
|
||||
return this.selectedPVC ? this.isShareableCapablePVC(this.selectedPVC) : false;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
isShareableCapable(neu) {
|
||||
if (!neu) {
|
||||
this.shareable = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
isShareableCapablePVC(pvc) {
|
||||
const pvcSpec = pvc?.spec;
|
||||
|
||||
if (!pvcSpec) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
|
||||
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
|
||||
const storageClass = this.allStorageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
|
||||
|
||||
// fail closed: without a resolved StorageClass the provisioner
|
||||
// requirement cannot be evaluated
|
||||
if (!storageClass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.diskName = '';
|
||||
this.volumeName = '';
|
||||
this.shareable = false;
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
if (this.actionResource) {
|
||||
try {
|
||||
const 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) {
|
||||
this.$store.dispatch('growl/success', {
|
||||
@@ -141,6 +225,19 @@ export default {
|
||||
class="mt-20"
|
||||
required
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="isShareableCapable"
|
||||
v-model:value="shareable"
|
||||
class="mt-20"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.volume.shareable.label"
|
||||
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isShareableCapable && shareable"
|
||||
color="warning"
|
||||
:label="t('harvester.virtualMachine.volume.shareable.warning')"
|
||||
/>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Card } from '@components/Card';
|
||||
import { Banner } from '@components/Banner';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterBackupModal',
|
||||
@@ -15,6 +17,7 @@ export default {
|
||||
AsyncButton,
|
||||
Card,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
Banner
|
||||
},
|
||||
|
||||
@@ -27,8 +30,9 @@ export default {
|
||||
|
||||
data() {
|
||||
return {
|
||||
backUpName: '',
|
||||
errors: []
|
||||
backUpName: '',
|
||||
errors: [],
|
||||
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
|
||||
};
|
||||
},
|
||||
|
||||
@@ -37,12 +41,22 @@ export default {
|
||||
|
||||
actionResource() {
|
||||
return this.resources[0];
|
||||
},
|
||||
|
||||
fsFreezeDeadlineEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
|
||||
},
|
||||
|
||||
fsFreezeDeadlineOptions() {
|
||||
return getFsFreezeDeadlineOptions(this.t);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.backUpName = '';
|
||||
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
@@ -51,7 +65,7 @@ export default {
|
||||
try {
|
||||
const res = await this.actionResource.doAction(
|
||||
'backup',
|
||||
{ name: this.backUpName },
|
||||
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
|
||||
{},
|
||||
false
|
||||
);
|
||||
@@ -100,9 +114,18 @@ export default {
|
||||
<template #body>
|
||||
<LabeledInput
|
||||
v-model:value="backUpName"
|
||||
class="mt-20"
|
||||
:label="t('generic.name')"
|
||||
required
|
||||
/>
|
||||
<LabeledSelect
|
||||
v-if="fsFreezeDeadlineEnabled"
|
||||
v-model:value="fsFreezeDeadline"
|
||||
class="mt-20"
|
||||
:options="fsFreezeDeadlineOptions"
|
||||
:label="t('harvester.modal.backup.fileSystemFreezeDeadline.label')"
|
||||
required
|
||||
/>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
|
||||
@@ -117,10 +117,10 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
close(data) {
|
||||
this.nodeName = '';
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
this.$emit('close', data);
|
||||
},
|
||||
|
||||
async apply(buttonDone) {
|
||||
@@ -166,7 +166,7 @@ export default {
|
||||
}
|
||||
|
||||
buttonDone(true);
|
||||
this.close();
|
||||
this.close({ performCallback: true, clearTableSelection: true });
|
||||
} catch (err) {
|
||||
const error = err?.data || err;
|
||||
const message = exceptionToErrorsArray(error);
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Card } from '@components/Card';
|
||||
import { Banner } from '@components/Banner';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { HCI } from '../types';
|
||||
import { BACKUP_TYPE } from '../config/types';
|
||||
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterVMSnapshotDialog',
|
||||
@@ -17,6 +19,7 @@ export default {
|
||||
AsyncButton,
|
||||
Card,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
Banner
|
||||
},
|
||||
|
||||
@@ -31,7 +34,8 @@ export default {
|
||||
return {
|
||||
snapshotName: '',
|
||||
snapshotNamespace: '',
|
||||
errors: []
|
||||
errors: [],
|
||||
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
|
||||
};
|
||||
},
|
||||
|
||||
@@ -40,13 +44,23 @@ export default {
|
||||
|
||||
actionResource() {
|
||||
return this.resources[0];
|
||||
},
|
||||
|
||||
fsFreezeDeadlineEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
|
||||
},
|
||||
|
||||
fsFreezeDeadlineOptions() {
|
||||
return getFsFreezeDeadlineOptions(this.t);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
|
||||
this.snapshotNamespace = '';
|
||||
this.snapshotName = '';
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
@@ -60,7 +74,8 @@ export default {
|
||||
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
|
||||
},
|
||||
spec: {
|
||||
source: {
|
||||
fsFreezeDeadline: this.fsFreezeDeadline,
|
||||
source: {
|
||||
apiGroup: 'kubevirt.io',
|
||||
kind: 'VirtualMachine',
|
||||
name: this.actionResource.metadata.name
|
||||
@@ -132,6 +147,14 @@ export default {
|
||||
:label="t('generic.name')"
|
||||
required
|
||||
/>
|
||||
<LabeledSelect
|
||||
v-if="fsFreezeDeadlineEnabled"
|
||||
v-model:value="fsFreezeDeadline"
|
||||
class="mt-20"
|
||||
:options="fsFreezeDeadlineOptions"
|
||||
:label="t('harvester.modal.vmSnapshot.fileSystemFreezeDeadline.label')"
|
||||
required
|
||||
/>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
|
||||
@@ -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>
|
||||
@@ -108,6 +108,7 @@ export default {
|
||||
<YamlEditor
|
||||
ref="yamlUser"
|
||||
v-model:value="config"
|
||||
:mode="mode"
|
||||
class="yaml-editor"
|
||||
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
|
||||
@onChanges="update"
|
||||
|
||||
@@ -468,10 +468,15 @@ export default {
|
||||
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
|
||||
const parentDevice = d.status?.deviceStatus?.parentDevice;
|
||||
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
|
||||
const mountPoint = d.status?.deviceStatus?.fileSystem?.mountPoint;
|
||||
const name = d.displayName;
|
||||
|
||||
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
|
||||
|
||||
if (mountPoint) {
|
||||
label = `${ label } - ${ this.t('harvester.host.disk.mounted') }`;
|
||||
}
|
||||
|
||||
if (parentDevice) {
|
||||
label = `- ${ label }`;
|
||||
}
|
||||
@@ -481,7 +486,7 @@ export default {
|
||||
value: d.id,
|
||||
action: this.addDisk,
|
||||
kind: !parentDevice ? 'group' : '',
|
||||
disabled: !!isChildAdded,
|
||||
disabled: !!isChildAdded || !!mountPoint,
|
||||
group: parentDevice || devPath,
|
||||
isParent: !!parentDevice,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { NAMESPACE, NODE } from '@shell/config/types';
|
||||
import { HCI } from '../types';
|
||||
import { NETWORK_TYPE, L2VLAN_MODE } from '../config/types';
|
||||
import { removeObject } from '@shell/utils/array';
|
||||
@@ -20,6 +21,7 @@ const { ACCESS, TRUNK } = L2VLAN_MODE;
|
||||
|
||||
const AUTO = 'auto';
|
||||
const MANUAL = 'manual';
|
||||
const KUBE_SYSTEM = 'kube-system';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
@@ -70,7 +72,12 @@ export default {
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({ clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }) });
|
||||
await allHash({
|
||||
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
|
||||
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
|
||||
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
|
||||
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
|
||||
});
|
||||
},
|
||||
|
||||
created() {
|
||||
@@ -199,6 +206,80 @@ export default {
|
||||
}
|
||||
|
||||
return this.type === UNTAGGED;
|
||||
},
|
||||
|
||||
showNicsTab() {
|
||||
return this.isOverlayNetwork && this.value.metadata.namespace === KUBE_SYSTEM;
|
||||
},
|
||||
|
||||
namespaceOptions() {
|
||||
const ns = this.$store.getters['harvester/all'](NAMESPACE) || [];
|
||||
|
||||
// Allow users to select the "kube-system" namespace as the external subnet from Kube-OVN.
|
||||
// This expects the provider network to be in the "kube-system" namespace for VPC NAT gateway functionality.
|
||||
return ns
|
||||
.filter((ns) => !ns.isSystem || ns.id === KUBE_SYSTEM)
|
||||
.map((ns) => ({ name: ns.id }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
|
||||
nodes() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
|
||||
|
||||
return nodes.filter((n) => n.isEtcd !== 'true');
|
||||
},
|
||||
|
||||
nics() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
|
||||
const linkStatus = linkMonitor?.status?.linkStatus || {};
|
||||
const nodes = this.nodes.map((n) => n.id);
|
||||
|
||||
const out = [];
|
||||
|
||||
// Collect all nics from all nodes (for overlay, we select from all nodes)
|
||||
Object.keys(linkStatus).map((nodeName) => {
|
||||
if (nodes.includes(nodeName)) {
|
||||
const nics = linkStatus[nodeName] || [];
|
||||
|
||||
nics.map((nic) => {
|
||||
out.push({
|
||||
...nic,
|
||||
nodeName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
nicOptions() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
|
||||
(this.nics || []).forEach((nic) => {
|
||||
if (!seen.has(nic.name)) {
|
||||
seen.add(nic.name);
|
||||
out.push({
|
||||
label: nic.name,
|
||||
value: nic.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return out.sort((a, b) => a.label.localeCompare(b.label));
|
||||
},
|
||||
|
||||
master: {
|
||||
get() {
|
||||
return this.config?.master || '';
|
||||
},
|
||||
|
||||
set(value) {
|
||||
this.config.master = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -214,6 +295,7 @@ export default {
|
||||
this.config.ipam = {};
|
||||
this.config.bridge = '';
|
||||
delete this.config.provider;
|
||||
delete this.config.master;
|
||||
delete this.config.server_socket;
|
||||
}
|
||||
},
|
||||
@@ -230,6 +312,13 @@ export default {
|
||||
this.config.vlan = '';
|
||||
}
|
||||
},
|
||||
'value.metadata.namespace'(newNamespace) {
|
||||
// NIC selection is only valid for overlay in kube-system namespace.
|
||||
if (newNamespace !== KUBE_SYSTEM) {
|
||||
delete this.config.master;
|
||||
this.value.spec.config = JSON.stringify({ ...this.config });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -324,6 +413,10 @@ export default {
|
||||
delete this.config.promiscMode;
|
||||
delete this.config.vlan;
|
||||
delete this.config.ipam;
|
||||
|
||||
if (this.value.metadata.namespace !== KUBE_SYSTEM) {
|
||||
delete this.config.master;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isUntaggedNetwork) {
|
||||
@@ -350,6 +443,7 @@ export default {
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespace-options="namespaceOptions"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
<Tabbed
|
||||
@@ -521,6 +615,25 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="showNicsTab"
|
||||
name="nics"
|
||||
:label="t('harvester.network.tabs.nic')"
|
||||
:weight="97"
|
||||
class="bordered-table"
|
||||
>
|
||||
<div class="row mt-10">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="master"
|
||||
:label="t('harvester.vlanConfig.uplink.nics.label')"
|
||||
:placeholder="t('harvester.vlanConfig.uplink.nics.overlayWarning')"
|
||||
:mode="mode"
|
||||
:options="nicOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabbed>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { VOLUME_MODE } 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 ArrayList from '@shell/components/form/ArrayList';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
@@ -10,6 +10,10 @@ import { HCI_SETTING } from '../../config/settings';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { _EDIT, _CREATE } from '@shell/config/query-params';
|
||||
|
||||
const {
|
||||
READ_WRITE_ONCE, READ_ONLY_MANY, READ_WRITE_MANY, READ_WRITE_ONCE_POD
|
||||
} = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
name: 'CDISettings',
|
||||
|
||||
@@ -90,10 +94,10 @@ export default {
|
||||
|
||||
accessModeOptions() {
|
||||
return [
|
||||
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
|
||||
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
|
||||
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
|
||||
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
|
||||
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
|
||||
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY },
|
||||
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY },
|
||||
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
|
||||
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
|
||||
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
|
||||
import { GIBIBYTE } from '../utils/unit';
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
|
||||
import { isInternalStorageClass } from '../utils/storage-class';
|
||||
|
||||
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
name: 'HarvesterVolume',
|
||||
|
||||
@@ -83,7 +85,7 @@ export default {
|
||||
if (this.mode === _CREATE) {
|
||||
// default volumeMode to 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;
|
||||
@@ -160,7 +162,7 @@ export default {
|
||||
},
|
||||
|
||||
accessModeOptions() {
|
||||
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
|
||||
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
|
||||
},
|
||||
|
||||
imageOption() {
|
||||
@@ -355,7 +357,7 @@ export default {
|
||||
|
||||
getAccessMode() {
|
||||
if (!this.longhornV2LVMSupport) {
|
||||
return ['ReadWriteMany'];
|
||||
return [READ_WRITE_MANY];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
|
||||
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
|
||||
},
|
||||
buildDataVolumeObj() {
|
||||
const storage = {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
eip: this.value?.spec?.eip || '',
|
||||
externalPort: this.value?.spec?.externalPort || '',
|
||||
internalIp: this.value?.spec?.internalIp || '',
|
||||
internalPort: this.value?.spec?.internalPort || '',
|
||||
protocol: this.value?.spec?.protocol || '',
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
eipOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
|
||||
|
||||
return eips.map((eip) => ({
|
||||
label: eip.id,
|
||||
value: eip.id,
|
||||
}));
|
||||
},
|
||||
|
||||
protocolOptions() {
|
||||
return [
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateBeforeSave() {
|
||||
if (!this.value.spec) {
|
||||
this.value.spec = {};
|
||||
}
|
||||
|
||||
this.value.spec.eip = this.eip;
|
||||
this.value.spec.externalPort = this.externalPort;
|
||||
this.value.spec.internalIp = this.internalIp;
|
||||
this.value.spec.internalPort = this.internalPort;
|
||||
this.value.spec.protocol = this.protocol;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('generic.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="eip"
|
||||
class="mb-20"
|
||||
:options="eipOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.dnat.eip.label')"
|
||||
:placeholder="t('harvester.dnat.eip.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="externalPort"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
:label="t('harvester.dnat.externalPort.label')"
|
||||
:placeholder="t('harvester.dnat.externalPort.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="internalIp"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
:label="t('harvester.dnat.internalIp.label')"
|
||||
:placeholder="t('harvester.dnat.internalIp.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="internalPort"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
:label="t('harvester.dnat.internalPort.label')"
|
||||
:placeholder="t('harvester.dnat.internalPort.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="protocol"
|
||||
class="mb-20"
|
||||
:options="protocolOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.dnat.protocol.label')"
|
||||
:placeholder="t('harvester.dnat.protocol.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
natGwDp: this.value?.spec?.natGwDp || '',
|
||||
externalSubnet: this.value?.spec?.externalSubnet || '',
|
||||
v4ip: this.value?.spec?.v4ip || '',
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({
|
||||
natGateways: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC_NAT_GATEWAY }),
|
||||
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
|
||||
});
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
natGatewayOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const natGateways = this.$store.getters[`${ inStore }/all`](HCI.VPC_NAT_GATEWAY) || [];
|
||||
|
||||
return natGateways.map((gw) => ({
|
||||
label: gw.id,
|
||||
value: gw.id,
|
||||
}));
|
||||
},
|
||||
|
||||
subnetOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const subnets = this.$store.getters[`${ inStore }/all`](HCI.SUBNET) || [];
|
||||
|
||||
return subnets.map((subnet) => ({
|
||||
label: subnet.id,
|
||||
value: subnet.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateBeforeSave() {
|
||||
if (!this.value.spec) {
|
||||
this.value.spec = {};
|
||||
}
|
||||
|
||||
this.value.spec.natGwDp = this.natGwDp;
|
||||
this.value.spec.externalSubnet = this.externalSubnet;
|
||||
this.value.spec.v4ip = this.v4ip;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('generic.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="natGwDp"
|
||||
class="mb-20"
|
||||
:options="natGatewayOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.externalIP.natGateway.label')"
|
||||
:placeholder="t('harvester.externalIP.natGateway.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="externalSubnet"
|
||||
class="mb-20"
|
||||
:options="subnetOptions"
|
||||
:label="t('harvester.externalIP.externalSubnet.label')"
|
||||
:placeholder="t('harvester.externalIP.externalSubnet.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="v4ip"
|
||||
class="mb-20"
|
||||
:label="t('harvester.externalIP.v4ip.label')"
|
||||
:placeholder="t('harvester.externalIP.v4ip.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
eip: this.value?.spec?.eip || '',
|
||||
internalCIDR: this.value?.spec?.internalCIDR || '',
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
eipOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
|
||||
|
||||
return eips.map((eip) => ({
|
||||
label: eip.id,
|
||||
value: eip.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateBeforeSave() {
|
||||
if (!this.value.spec) {
|
||||
this.value.spec = {};
|
||||
}
|
||||
|
||||
this.value.spec.eip = this.eip;
|
||||
this.value.spec.internalCIDR = this.internalCIDR;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('generic.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="eip"
|
||||
class="mb-20"
|
||||
:options="eipOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.snat.eip.label')"
|
||||
:placeholder="t('harvester.snat.eip.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="internalCIDR"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
:label="t('harvester.snat.internalCIDR.label')"
|
||||
:placeholder="t('harvester.snat.internalCIDR.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,384 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import InfoBox from '@shell/components/InfoBox';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { NODE } from '@shell/config/types';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
ArrayList,
|
||||
InfoBox,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
LabeledSelect,
|
||||
ArrayListSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
defaultInterface: this.value?.spec?.defaultInterface || '',
|
||||
excludedNodes: this.value?.spec?.excludeNodes || [],
|
||||
customInterfaces: this.value?.spec?.customInterfaces || [],
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({
|
||||
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
|
||||
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
|
||||
});
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
nodes() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
|
||||
|
||||
return nodes.filter((n) => n.isEtcd !== 'true');
|
||||
},
|
||||
|
||||
nics() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
|
||||
const linkStatus = linkMonitor?.status?.linkStatus || {};
|
||||
const nodes = this.nodes.map((n) => n.id);
|
||||
|
||||
const out = [];
|
||||
|
||||
// Collect all nics from all nodes
|
||||
Object.keys(linkStatus).map((nodeName) => {
|
||||
if (nodes.includes(nodeName)) {
|
||||
const nics = linkStatus[nodeName] || [];
|
||||
|
||||
nics.map((nic) => {
|
||||
out.push({
|
||||
...nic,
|
||||
nodeName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
nicOptions() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
|
||||
(this.nics || []).forEach((nic) => {
|
||||
if (!seen.has(nic.name)) {
|
||||
seen.add(nic.name);
|
||||
out.push({
|
||||
label: nic.name,
|
||||
value: nic.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return out.sort((a, b) => a.label.localeCompare(b.label));
|
||||
},
|
||||
|
||||
nodeOptions() {
|
||||
return this.nodes.map((node) => ({
|
||||
label: node.id,
|
||||
value: node.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeCustomInterface(index) {
|
||||
this.customInterfaces.splice(index, 1);
|
||||
},
|
||||
|
||||
updateBeforeSave() {
|
||||
if (!this.value.spec) {
|
||||
this.value.spec = {};
|
||||
}
|
||||
|
||||
this.value.spec.defaultInterface = this.defaultInterface;
|
||||
this.value.spec.excludeNodes = this.excludedNodes;
|
||||
this.value.spec.customInterfaces = (this.customInterfaces || [])
|
||||
.filter((item) => item?.interface || (item?.nodes || []).length)
|
||||
.map((item) => ({
|
||||
interface: item.interface || '',
|
||||
nodes: (item.nodes || []).filter((node) => !!node),
|
||||
}))
|
||||
.filter((item) => item.interface && item.nodes.length > 0);
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="Interfaces"
|
||||
label="Interfaces"
|
||||
:weight="99"
|
||||
>
|
||||
<LabeledSelect
|
||||
v-model:value="defaultInterface"
|
||||
class="mb-20"
|
||||
required
|
||||
:options="nicOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.providerNetwork.defaultInterface.label')"
|
||||
:placeholder="t('harvester.providerNetwork.defaultInterface.placeholder')"
|
||||
/>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<ArrayList
|
||||
v-model:value="customInterfaces"
|
||||
class="mb-20 custom-interface-list"
|
||||
:mode="mode"
|
||||
:title="t('harvester.providerNetwork.customInterfaces.label')"
|
||||
:protip="false"
|
||||
:remove-allowed="false"
|
||||
:initial-empty-row="true"
|
||||
:default-add-value="{ interface: '', nodes: [] }"
|
||||
>
|
||||
<template #add="{ add }">
|
||||
<div class="custom-interface-primary-add">
|
||||
<button
|
||||
type="button"
|
||||
class="btn role-primary"
|
||||
:disabled="mode === 'view'"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('harvester.providerNetwork.customInterfaces.addLabel') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #column-headers>
|
||||
<div class="row custom-interface-header">
|
||||
<div class="col span-6">
|
||||
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
{{ t('harvester.providerNetwork.customInterfaces.nodes.label') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #columns="scope">
|
||||
<InfoBox class="custom-interface-box">
|
||||
<button
|
||||
v-if="mode !== 'view'"
|
||||
type="button"
|
||||
class="role-link btn btn-sm remove"
|
||||
@click="removeCustomInterface(scope.i)"
|
||||
>
|
||||
<i class="icon icon-x" />
|
||||
</button>
|
||||
|
||||
<div class="custom-interface-content">
|
||||
<div class="row custom-interface-row interface-row">
|
||||
<div class="col span-12 interface-col">
|
||||
<h3 class="mb-10">
|
||||
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
|
||||
</h3>
|
||||
<LabeledSelect
|
||||
v-model:value="scope.row.value.interface"
|
||||
class="mb-20"
|
||||
:label="''"
|
||||
:options="nicOptions"
|
||||
:mode="mode"
|
||||
:placeholder="t('harvester.providerNetwork.customInterfaces.interface.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row custom-interface-row nodes-row">
|
||||
<div class="col span-12">
|
||||
<ArrayListSelect
|
||||
v-model:value="scope.row.value.nodes"
|
||||
:options="nodeOptions"
|
||||
:mode="mode"
|
||||
:disabled="mode === 'view'"
|
||||
:enable-default-add-value="false"
|
||||
:array-list-props="{
|
||||
addLabel: t('harvester.providerNetwork.customInterfaces.nodes.addLabel'),
|
||||
initialEmptyRow: true,
|
||||
title: t('harvester.providerNetwork.customInterfaces.nodes.label'),
|
||||
required: false,
|
||||
protip: false,
|
||||
}"
|
||||
:select-props="{
|
||||
placeholder: t('harvester.providerNetwork.customInterfaces.nodes.placeholder'),
|
||||
disabled: mode === 'view',
|
||||
}"
|
||||
>
|
||||
<template #add="{ add }">
|
||||
<div class="custom-interface-add">
|
||||
<button
|
||||
type="button"
|
||||
class="btn role-tertiary add"
|
||||
:disabled="mode === 'view'"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('harvester.providerNetwork.customInterfaces.nodes.addLabel') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ArrayListSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoBox>
|
||||
</template>
|
||||
</ArrayList>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="excludedNodes"
|
||||
:label="t('harvester.providerNetwork.excludedNodes.label')"
|
||||
:weight="98"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<ArrayListSelect
|
||||
v-model:value="excludedNodes"
|
||||
:options="nodeOptions"
|
||||
:disabled="mode === 'view'"
|
||||
:mode="mode"
|
||||
:enable-default-add-value="false"
|
||||
:array-list-props="{
|
||||
addLabel: t('harvester.providerNetwork.excludedNodes.addLabel'),
|
||||
initialEmptyRow: true,
|
||||
required: false,
|
||||
protip: false,
|
||||
}"
|
||||
:select-props="{
|
||||
placeholder: t('harvester.providerNetwork.excludedNodes.placeholder'),
|
||||
disabled: mode === 'view',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 10px 0 20px;
|
||||
}
|
||||
|
||||
.custom-interface-header {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-interface-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.interface-row {
|
||||
width: calc(100% - 90px);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nodes-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.custom-interface-add {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:deep(.nodes-row .array-list-select .box) {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.nodes-row .array-list-select .box .remove) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.custom-interface-primary-add {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.custom-interface-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
:deep(.custom-interface-list .box) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@ import Loading from '@shell/components/Loading';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types';
|
||||
import { set } from '@shell/utils/object';
|
||||
import { set, remove } from '@shell/utils/object';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../../types';
|
||||
@@ -64,8 +64,9 @@ export default {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const hash = {
|
||||
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
|
||||
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
|
||||
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
|
||||
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
|
||||
vlans: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN }),
|
||||
};
|
||||
|
||||
await allHash(hash);
|
||||
@@ -135,6 +136,18 @@ export default {
|
||||
natOutgoingDisabled() {
|
||||
// Disable the NAT Outgoing option when the subnet belongs to the ovn-cluster VPC and its name is join or ovn-default.
|
||||
return this.value?.spec?.vpc === 'ovn-cluster' && ['join', 'ovn-default'].includes(this.value?.metadata?.name);
|
||||
},
|
||||
|
||||
vlanOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
|
||||
|
||||
const options = vlans.map((vlan) => ({
|
||||
label: vlan.id,
|
||||
value: vlan.id,
|
||||
}));
|
||||
|
||||
return [{ label: this.t('generic.none'), value: '' }, ...options];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -149,6 +162,14 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onVlanChange(value) {
|
||||
if (value === '') {
|
||||
remove(this.value.spec, 'vlan');
|
||||
} else {
|
||||
set(this.value, 'spec.vlan', value);
|
||||
}
|
||||
},
|
||||
|
||||
async saveSubnet(buttonCb) {
|
||||
const errors = [];
|
||||
const name = this.value?.metadata?.name;
|
||||
@@ -270,6 +291,17 @@ export default {
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
:value="value.spec.vlan ?? ''"
|
||||
class="mb-20"
|
||||
:options="vlanOptions"
|
||||
:placeholder="t('harvester.subnet.vlan.placeholder')"
|
||||
:label="t('harvester.subnet.vlan.label')"
|
||||
:mode="mode"
|
||||
@update:value="onVlanChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
vlanId: this.value?.spec?.id || '',
|
||||
provider: this.value?.spec?.provider || '',
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({ providerNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.PROVIDER_NETWORK }) });
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
providerNetworks() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const providerNetworks = this.$store.getters[`${ inStore }/all`](HCI.PROVIDER_NETWORK) || [];
|
||||
|
||||
return providerNetworks.map((pn) => ({
|
||||
label: pn.id,
|
||||
value: pn.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateBeforeSave() {
|
||||
if (!this.value.spec) {
|
||||
this.value.spec = {};
|
||||
}
|
||||
|
||||
if (this.vlanId !== '') {
|
||||
this.value.spec.id = Number(this.vlanId);
|
||||
}
|
||||
|
||||
this.value.spec.provider = this.provider;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('generic.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value.number="vlanId"
|
||||
class="mb-20"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="4094"
|
||||
:label="t('harvester.vlan.id.label')"
|
||||
:placeholder="t('harvester.vlan.id.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="provider"
|
||||
class="mb-20"
|
||||
:options="providerNetworks"
|
||||
:mode="mode"
|
||||
:label="t('harvester.vlan.provider.label')"
|
||||
:placeholder="t('harvester.vlan.provider.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/config/labels-annotations';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
ArrayListSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const internalTenantNetwork = this.value?.metadata?.annotations?.[HCI_ANNOTATIONS.CNI_NETWORKS] || '';
|
||||
|
||||
return {
|
||||
internalTenantNetwork,
|
||||
vpc: this.value?.spec?.vpc || '',
|
||||
subnet: this.value?.spec?.subnet || '',
|
||||
lanIp: this.value?.spec?.lanIp || '',
|
||||
externalSubnets: this.value?.spec?.externalSubnets || [],
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({
|
||||
vpcs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
|
||||
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
|
||||
vmNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.NETWORK_ATTACHMENT }),
|
||||
});
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
vpcOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const vpcs = this.$store.getters[`${ inStore }/all`](HCI.VPC) || [];
|
||||
|
||||
return vpcs.map((vpc) => ({
|
||||
label: vpc.id,
|
||||
value: vpc.id,
|
||||
}));
|
||||
},
|
||||
|
||||
subnetOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const subnets = this.$store.getters[`${ inStore }/all`](HCI.SUBNET) || [];
|
||||
|
||||
return subnets.map((subnet) => ({
|
||||
label: subnet.id,
|
||||
value: subnet.id,
|
||||
}));
|
||||
},
|
||||
|
||||
vmNetworkOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const vmNetworks = this.$store.getters[`${ inStore }/all`](HCI.NETWORK_ATTACHMENT) || [];
|
||||
|
||||
return vmNetworks.map((network) => ({
|
||||
label: network.id,
|
||||
value: network.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateBeforeSave() {
|
||||
if (!this.value.spec) {
|
||||
this.value.spec = {};
|
||||
}
|
||||
|
||||
if (!this.value.metadata) {
|
||||
this.value.metadata = {};
|
||||
}
|
||||
|
||||
if (!this.value.metadata.annotations) {
|
||||
this.value.metadata.annotations = {};
|
||||
}
|
||||
|
||||
this.value.spec.vpc = this.vpc;
|
||||
this.value.spec.subnet = this.subnet;
|
||||
this.value.spec.lanIp = this.lanIp;
|
||||
this.value.spec.externalSubnets = (this.externalSubnets || []).filter((subnet) => !!subnet);
|
||||
|
||||
if (this.internalTenantNetwork) {
|
||||
this.value.metadata.annotations[HCI_ANNOTATIONS.CNI_NETWORKS] = this.internalTenantNetwork;
|
||||
} else {
|
||||
delete this.value.metadata.annotations[HCI_ANNOTATIONS.CNI_NETWORKS];
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<NameNsDescription
|
||||
ref="nd"
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('generic.basic')"
|
||||
:weight="99"
|
||||
>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="internalTenantNetwork"
|
||||
class="mb-20"
|
||||
required
|
||||
:options="vmNetworkOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.natGateway.internalTenantNetwork.label')"
|
||||
:placeholder="t('harvester.natGateway.internalTenantNetwork.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="vpc"
|
||||
class="mb-20"
|
||||
:options="vpcOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.natGateway.vpc.label')"
|
||||
:placeholder="t('harvester.natGateway.vpc.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="subnet"
|
||||
class="mb-20"
|
||||
:options="subnetOptions"
|
||||
:mode="mode"
|
||||
:label="t('harvester.natGateway.subnet.label')"
|
||||
:placeholder="t('harvester.natGateway.subnet.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="lanIp"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
:label="t('harvester.natGateway.lanIp.label')"
|
||||
:placeholder="t('harvester.natGateway.lanIp.placeholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="externalSubnets"
|
||||
:label="t('harvester.natGateway.externalSubnets.label')"
|
||||
:weight="98"
|
||||
>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<ArrayListSelect
|
||||
v-model:value="externalSubnets"
|
||||
:mode="mode"
|
||||
:disabled="mode === 'view'"
|
||||
required
|
||||
:options="subnetOptions"
|
||||
:enable-default-add-value="false"
|
||||
:array-list-props="{
|
||||
addLabel: t('harvester.natGateway.externalSubnets.addLabel'),
|
||||
title: t('harvester.natGateway.subnet.label'),
|
||||
initialEmptyRow: true,
|
||||
required: true,
|
||||
protip: false,
|
||||
}"
|
||||
:select-props="{
|
||||
placeholder: t('harvester.natGateway.externalSubnets.placeholder'),
|
||||
disabled: mode === 'view',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
+1
-6
@@ -68,12 +68,6 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
osType(neu) {
|
||||
if (neu === 'windows') {
|
||||
this.id = '';
|
||||
}
|
||||
},
|
||||
|
||||
value(neu) {
|
||||
this.yamlScript = neu;
|
||||
},
|
||||
@@ -143,6 +137,7 @@ export default {
|
||||
<YamlEditor
|
||||
ref="yaml"
|
||||
v-model:value="yamlScript"
|
||||
:mode="mode"
|
||||
class="yaml-editor"
|
||||
:editor-mode="editorMode"
|
||||
/>
|
||||
|
||||
@@ -234,7 +234,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="completedRows.length > 0"
|
||||
v-if="completedRows.length > 0 && mode === 'create'"
|
||||
color="warning"
|
||||
class="mt-10"
|
||||
>
|
||||
|
||||
@@ -133,6 +133,10 @@ export default {
|
||||
}];
|
||||
|
||||
return this.isMasquerade ? masquerade : other;
|
||||
},
|
||||
|
||||
staticIPForVMEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('staticIPForVM');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -150,6 +154,7 @@ export default {
|
||||
isSingle(neu) {
|
||||
if (!neu) {
|
||||
this.value['macAddress'] = '';
|
||||
this.value['staticIp'] = '';
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
@@ -163,6 +168,7 @@ export default {
|
||||
if (neu === MANAGEMENT_NETWORK) {
|
||||
this.value.isPod = true;
|
||||
this.value.macAddress = '';
|
||||
this.value.staticIp = '';
|
||||
} else {
|
||||
this.value.isPod = false;
|
||||
}
|
||||
@@ -289,9 +295,30 @@ export default {
|
||||
</a>
|
||||
</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
|
||||
v-if="showAdvanced"
|
||||
data-testid="input-hen-macAddress"
|
||||
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 { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
|
||||
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 { EMPTY_IMAGE } from '../../../utils/vm';
|
||||
|
||||
const { READ_WRITE_MANY } = ACCESS_MODE;
|
||||
|
||||
export default {
|
||||
emits: ['update:value'],
|
||||
|
||||
@@ -69,7 +71,7 @@ export default {
|
||||
|
||||
customAccessMode: {
|
||||
type: String,
|
||||
default: 'ReadWriteMany'
|
||||
default: READ_WRITE_MANY
|
||||
},
|
||||
|
||||
resourceType: {
|
||||
|
||||
+56
-2
@@ -5,10 +5,12 @@ import LabelValue from '@shell/components/LabelValue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { PVC } from '@shell/config/types';
|
||||
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
|
||||
import { _CREATE } from '@shell/config/query-params';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { HCI } from '../../../../types';
|
||||
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
|
||||
import { GIBIBYTE } from '../../../../utils/unit';
|
||||
@@ -19,7 +21,7 @@ export default {
|
||||
emits: ['update'],
|
||||
|
||||
components: {
|
||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
|
||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox
|
||||
},
|
||||
|
||||
props: {
|
||||
@@ -94,6 +96,30 @@ export default {
|
||||
return this.allPVCs.find( (P) => P.metadata.name === this.value.volumeName );
|
||||
},
|
||||
|
||||
storageClasses() {
|
||||
return this.$store.getters['harvester/all'](STORAGE_CLASS) || [];
|
||||
},
|
||||
|
||||
isShareableCapable() {
|
||||
const pvcSpec = this.pvcResource?.spec;
|
||||
|
||||
if (!pvcSpec) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
|
||||
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
|
||||
const storageClass = this.storageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
|
||||
|
||||
// fail closed: without a resolved StorageClass the provisioner
|
||||
// requirement cannot be evaluated
|
||||
if (!storageClass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
|
||||
},
|
||||
|
||||
volumeOption() {
|
||||
return sortBy(
|
||||
this.allPVCs
|
||||
@@ -145,6 +171,7 @@ export default {
|
||||
this.value.size = pvcResource.spec.resources.requests.storage;
|
||||
this.value.storageClassName = pvcResource.spec.storageClassName;
|
||||
this.value.volumeMode = pvcResource.spec.volumeMode;
|
||||
this.value.shareable = false;
|
||||
this.update();
|
||||
},
|
||||
|
||||
@@ -155,6 +182,13 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
isShareableCapable(neu) {
|
||||
if (!neu && this.value.shareable) {
|
||||
this.value.shareable = false;
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
pvcResource: {
|
||||
handler(pvc) {
|
||||
if (!this.value.volumeName && pvc?.metadata?.name) {
|
||||
@@ -303,6 +337,26 @@ export default {
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShareableCapable"
|
||||
data-testid="input-hee-shareable"
|
||||
class="col span-6"
|
||||
>
|
||||
<Checkbox
|
||||
v-model:value="value.shareable"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.volume.shareable.label"
|
||||
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
|
||||
:mode="mode"
|
||||
@update:value="update"
|
||||
/>
|
||||
<Banner
|
||||
v-if="value.shareable"
|
||||
color="warning"
|
||||
:label="t('harvester.virtualMachine.volume.shareable.warning')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="value.volumeBackups"
|
||||
class="col span-6"
|
||||
|
||||
@@ -14,7 +14,9 @@ import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storagec
|
||||
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
|
||||
import { GIBIBYTE } from '../../../../utils/unit';
|
||||
import { isInternalStorageClass } from '../../../../utils/storage-class';
|
||||
import { VOLUME_MODE } 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 {
|
||||
name: 'HarvesterEditVolume',
|
||||
@@ -205,7 +207,7 @@ export default {
|
||||
|
||||
getAccessMode(storageClassName) {
|
||||
if (!this.longhornV2LVMSupport) {
|
||||
return 'ReadWriteMany';
|
||||
return READ_WRITE_MANY;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return readWriteOnce ? 'ReadWriteOnce' : 'ReadWriteMany';
|
||||
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
|
||||
},
|
||||
|
||||
update() {
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
import { SECRET } from '@shell/config/types';
|
||||
|
||||
const _AUTOUNATTENDXML = 'autounattend.xml';
|
||||
const _NEW = '_NEW';
|
||||
|
||||
export default {
|
||||
name: 'VirtualMachineWindowsSysprep',
|
||||
|
||||
components: {
|
||||
Banner,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
ModalWithCard,
|
||||
YamlEditor,
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
namespace: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
secretName: '',
|
||||
xmlContent: '',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
secretOptions: [],
|
||||
errors: [],
|
||||
secretName: '',
|
||||
xmlContent: '',
|
||||
newSecretName: '',
|
||||
newXmlContent: '',
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
await this.loadSecretsOptions();
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Force the loading of the XML content.
|
||||
this.secretName = this.value?.secretName || '';
|
||||
},
|
||||
|
||||
watch: {
|
||||
secretName() {
|
||||
if (this.secretName === _NEW) {
|
||||
this.isOpen = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the content from the given secret, or set empty value.
|
||||
this.xmlContent = this.secretName === '' ? '' : this.loadXmlContentFromSecret(this.secretName);
|
||||
|
||||
// Update the editor form field.
|
||||
this.updateValue();
|
||||
|
||||
// Update the property.
|
||||
this.update();
|
||||
},
|
||||
|
||||
xmlContent() {
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
editorMode() {
|
||||
return this.mode === 'view' ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === 'view';
|
||||
},
|
||||
|
||||
xmlContentValidationError() {
|
||||
return this.validateXmlContent(this.xmlContent);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.$emit('update:value', {
|
||||
secretName: this.secretName,
|
||||
xmlContent: this.xmlContent,
|
||||
});
|
||||
},
|
||||
|
||||
updateValue() {
|
||||
this.$refs?.xmlEditor?.updateValue?.(this.xmlContent);
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.$refs?.xmlEditor?.refresh?.();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
if (this.secretName === _NEW) {
|
||||
this.secretName = '';
|
||||
}
|
||||
this.newSecretName = '';
|
||||
this.newXmlContent = '';
|
||||
this.errors = [];
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
async loadSecretsOptions() {
|
||||
const secrets = await this.$store.dispatch('harvester/findAll', { type: SECRET });
|
||||
|
||||
const templates = secrets
|
||||
.filter(
|
||||
(s) => s.metadata?.namespace === this.namespace &&
|
||||
s.decodedData?.[_AUTOUNATTENDXML]
|
||||
)
|
||||
.map((s) => ({
|
||||
label: s.metadata.name,
|
||||
value: `${ s.metadata.namespace }/${ s.metadata.name }`,
|
||||
}));
|
||||
|
||||
this.secretOptions = [
|
||||
{
|
||||
label: this.t('generic.none'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: this.t('harvester.virtualMachine.sysprep.createNew'),
|
||||
value: _NEW,
|
||||
},
|
||||
...templates,
|
||||
];
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
this.errors = [];
|
||||
|
||||
if (!this.newSecretName?.trim()) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.newXmlContent?.trim()) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const xmlError = this.validateXmlContent(this.newXmlContent);
|
||||
|
||||
if (xmlError) {
|
||||
this.errors.push(xmlError);
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = await this.$store.dispatch('harvester/create', {
|
||||
type: SECRET,
|
||||
metadata: {
|
||||
name: this.newSecretName,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
});
|
||||
|
||||
secret.setData(_AUTOUNATTENDXML, this.newXmlContent);
|
||||
const res = await secret.save();
|
||||
|
||||
if (res?.metadata?.name) {
|
||||
await this.loadSecretsOptions();
|
||||
this.secretName = `${ this.namespace }/${ res.metadata.name }`;
|
||||
}
|
||||
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
this.errors = [err.message];
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
validateXmlContent(content) {
|
||||
if (!content?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(content, 'text/xml');
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
|
||||
if (parseError) {
|
||||
return this.t('harvester.virtualMachine.sysprep.validation.invalidXml');
|
||||
}
|
||||
|
||||
// See https://learn.microsoft.com/en-us/windows-hardware/drivers/gpiobtn/implement-the-unattended-windows-setup-setting
|
||||
if (doc.documentElement.namespaceURI !== 'urn:schemas-microsoft-com:unattend') {
|
||||
return this.t('harvester.virtualMachine.sysprep.validation.invalidNamespace');
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
},
|
||||
|
||||
loadXmlContentFromSecret(id) {
|
||||
const secret = this.$store.getters['harvester/byId'](SECRET, id);
|
||||
|
||||
return secret?.decodedData?.[_AUTOUNATTENDXML] || '';
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="!isView">
|
||||
{{ t('harvester.virtualMachine.sysprep.title') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!isView"
|
||||
class="text-muted mb-20"
|
||||
>
|
||||
<t
|
||||
k="harvester.virtualMachine.sysprep.description"
|
||||
:raw="true"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<!-- Secret Selection -->
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="secretName"
|
||||
:options="secretOptions"
|
||||
:label="t('harvester.virtualMachine.sysprep.secret.label')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XML Editor -->
|
||||
<div class="mb-20">
|
||||
<Banner
|
||||
v-if="xmlContentValidationError"
|
||||
color="error"
|
||||
class="mb-10"
|
||||
>
|
||||
<i class="icon icon-error" />
|
||||
{{ xmlContentValidationError }}
|
||||
</Banner>
|
||||
|
||||
<div class="resource-xml">
|
||||
<YamlEditor
|
||||
ref="xmlEditor"
|
||||
v-model:value="xmlContent"
|
||||
:editor-mode="editorMode"
|
||||
class="xml-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="isOpen"
|
||||
name="createSysprepSecret"
|
||||
width="40%"
|
||||
:errors="errors"
|
||||
@finish="save"
|
||||
@close="cancel"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.sysprep.createNewTitle') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<LabeledInput
|
||||
v-model:value="newSecretName"
|
||||
:label="t('harvester.virtualMachine.input.name')"
|
||||
class="mb-20"
|
||||
required
|
||||
@keydown.native.enter.prevent="()=>{}"
|
||||
/>
|
||||
|
||||
<div class="xml">
|
||||
<div class="resource-xml">
|
||||
<YamlEditor
|
||||
ref="createTemplate"
|
||||
v-model:value="newXmlContent"
|
||||
:editor-mode="editorMode"
|
||||
class="xml-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$xml-height: 350px;
|
||||
|
||||
:deep() .resource-xml {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& .xml-editor {
|
||||
flex: 1;
|
||||
min-height: $xml-height;
|
||||
font-family: monospace;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
min-height: $xml-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.xml {
|
||||
height: $xml-height;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -21,9 +21,7 @@ import { saferDump } from '@shell/utils/create-yaml';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
|
||||
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
|
||||
import VM_MIXIN from '../../mixins/harvester-vm';
|
||||
import { HCI } from '../../types';
|
||||
@@ -31,6 +29,7 @@ import RestartVMDialog from '../../dialog/RestartVMDialog';
|
||||
import PciDevices from './VirtualMachinePciDevices/index';
|
||||
import AccessCredentials from './VirtualMachineAccessCredentials';
|
||||
import CloudConfig from './VirtualMachineCloudConfig';
|
||||
import WindowsSysprep from './VirtualMachineWindowsSysprep';
|
||||
import CpuMemory from './VirtualMachineCpuMemory';
|
||||
import CpuModel from './VirtualMachineCpuModel';
|
||||
import Network from './VirtualMachineNetwork';
|
||||
@@ -61,6 +60,7 @@ export default {
|
||||
CpuMemory,
|
||||
CpuModel,
|
||||
CloudConfig,
|
||||
WindowsSysprep,
|
||||
NodeScheduling,
|
||||
PodAffinity,
|
||||
AccessCredentials,
|
||||
@@ -93,6 +93,9 @@ export default {
|
||||
|
||||
const hostname = this.value.spec.template.spec.hostname || '';
|
||||
|
||||
// Display name can contain arbitrary strings. There is no XSS risk because the value is
|
||||
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
|
||||
// this field. See harvester/harvester#10423 for details.
|
||||
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
|
||||
|
||||
return {
|
||||
@@ -326,6 +329,10 @@ export default {
|
||||
try {
|
||||
await this.saveSecret(res);
|
||||
await this.saveAccessCredentials(res);
|
||||
|
||||
if (this.isWindows) {
|
||||
await this.saveSysprepConfig(res);
|
||||
}
|
||||
} catch (e) {
|
||||
this.errors.push(...exceptionToErrorsArray(e));
|
||||
}
|
||||
@@ -371,6 +378,7 @@ export default {
|
||||
clear(this.errors);
|
||||
|
||||
this.validateCPUMemory();
|
||||
this.validateWindowsSysprep();
|
||||
|
||||
// block create VM flow if has validation errors
|
||||
if (this.errors.length) {
|
||||
@@ -398,6 +406,24 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
validateWindowsSysprep() {
|
||||
if (!this.isWindows) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sysprep?.secretName?.trim?.() && !this.sysprep?.xmlContent?.trim?.()) {
|
||||
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sysprepValidationError = this.$refs.sysprepConfig?.xmlContentValidationError;
|
||||
|
||||
if (sysprepValidationError) {
|
||||
this.errors.push(sysprepValidationError);
|
||||
}
|
||||
},
|
||||
|
||||
async saveSingle(buttonCb) {
|
||||
this.parseVM();
|
||||
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
|
||||
@@ -461,6 +487,20 @@ export default {
|
||||
await value.save();
|
||||
await this.applyHooks(AFTER_SAVE_HOOKS);
|
||||
} catch (e) {
|
||||
// Reset the generated secret name(s) so a retried create (e.g. after correcting an
|
||||
// invalid/duplicate VM name) always regenerates them from the current name instead of
|
||||
// reusing names derived from this failed attempt. Only do this for create: in edit mode
|
||||
// secretName/sysprep.secretName may reference an existing secret loaded from the VM,
|
||||
// which must not be discarded. See harvester/harvester#11174.
|
||||
if (this.isCreate) {
|
||||
this.secretName = '';
|
||||
this.secretNamePrefixUsed = '';
|
||||
if (this.sysprepSecretNamePrefixUsed !== '') {
|
||||
this.sysprep.secretName = '';
|
||||
this.sysprepSecretNamePrefixUsed = '';
|
||||
}
|
||||
}
|
||||
|
||||
this.errors.push(...exceptionToErrorsArray(e));
|
||||
buttonCb(false);
|
||||
}
|
||||
@@ -548,6 +588,7 @@ export default {
|
||||
onTabChanged({ tab }) {
|
||||
if (tab.name === 'advanced') {
|
||||
this.$refs.yamlEditor?.refresh();
|
||||
this.$refs.sysprepConfig?.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -844,7 +885,7 @@ export default {
|
||||
>
|
||||
<Filesystem
|
||||
v-model:value="filesystemRows"
|
||||
:mode="mode"
|
||||
:mode="isCreate ? mode : 'view'"
|
||||
:namespace="value.metadata.namespace"
|
||||
/>
|
||||
</Tab>
|
||||
@@ -1025,7 +1066,6 @@ export default {
|
||||
:user-script="userScript"
|
||||
:mode="mode"
|
||||
:os-type="osType"
|
||||
:view-code="isWindows"
|
||||
:namespace="value.metadata.namespace"
|
||||
:network-script="networkScript"
|
||||
@updateUserData="updateUserData"
|
||||
@@ -1033,6 +1073,14 @@ export default {
|
||||
@updateDataTemplateId="updateDataTemplateId"
|
||||
/>
|
||||
|
||||
<WindowsSysprep
|
||||
v-if="isWindows"
|
||||
ref="sysprepConfig"
|
||||
v-model:value="sysprep"
|
||||
:mode="mode"
|
||||
:namespace="value.metadata.namespace"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="value.cpuPinningFeatureEnabled"
|
||||
v-model:value="cpuPinning"
|
||||
|
||||
@@ -63,6 +63,7 @@ export default {
|
||||
RANCHER_TYPES,
|
||||
fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }],
|
||||
canEditPSPBindings: true,
|
||||
isQuotasValid: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -190,15 +191,13 @@ export default {
|
||||
this['membershipUpdate'] = update;
|
||||
},
|
||||
|
||||
removeQuota(key) {
|
||||
['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => {
|
||||
if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) {
|
||||
delete this.value?.spec[specProp]?.limit[key];
|
||||
}
|
||||
if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) {
|
||||
delete this.value?.spec[specProp]?.usedLimit[key];
|
||||
}
|
||||
});
|
||||
validateQuotas(isValid) {
|
||||
this.isQuotasValid = isValid;
|
||||
},
|
||||
|
||||
onQuotasInput({ projectLimit, nsLimit } = {}) {
|
||||
this.value.spec.resourceQuota = { ...this.value.spec.resourceQuota, limit: projectLimit };
|
||||
this.value.spec.namespaceDefaultResourceQuota = { ...this.value.spec.namespaceDefaultResourceQuota, limit: nsLimit };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -212,7 +211,7 @@ export default {
|
||||
:resource="value"
|
||||
:subtypes="[]"
|
||||
:can-yaml="false"
|
||||
:validation-passed="fvFormIsValid"
|
||||
:validation-passed="fvFormIsValid && isQuotasValid"
|
||||
@error="e=>errors=e"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
@@ -267,10 +266,11 @@ export default {
|
||||
:weight="9"
|
||||
>
|
||||
<ResourceQuota
|
||||
v-model:value="value"
|
||||
:value="value"
|
||||
:mode="canEditTabElements"
|
||||
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
|
||||
@remove="removeQuota"
|
||||
@input="onQuotasInput"
|
||||
@validationChanged="validateQuotas"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<script>
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import ResourceTabs from '@shell/components/form/ResourceTabs';
|
||||
import Tab from '@shell/components/Tabbed/Tab';
|
||||
import InfoBox from '@shell/components/InfoBox';
|
||||
import MessageLink from '@shell/components/MessageLink';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import HarvesterNodeSelector from '../components/HarvesterNodeSelector';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { set } from '@shell/utils/object';
|
||||
import { NODE } from '@shell/config/types';
|
||||
import { HCI } from '../types';
|
||||
import { ADD_ONS } from '../config/harvester-map';
|
||||
|
||||
const MODE_DHCP = 'dhcp';
|
||||
const MODE_STATIC = 'static';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterHostNetworkConfigEditPage',
|
||||
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
CruResource,
|
||||
NameNsDescription,
|
||||
ResourceTabs,
|
||||
Tab,
|
||||
InfoBox,
|
||||
MessageLink,
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
RadioGroup,
|
||||
Checkbox,
|
||||
HarvesterNodeSelector,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({
|
||||
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
|
||||
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
|
||||
hostNetworkConfigs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HOST_NETWORK_CONFIG }),
|
||||
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
|
||||
});
|
||||
},
|
||||
|
||||
data() {
|
||||
const networkMode = this.value?.spec?.mode || MODE_DHCP;
|
||||
const ips = { ...(this.value?.spec?.ips || {}) };
|
||||
|
||||
if (!this.value.spec) {
|
||||
set(this.value, 'spec', {});
|
||||
}
|
||||
|
||||
return {
|
||||
networkMode,
|
||||
ips,
|
||||
hasNodeSelector: !!this.value?.spec?.nodeSelector,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
modeOptions() {
|
||||
return [
|
||||
{ label: 'DHCP', value: MODE_DHCP },
|
||||
{ label: 'Static', value: MODE_STATIC },
|
||||
];
|
||||
},
|
||||
|
||||
clusterNetworkOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
|
||||
|
||||
return clusterNetworks.map((n) => {
|
||||
const disabled = !n.isReady;
|
||||
|
||||
return {
|
||||
label: disabled ? `${ n.id } (${ this.t('generic.notReady') })` : n.id,
|
||||
value: n.id,
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
nodes() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/all`](NODE) || [];
|
||||
},
|
||||
|
||||
isStaticMode() {
|
||||
return this.networkMode === MODE_STATIC;
|
||||
},
|
||||
|
||||
underlay: {
|
||||
get() {
|
||||
return !!this.value?.spec?.underlay;
|
||||
},
|
||||
set(val) {
|
||||
set(this.value, 'spec.underlay', val);
|
||||
},
|
||||
},
|
||||
|
||||
vlanID: {
|
||||
get() {
|
||||
return this.value?.spec?.vlanID;
|
||||
},
|
||||
set(val) {
|
||||
set(this.value, 'spec.vlanID', val);
|
||||
},
|
||||
},
|
||||
|
||||
clusterNetwork: {
|
||||
get() {
|
||||
return this.value?.spec?.clusterNetwork;
|
||||
},
|
||||
set(val) {
|
||||
set(this.value, 'spec.clusterNetwork', val);
|
||||
},
|
||||
},
|
||||
|
||||
underlayConflict() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const all = this.$store.getters[`${ inStore }/all`](HCI.HOST_NETWORK_CONFIG) || [];
|
||||
const currentId = this.value?.id;
|
||||
|
||||
return all.find((c) => c.id !== currentId && c.spec?.underlay === true) || null;
|
||||
},
|
||||
|
||||
kubeovnEnabled() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS) || [];
|
||||
|
||||
return addons.find((a) => a.name === ADD_ONS.KUBEOVN_OPERATOR)?.spec?.enabled === true;
|
||||
},
|
||||
|
||||
underlayDisabled() {
|
||||
return !this.kubeovnEnabled || !!this.underlayConflict;
|
||||
},
|
||||
|
||||
kubeovnAddonTo() {
|
||||
return {
|
||||
name: 'c-cluster-product-resource-namespace-id',
|
||||
params: {
|
||||
cluster: this.$route.params.cluster,
|
||||
product: this.$store.getters['productId'],
|
||||
resource: HCI.ADD_ONS,
|
||||
namespace: 'kube-system',
|
||||
id: ADD_ONS.KUBEOVN_OPERATOR,
|
||||
},
|
||||
query: { mode: 'edit' },
|
||||
hash: '#basic',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
networkMode(neu) {
|
||||
set(this.value, 'spec.mode', neu);
|
||||
|
||||
if (neu !== MODE_STATIC) {
|
||||
if (this.value?.spec?.ips !== undefined) {
|
||||
delete this.value.spec.ips;
|
||||
}
|
||||
this.ips = {};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.updateBeforeSave);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateBeforeSave() {
|
||||
set(this.value, 'spec.mode', this.networkMode);
|
||||
|
||||
if (this.isStaticMode) {
|
||||
set(this.value, 'spec.ips', { ...this.ips });
|
||||
}
|
||||
},
|
||||
|
||||
updateIp(nodeName, val) {
|
||||
this.ips = { ...this.ips, [nodeName]: val };
|
||||
},
|
||||
|
||||
addNodeSelector() {
|
||||
set(this.value.spec, 'nodeSelector', {
|
||||
matchExpressions: [{
|
||||
key: '', operator: 'In', values: []
|
||||
}]
|
||||
});
|
||||
this.hasNodeSelector = true;
|
||||
},
|
||||
|
||||
removeNodeSelector() {
|
||||
delete this.value.spec.nodeSelector;
|
||||
this.hasNodeSelector = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
:done-route="doneRoute"
|
||||
:mode="mode"
|
||||
:resource="value"
|
||||
:errors="errors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@cancel="done"
|
||||
@error="e => errors = e"
|
||||
>
|
||||
<NameNsDescription
|
||||
:value="value"
|
||||
:mode="mode"
|
||||
:namespaced="false"
|
||||
description-key="spec.description"
|
||||
@update:value="$emit('update:value', $event)"
|
||||
/>
|
||||
|
||||
<ResourceTabs
|
||||
class="mt-15"
|
||||
:need-conditions="false"
|
||||
:need-related="false"
|
||||
:need-events="false"
|
||||
:side-tabs="true"
|
||||
:mode="mode"
|
||||
>
|
||||
<Tab
|
||||
name="basic"
|
||||
:label="t('harvester.hostNetworkConfig.tabs.mode')"
|
||||
:weight="99"
|
||||
class="bordered-table"
|
||||
>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model:value="networkMode"
|
||||
name="hostNetworkConfigMode"
|
||||
:options="modeOptions"
|
||||
:mode="mode"
|
||||
:row="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model:value="clusterNetwork"
|
||||
:label="t('harvester.network.clusterNetwork.label')"
|
||||
:options="clusterNetworkOptions"
|
||||
:mode="mode"
|
||||
required
|
||||
:placeholder="t('harvester.network.clusterNetwork.selectPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value.number="vlanID"
|
||||
type="number"
|
||||
required
|
||||
:min="1"
|
||||
:max="4094"
|
||||
placeholder="e.g. 1 ~ 4094"
|
||||
:label="t('harvester.hostNetworkConfig.vlanID.label')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<Checkbox
|
||||
v-model:value="underlay"
|
||||
:label="t('harvester.hostNetworkConfig.underlay.label')"
|
||||
:tooltip="t('harvester.hostNetworkConfig.underlay.tooltip')"
|
||||
:mode="mode"
|
||||
:disabled="underlayDisabled"
|
||||
/>
|
||||
<span
|
||||
v-if="!kubeovnEnabled"
|
||||
class="underlay-conflict-warning"
|
||||
>
|
||||
<i class="icon icon-warning" />
|
||||
<MessageLink
|
||||
:to="kubeovnAddonTo"
|
||||
prefix-label="harvester.hostNetworkConfig.underlay.noKubeovn.prefix"
|
||||
middle-label="harvester.hostNetworkConfig.underlay.noKubeovn.middle"
|
||||
suffix-label="harvester.hostNetworkConfig.underlay.noKubeovn.suffix"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="underlayConflict"
|
||||
class="underlay-conflict-warning"
|
||||
>
|
||||
<i class="icon icon-warning" />
|
||||
{{ t('harvester.hostNetworkConfig.underlay.conflict', { name: underlayConflict.nameDisplay || underlayConflict.id }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="isStaticMode">
|
||||
<hr class="section-divider" />
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="row mb-10 ips-row"
|
||||
>
|
||||
<div class="col span-3">
|
||||
<LabeledInput
|
||||
:value="node.nameDisplay || node.id"
|
||||
:label="t('harvester.hostNetworkConfig.ips.nodeLabel')"
|
||||
mode="view"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-5">
|
||||
<LabeledInput
|
||||
:value="ips[node.id]"
|
||||
:label="t('harvester.hostNetworkConfig.ips.label')"
|
||||
:placeholder="t('harvester.hostNetworkConfig.ips.placeholder')"
|
||||
:mode="mode"
|
||||
required
|
||||
@update:value="updateIp(node.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tab>
|
||||
<Tab
|
||||
name="nodeSelector"
|
||||
:label="t('harvester.hostNetworkConfig.tabs.nodeSelector')"
|
||||
:weight="98"
|
||||
>
|
||||
<template v-if="hasNodeSelector">
|
||||
<InfoBox class="node-selector-box">
|
||||
<button
|
||||
v-if="!isView"
|
||||
type="button"
|
||||
class="role-link btn btn-sm remove"
|
||||
:aria-label="t('generic.remove')"
|
||||
@click="removeNodeSelector"
|
||||
>
|
||||
<i class="icon icon-x" />
|
||||
</button>
|
||||
<HarvesterNodeSelector
|
||||
class="mt-20"
|
||||
:value="value.spec.nodeSelector"
|
||||
:mode="mode"
|
||||
/>
|
||||
</InfoBox>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="btn role-secondary"
|
||||
:disabled="isView"
|
||||
@click="addNodeSelector"
|
||||
>
|
||||
{{ t('harvester.hostNetworkConfig.nodeSelector.addButton') }}
|
||||
</button>
|
||||
</template>
|
||||
</Tab>
|
||||
</ResourceTabs>
|
||||
</CruResource>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 10px 0 20px;
|
||||
}
|
||||
|
||||
.node-selector-box {
|
||||
position: relative;
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.underlay-conflict-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
color: var(--warning);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -31,6 +31,10 @@ export default {
|
||||
to() {
|
||||
return this.vm?.detailLocation;
|
||||
},
|
||||
|
||||
attachVMName() {
|
||||
return this.vm?.nameDisplay || this.vm?.metadata?.name || this.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -40,10 +44,10 @@ export default {
|
||||
v-if="to"
|
||||
:to="to"
|
||||
>
|
||||
{{ value }}
|
||||
{{ attachVMName }}
|
||||
</router-link>
|
||||
|
||||
<span v-else>
|
||||
{{ value }}
|
||||
{{ attachVMName }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'HarvesterBooleanFormatter',
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="value">
|
||||
<i class="icon icon-checkmark" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,11 +1,11 @@
|
||||
<script>
|
||||
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
|
||||
import PercentageBar from '@shell/components/PercentageBar';
|
||||
import { METRIC, NODE } from '@shell/config/types';
|
||||
import { parseSi } from '@shell/utils/units';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterCpuUsed',
|
||||
components: { ConsumptionGauge },
|
||||
components: { PercentageBar },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
@@ -74,54 +74,89 @@ export default {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
reservedPercentage() {
|
||||
return this.cpuTotal ? (this.reserved * 100) / this.cpuTotal : 0;
|
||||
},
|
||||
|
||||
usedPercentage() {
|
||||
return this.cpuTotal ? (this.used * 100) / this.cpuTotal : 0;
|
||||
},
|
||||
|
||||
reservedAmountTemplateValues() {
|
||||
return {
|
||||
used: this.numberFormatter(this.reserved || 0),
|
||||
total: this.numberFormatter(this.cpuTotal || 0),
|
||||
unit: ` ${ this.cpuUnits }`,
|
||||
};
|
||||
},
|
||||
|
||||
usedAmountTemplateValues() {
|
||||
return {
|
||||
used: this.numberFormatter(this.used || 0),
|
||||
total: this.numberFormatter(this.cpuTotal || 0),
|
||||
unit: ` ${ this.cpuUnits }`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
numberFormatter(value) {
|
||||
return Number.isInteger(value) ? value : value.toFixed(2);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ConsumptionGauge
|
||||
:capacity="cpuTotal"
|
||||
:used="reserved"
|
||||
:units="cpuUnits"
|
||||
:resource-name="resourceName"
|
||||
>
|
||||
<template #title="{amountTemplateValues, formattedPercentage}">
|
||||
<span>
|
||||
<div class="consumption-gauge">
|
||||
<div class="numbers">
|
||||
<span class="mr-5">
|
||||
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
|
||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
||||
</span>
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
|
||||
</span>
|
||||
</template>
|
||||
</ConsumptionGauge>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<PercentageBar
|
||||
:model-value="reservedPercentage"
|
||||
show-percentage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showUsed"
|
||||
class="mt-10"
|
||||
class="consumption-gauge mt-10"
|
||||
>
|
||||
<ConsumptionGauge
|
||||
:capacity="cpuTotal"
|
||||
:used="used"
|
||||
:units="cpuUnits"
|
||||
>
|
||||
<template #title="{amountTemplateValues, formattedPercentage}">
|
||||
<span>
|
||||
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
|
||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ConsumptionGauge>
|
||||
<div class="numbers">
|
||||
<span>
|
||||
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<PercentageBar
|
||||
:model-value="usedPercentage"
|
||||
show-percentage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consumption-gauge {
|
||||
.numbers {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.precent-data {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'HarvesterHostNetworkConfigModeFormatter',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
displayMode() {
|
||||
if (this.value?.toLowerCase() === 'dhcp') {
|
||||
return 'DHCP';
|
||||
}
|
||||
|
||||
if (this.value?.toLowerCase() === 'static') {
|
||||
return 'Static';
|
||||
}
|
||||
|
||||
return this.value;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ displayMode }}</span>
|
||||
</template>
|
||||
@@ -36,6 +36,10 @@ export default {
|
||||
|
||||
computed: {
|
||||
ips() {
|
||||
if (this.staticAnnotationIP.length) {
|
||||
return this.staticAnnotationIP;
|
||||
}
|
||||
|
||||
if (this.vmiIp.length) {
|
||||
return [...this.vmiIp, ...this.networkAnnotationIP]
|
||||
.filter(Boolean)
|
||||
@@ -45,6 +49,24 @@ export default {
|
||||
return this.customAnnotationIP;
|
||||
},
|
||||
|
||||
staticAnnotationIP() {
|
||||
const annotations = this.row?.metadata?.annotations || {};
|
||||
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
|
||||
|
||||
return Object.entries(annotations)
|
||||
.filter(([key, value]) => key.startsWith(staticIpPrefix) && !!value)
|
||||
.map(([key, value]) => {
|
||||
const nicName = key.slice(staticIpPrefix.length);
|
||||
const ip = String(value).replace(/\/[\d\D]*/, '');
|
||||
|
||||
return {
|
||||
ip,
|
||||
name: nicName
|
||||
};
|
||||
})
|
||||
.filter(({ ip }) => isIpv4(ip));
|
||||
},
|
||||
|
||||
customAnnotationIP() {
|
||||
const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`);
|
||||
|
||||
@@ -110,16 +132,28 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showIP">
|
||||
<span
|
||||
<div
|
||||
v-if="showIP"
|
||||
class="ip-list"
|
||||
>
|
||||
<div
|
||||
v-for="{ ip, name, isCustom } in ips"
|
||||
:key="`${ip}-${name}`"
|
||||
class="ip-item"
|
||||
>
|
||||
<CopyToClipboardText
|
||||
v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name"
|
||||
:text="ip"
|
||||
:plain="isCustom"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script>
|
||||
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
|
||||
import PercentageBar from '@shell/components/PercentageBar';
|
||||
import { METRIC, NODE } from '@shell/config/types';
|
||||
import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units';
|
||||
import { UNIT_SUFFIX } from '../utils/unit';
|
||||
export default {
|
||||
name: 'HarvesterMemoryUsed',
|
||||
components: { ConsumptionGauge },
|
||||
components: { PercentageBar },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
@@ -76,6 +76,30 @@ export default {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
reservedPercentage() {
|
||||
return this.memoryTotal ? (this.reserved * 100) / this.memoryTotal : 0;
|
||||
},
|
||||
|
||||
usedPercentage() {
|
||||
return this.memoryTotal ? (this.used * 100) / this.memoryTotal : 0;
|
||||
},
|
||||
|
||||
reservedAmountTemplateValues() {
|
||||
return {
|
||||
used: this.memoryFormatter(this.reserved || 0),
|
||||
total: this.memoryFormatter(this.memoryTotal || 0),
|
||||
unit: ` ${ this.memoryUnits }`,
|
||||
};
|
||||
},
|
||||
|
||||
usedAmountTemplateValues() {
|
||||
return {
|
||||
used: this.memoryFormatter(this.used || 0),
|
||||
total: this.memoryFormatter(this.memoryTotal || 0),
|
||||
unit: ` ${ this.memoryUnits }`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -96,50 +120,53 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ConsumptionGauge
|
||||
:capacity="memoryTotal"
|
||||
:used="reserved"
|
||||
:units="memoryUnits"
|
||||
:number-formatter="memoryFormatter"
|
||||
:resource-name="resourceName"
|
||||
>
|
||||
<template #title="{amountTemplateValues, formattedPercentage}">
|
||||
<span>
|
||||
<div class="consumption-gauge">
|
||||
<div class="numbers">
|
||||
<span class="mr-10">
|
||||
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
|
||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
||||
</span>
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
|
||||
</span>
|
||||
</template>
|
||||
</ConsumptionGauge>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<PercentageBar
|
||||
:model-value="reservedPercentage"
|
||||
show-percentage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showUsed"
|
||||
class="mt-10"
|
||||
class="consumption-gauge mt-10"
|
||||
>
|
||||
<ConsumptionGauge
|
||||
:capacity="memoryTotal"
|
||||
:used="used"
|
||||
:units="memoryUnits"
|
||||
:number-formatter="memoryFormatter"
|
||||
>
|
||||
<template #title="{amountTemplateValues, formattedPercentage}">
|
||||
<span>
|
||||
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }}
|
||||
<span class="ml-10 percentage">/ {{ formattedPercentage }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ConsumptionGauge>
|
||||
<div class="numbers">
|
||||
<span>
|
||||
{{ t('harvester.formatters.hardwareResourceGauge.used') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<PercentageBar
|
||||
:model-value="usedPercentage"
|
||||
show-percentage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consumption-gauge {
|
||||
.numbers {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.precent-data {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script>
|
||||
import ConsumptionGauge from '@shell/components/ConsumptionGauge';
|
||||
import PercentageBar from '@shell/components/PercentageBar';
|
||||
import { LONGHORN } from '@shell/config/types';
|
||||
import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units';
|
||||
import { UNIT_SUFFIX } from '../utils/unit';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterStorageUsed',
|
||||
components: { ConsumptionGauge },
|
||||
components: { PercentageBar },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
@@ -120,6 +120,14 @@ export default {
|
||||
unit: this.allocatedUnits,
|
||||
};
|
||||
},
|
||||
|
||||
usedPercentage() {
|
||||
return this.storageStats.maximum ? (this.storageStats.used * 100) / this.storageStats.maximum : 0;
|
||||
},
|
||||
|
||||
allocatedPercentage() {
|
||||
return this.storageStats.total ? (this.storageStats.scheduled * 100) / this.storageStats.total : 0;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -144,53 +152,54 @@ export default {
|
||||
<div>
|
||||
<div
|
||||
v-if="showAllocated"
|
||||
class="consumption-gauge"
|
||||
>
|
||||
<ConsumptionGauge
|
||||
:capacity="storageStats.total"
|
||||
:used="storageStats.scheduled"
|
||||
:units="allocatedUnits"
|
||||
:number-formatter="formatter"
|
||||
:resource-name="resourceName"
|
||||
>
|
||||
<template #title="{formattedPercentage}">
|
||||
<span>
|
||||
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
|
||||
<span class="ml-10 percentage">
|
||||
/ {{ formattedPercentage }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ConsumptionGauge>
|
||||
<div class="numbers">
|
||||
<span class="mr-10">
|
||||
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<PercentageBar
|
||||
:model-value="allocatedPercentage"
|
||||
show-percentage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConsumptionGauge
|
||||
:capacity="storageStats.maximum"
|
||||
:used="storageStats.used"
|
||||
:units="usedUnits"
|
||||
:number-formatter="formatter"
|
||||
:resource-name="showAllocated ? '' : resourceName"
|
||||
:class="{
|
||||
'mt-10': showAllocated,
|
||||
}"
|
||||
<div
|
||||
class="consumption-gauge"
|
||||
:class="{ 'mt-10': showAllocated }"
|
||||
>
|
||||
<template #title="{formattedPercentage}">
|
||||
<div class="numbers">
|
||||
<span>
|
||||
{{ t('node.detail.glance.consumptionGauge.used') }}
|
||||
</span>
|
||||
<span class="precent-data">
|
||||
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
|
||||
<span class="ml-10 percentage">
|
||||
/ {{ formattedPercentage }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ConsumptionGauge>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<PercentageBar
|
||||
:model-value="usedPercentage"
|
||||
show-percentage
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consumption-gauge {
|
||||
.numbers {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.precent-data {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'HarvesterVlanFormatter',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="value">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,3 +1,8 @@
|
||||
wizard:
|
||||
create: Create
|
||||
next: Proceed
|
||||
previous: Back
|
||||
|
||||
generic:
|
||||
tip: Tip
|
||||
resourceExternalLinkTips: 'External Link'
|
||||
@@ -7,6 +12,16 @@ generic:
|
||||
inProgress: In Progress
|
||||
basic: Basic
|
||||
loading: Loading...
|
||||
none: None
|
||||
duration:
|
||||
infinite: Infinite
|
||||
1s: 1 second
|
||||
5s: 5 seconds
|
||||
10s: 10 seconds
|
||||
30s: 30 seconds
|
||||
1m: 1 minute
|
||||
3m: 3 minutes
|
||||
5m: 5 minutes
|
||||
|
||||
unsupported:
|
||||
serverVersion: 'Current version: <code>{serverVersion}</code>'
|
||||
@@ -21,6 +36,8 @@ nav:
|
||||
Logging: Logging
|
||||
'Monitoring and Logging': Monitoring and Logging
|
||||
vmimport: Virtual Machine Imports
|
||||
vmMigration: Virtual Machine Migration
|
||||
Configurations: Configurations
|
||||
|
||||
resourceTable:
|
||||
groupBy:
|
||||
@@ -34,6 +51,8 @@ members:
|
||||
projectMembership: Project Membership
|
||||
|
||||
asyncButton:
|
||||
createAndStart:
|
||||
action: Create and Start
|
||||
restart:
|
||||
action: Save and Restart
|
||||
success: Restarted
|
||||
@@ -74,6 +93,8 @@ harvester:
|
||||
backup:
|
||||
success: 'Backup { backUpName } has been initiated.'
|
||||
addBackup: Add Backup
|
||||
fileSystemFreezeDeadline:
|
||||
label: 'File System Freeze Deadline'
|
||||
quota:
|
||||
editVMQuota: Edit VM Quota
|
||||
editQuota: Edit Quota
|
||||
@@ -213,6 +234,8 @@ harvester:
|
||||
title: Take Virtual Machine Snapshot
|
||||
name: Name
|
||||
success: 'Take virtual machine Snapshot { name } successfully.'
|
||||
fileSystemFreezeDeadline:
|
||||
label: 'File System Freeze Deadline'
|
||||
restart:
|
||||
title: Restart Virtual Machine
|
||||
tip: Restart the virtual machine for configuration changes to take effect.
|
||||
@@ -295,6 +318,12 @@ harvester:
|
||||
harvesterIpAddress:
|
||||
customIpTooltip: "Custom IP (set via annotation)"
|
||||
tableHeaders:
|
||||
hostNetworkConfig:
|
||||
underlay: Underlay
|
||||
underlayTooltip: Allow this interface to act as the underlay for VM overlay networks.
|
||||
vlanID: VLAN ID
|
||||
mode: Mode
|
||||
clusterNetwork: Cluster Network
|
||||
imageEncryption: Encryption
|
||||
size: Size
|
||||
virtualSize: Virtual Size
|
||||
@@ -302,7 +331,8 @@ harvester:
|
||||
message: Message
|
||||
phase: Phase
|
||||
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.
|
||||
fingerprint: Fingerprint
|
||||
value: Value
|
||||
@@ -342,6 +372,16 @@ harvester:
|
||||
vmImportSourceOClusterStatus: Cluster Status
|
||||
vmImportSourceOVAUrl: URL
|
||||
vmImportSourceOVAStatus: Status
|
||||
v4ip: V4 IP
|
||||
v6ip: V6 IP
|
||||
eipName: EIP Name
|
||||
vmMigrationProviderType: Type
|
||||
vmMigrationProviderUrl: URL
|
||||
vmMigrationMapSourceProvider: Source Provider
|
||||
vmMigrationMapDestProvider: Destination Provider
|
||||
vmMigrationPlanTargetNs: Target Namespace
|
||||
vmMigrationPlanVmCount: VM Count
|
||||
vmMigrationMigrationPlan: Plan
|
||||
tab:
|
||||
volume: Volumes
|
||||
network: Networks
|
||||
@@ -366,6 +406,7 @@ harvester:
|
||||
network: Network
|
||||
model: Model
|
||||
macAddress: MAC address
|
||||
staticIp: Static IP
|
||||
port: Port
|
||||
protocol: Protocol
|
||||
remove: Remove
|
||||
@@ -447,7 +488,6 @@ harvester:
|
||||
volume:
|
||||
upperType: Volume name
|
||||
lowerType: volume name
|
||||
needAtLeastOneBootable: 'At least one bootable volume is required!'
|
||||
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.'
|
||||
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.'
|
||||
@@ -601,6 +641,7 @@ harvester:
|
||||
notification:
|
||||
success: 'Update host "{name}" storage configurations successfully.'
|
||||
error: Host has unready or unschedulable disks.
|
||||
mounted: Already Mounted
|
||||
fileSystem:
|
||||
info: Current file system is {system}, You can format it manually.
|
||||
formatting: Disk is formatting, please wait.
|
||||
@@ -740,6 +781,7 @@ harvester:
|
||||
dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order.
|
||||
volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes.
|
||||
macTip: "MAC address as seen inside the guest system."
|
||||
staticIpTip: "Static IP that will be stored to annotation static-ip.harvesterhci.io/{nic_name}."
|
||||
volumeUpdate: 'Set volume { name } successfully'
|
||||
type: Type
|
||||
size: Size
|
||||
@@ -756,6 +798,10 @@ harvester:
|
||||
setFirst: Set as root volume
|
||||
saveVolume: Update Volume
|
||||
encryption: Encryption
|
||||
shareable:
|
||||
label: Shareable
|
||||
tip: Allow multiple virtual machines to attach and write to this volume simultaneously.
|
||||
warning: Multiple virtual machines can write to this volume at the same time. The guest workloads must coordinate access to the shared block device, otherwise concurrent writes will corrupt the data. Workloads that fence through SCSI-3 Persistent Reservations (e.g. Windows Server Failover Clustering) also require persistent reservation support from the storage backend. Host cache and hypervisor disk locking are disabled, and backup or snapshot operations are rejected while the volume is shareable.
|
||||
vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
|
||||
lockTooltip:
|
||||
all: All volumes are encrypted.
|
||||
@@ -775,7 +821,7 @@ harvester:
|
||||
addPort: Add Port
|
||||
cloudConfig:
|
||||
title: Cloud Configuration
|
||||
createTemplateTitle: 'Create {name}.'
|
||||
createTemplateTitle: 'Create {name}'
|
||||
createNew: Create new...
|
||||
cloudInit:
|
||||
label: Cloud Init
|
||||
@@ -788,6 +834,17 @@ harvester:
|
||||
label: Network Data Template
|
||||
title: "Network Data:"
|
||||
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
|
||||
sysprep:
|
||||
title: Windows 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>"
|
||||
createNew: Create new...
|
||||
createNewTitle: Create Windows Unattended & Sysprep Template
|
||||
xmlContent: autounattend.xml content
|
||||
secret:
|
||||
label: Windows Unattended & Sysprep Template
|
||||
validation:
|
||||
invalidXml: Invalid XML syntax
|
||||
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
|
||||
scheduling:
|
||||
affinity:
|
||||
anyNode: 'Run virtual machine on any available node'
|
||||
@@ -1046,6 +1103,8 @@ harvester:
|
||||
downloadLog: Download Log
|
||||
logStatus: Log Download Status
|
||||
dismissMessage: Dismiss it
|
||||
upgradeAvailable: Upgrade Available
|
||||
upgradeUnavailable: Upgrade Unavailable
|
||||
upgradeInfo:
|
||||
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.
|
||||
@@ -1149,6 +1208,9 @@ harvester:
|
||||
gateway:
|
||||
label: Gateway IP
|
||||
placeholder: e.g. 172.20.0.1
|
||||
vlan:
|
||||
label: VLAN
|
||||
placeholder: Select a VLAN
|
||||
dhcp:
|
||||
label: Dynamic Host Configuration Protocol (DHCP)
|
||||
v4Options: DHCPV4Options
|
||||
@@ -1234,14 +1296,97 @@ harvester:
|
||||
remoteVpc:
|
||||
label: Remote VPC
|
||||
infoBanner: The static route destination CIDR must cover all subnets CIDR from remote VPC Peer. Read <a href="{url}" target="_blank">VPC Peering Configuration Examples</a> for more information.
|
||||
natGateway:
|
||||
label: Gateways
|
||||
internalTenantNetwork:
|
||||
label: Internal Tenant Network
|
||||
placeholder: Select a Virtual Machine Network
|
||||
vpc:
|
||||
label: VPC
|
||||
placeholder: Select a VPC
|
||||
subnet:
|
||||
label: Subnet
|
||||
placeholder: Select a subnet
|
||||
lanIp:
|
||||
label: LAN IP
|
||||
placeholder: Enter LAN IP
|
||||
externalSubnets:
|
||||
label: External Subnets
|
||||
addLabel: Add
|
||||
placeholder: Select a subnet
|
||||
externalIP:
|
||||
label: External IPs
|
||||
natGateway:
|
||||
label: VpcNatGateway
|
||||
placeholder: Select a VpcNatGateway
|
||||
externalSubnet:
|
||||
label: External Subnet
|
||||
placeholder: Select an external subnet
|
||||
v4ip:
|
||||
label: V4 IP
|
||||
placeholder: public ip from external subnet
|
||||
snat:
|
||||
label: Source Rules
|
||||
eip:
|
||||
label: EIP
|
||||
placeholder: Select an external IP
|
||||
internalCIDR:
|
||||
label: Internal CIDR
|
||||
placeholder: internal subnet CIDR
|
||||
dnat:
|
||||
label: Destination Rules
|
||||
eip:
|
||||
label: EIP
|
||||
placeholder: Select an external IP
|
||||
externalPort:
|
||||
label: External Port
|
||||
placeholder: port number
|
||||
internalIp:
|
||||
label: Internal IP
|
||||
placeholder: internal IP address
|
||||
internalPort:
|
||||
label: Internal Port
|
||||
placeholder: port number
|
||||
protocol:
|
||||
label: Protocol
|
||||
placeholder: Select protocol (tcp or udp)
|
||||
providerNetwork:
|
||||
label: Provider Networks
|
||||
defaultInterface:
|
||||
label: Default Interface
|
||||
placeholder: Select the interface the same as master interface of external overlay network
|
||||
customInterfaces:
|
||||
label: Custom Interfaces
|
||||
addLabel: Add Custom Interface
|
||||
interface:
|
||||
label: Network Interface
|
||||
placeholder: e.g. eth2
|
||||
nodes:
|
||||
label: Nodes
|
||||
addLabel: Add Node
|
||||
placeholder: Select a node
|
||||
excludedNodes:
|
||||
label: Excluded Nodes
|
||||
addLabel: Add Excluded Node
|
||||
placeholder: Select node to exclude from this provider network
|
||||
vlanNetwork:
|
||||
label: VLANs
|
||||
vlan:
|
||||
id:
|
||||
label: VLAN ID
|
||||
placeholder: "e.g. 1-4094"
|
||||
provider:
|
||||
label: Provider Network
|
||||
placeholder: Select a provider network
|
||||
networkPolicy:
|
||||
label: Network Policies
|
||||
label: Policies
|
||||
banner: The network policies must be used for VMs attached to overlay networks. Please read the <a href="{url}" target="_blank">harvester document</a> how the network policy works.
|
||||
network:
|
||||
label: Virtual Machine Networks
|
||||
tabs:
|
||||
basics: Basics
|
||||
layer3Network: Route
|
||||
nic: Network Interface Card
|
||||
clusterNetwork:
|
||||
label: Cluster Network
|
||||
create: Create a new cluster network
|
||||
@@ -1310,6 +1455,7 @@ harvester:
|
||||
invalid: '"Range" is invalid.'
|
||||
clusterNetwork: Cluster Network
|
||||
vlan: VLAN ID
|
||||
exclusiveVlan: Exclusive VLAN
|
||||
exclude:
|
||||
label: Exclude IPs
|
||||
placeholder: CIDR format, e.g. 172.16.0.10/32
|
||||
@@ -1627,8 +1773,9 @@ harvester:
|
||||
schedulingRules: Select node(s) matching rules
|
||||
uplink:
|
||||
nics:
|
||||
label: NICs
|
||||
label: NIC
|
||||
addLabel: Add NIC
|
||||
overlayWarning: The NIC selected here must match the NIC provided in the provider network.
|
||||
placeholder: Select a NIC that is available on all the selected nodes
|
||||
validate:
|
||||
available: NIC "{nic}" is not available on the selected nodes
|
||||
@@ -1690,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.
|
||||
'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:
|
||||
titles:
|
||||
basic: Basic
|
||||
@@ -1870,6 +2233,32 @@ harvester:
|
||||
addLabel: Add CIDR
|
||||
range:
|
||||
addLabel: Add Range
|
||||
hostNetworkConfig:
|
||||
label: Host Networks
|
||||
mode:
|
||||
label: Mode
|
||||
tabs:
|
||||
mode: Mode
|
||||
nodeSelector: Node Selector
|
||||
nodeSelector:
|
||||
addButton: Add Node Selector
|
||||
underlay:
|
||||
label: Underlay
|
||||
tooltip: Allow this interface to act as the underlay for VM overlay networks.
|
||||
conflict: '`{name}` host network config already has underlay enabled. Only one underlay is allowed in the cluster.'
|
||||
noKubeovn:
|
||||
prefix: The kubeovn-operator add-on is not enabled. Click
|
||||
middle: here
|
||||
suffix: to enable the add-on for overlay networking.
|
||||
vlanID:
|
||||
label: VLAN ID
|
||||
ipRange:
|
||||
label: IP Range ({node})
|
||||
placeholder: e.g. 192.168.1.10/24
|
||||
ips:
|
||||
nodeLabel: Node
|
||||
label: IP
|
||||
placeholder: 'e.g. 192.168.1.10/24'
|
||||
|
||||
service:
|
||||
healthCheckPort:
|
||||
@@ -1912,6 +2301,12 @@ harvester:
|
||||
prefix: The nvidia-driver-toolkit add-on is not enabled, click
|
||||
middle: here
|
||||
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:
|
||||
label: vGPU MIG Configurations
|
||||
@@ -2057,6 +2452,8 @@ advancedSettings:
|
||||
'harv-auto-rotate-rke2-certs': The certificate rotation mechanism relies on Rancher. Harvester will automatically update certificates generation to trigger rotation.
|
||||
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on Harvester administration kubeconfig files. Default is 0, which means to never expire.'
|
||||
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
|
||||
'harv-longhorn-v2-data-engine-hugepage-enabled': 'Enable hugepages when using the Longhorn V2 data engine. Default is true. Disabling hugepages reduces memory pressure on low-spec nodes and increases deployment flexibility. However, performance may be lower compared to running with hugepages.'
|
||||
'harv-longhorn-v2-data-engine-memory-size': 'Configure the amount of memory allocated to the SPDK target daemon when using the Longhorn V2 data engine. Default is 2048 MiB.'
|
||||
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
|
||||
'harv-upgrade-config': 'Configure image preloading and VM restore options for upgrades. See related fields in <a href="{url}" target="_blank" rel="noopener">settings/upgrade-config</a>'
|
||||
'harv-vm-migration-network': 'Segregated network for VM migration traffic.'
|
||||
@@ -2127,6 +2524,36 @@ typeLabel:
|
||||
one { Virtual Private Cloud }
|
||||
other { Virtual Private Clouds }
|
||||
}
|
||||
kubeovn.io.vlan: |-
|
||||
{count, plural,
|
||||
one { VLAN Network }
|
||||
other { VLAN Networks }
|
||||
}
|
||||
kubeovn.io.providernetwork: |-
|
||||
{count, plural,
|
||||
one { Provider Network }
|
||||
other { Provider Networks }
|
||||
}
|
||||
kubeovn.io.vpcnatgateway: |-
|
||||
{count, plural,
|
||||
one { NAT Gateway }
|
||||
other { NAT Gateways }
|
||||
}
|
||||
kubeovn.io.iptablessnatrule: |-
|
||||
{count, plural,
|
||||
one { Source Rule }
|
||||
other { Source Rules }
|
||||
}
|
||||
kubeovn.io.iptablesdnatrule: |-
|
||||
{count, plural,
|
||||
one { Destination Rule }
|
||||
other { Destination Rules }
|
||||
}
|
||||
kubeovn.io.iptableseip: |-
|
||||
{count, plural,
|
||||
one { External IP }
|
||||
other { External IPs }
|
||||
}
|
||||
networking.k8s.io.networkpolicy: |-
|
||||
{count, plural,
|
||||
one { Network Policy }
|
||||
@@ -2193,6 +2620,11 @@ typeLabel:
|
||||
one { Cluster Network }
|
||||
other { Cluster Networks }
|
||||
}
|
||||
network.harvesterhci.io.hostnetworkconfig: |-
|
||||
{count, plural,
|
||||
one { Host Network }
|
||||
other { Host Networks }
|
||||
}
|
||||
harvesterhci.io.addon: |-
|
||||
{count, plural,
|
||||
one { Add-on }
|
||||
@@ -2262,3 +2694,28 @@ typeLabel:
|
||||
one { Virtual Machine Import }
|
||||
other { Virtual Machine Imports }
|
||||
}
|
||||
forklift.konveyor.io.provider: |-
|
||||
{count, plural,
|
||||
one { Provider }
|
||||
other { Providers }
|
||||
}
|
||||
forklift.konveyor.io.networkmap: |-
|
||||
{count, plural,
|
||||
one { Network Map }
|
||||
other { Network Maps }
|
||||
}
|
||||
forklift.konveyor.io.storagemap: |-
|
||||
{count, plural,
|
||||
one { Storage Map }
|
||||
other { Storage Maps }
|
||||
}
|
||||
forklift.konveyor.io.plan: |-
|
||||
{count, plural,
|
||||
one { Migration Plan }
|
||||
other { Migration Plans }
|
||||
}
|
||||
forklift.konveyor.io.migration: |-
|
||||
{count, plural,
|
||||
one { Migration }
|
||||
other { Migrations }
|
||||
}
|
||||
|
||||
@@ -86,6 +86,13 @@ export default {
|
||||
value: 'spec.address',
|
||||
sort: ['spec.address']
|
||||
},
|
||||
{
|
||||
name: 'timesliced',
|
||||
labelKey: 'harvester.sriovgpu.timesliced',
|
||||
value: 'isTimesliced',
|
||||
formatter: 'Checked',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
name: 'vfAddresses',
|
||||
label: 'VF Addresses',
|
||||
|
||||
@@ -53,7 +53,6 @@ export default {
|
||||
|
||||
devices() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
|
||||
|
||||
data.forEach((row) => {
|
||||
|
||||
@@ -84,7 +84,7 @@ export default {
|
||||
},
|
||||
{
|
||||
...NAME,
|
||||
width: 130,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
name: 'host-ip',
|
||||
@@ -136,14 +136,14 @@ export default {
|
||||
value: 'id',
|
||||
formatter: 'HarvesterCPUPinning',
|
||||
formatterOpts: { rows: this.rows },
|
||||
width: 150,
|
||||
width: 75,
|
||||
align: 'center',
|
||||
});
|
||||
}
|
||||
if (this.hasLonghornSchema) {
|
||||
out.push({
|
||||
name: 'diskState',
|
||||
labelKey: 'tableHeaders.diskState',
|
||||
labelKey: 'harvester.tableHeaders.diskState',
|
||||
value: 'diskState',
|
||||
formatter: 'HarvesterDiskState',
|
||||
width: 130,
|
||||
@@ -156,7 +156,6 @@ export default {
|
||||
name: 'console',
|
||||
label: ' ',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
});
|
||||
|
||||
return out;
|
||||
@@ -239,6 +238,8 @@ export default {
|
||||
v-if="!row.consoleUrl"
|
||||
:href="consoleDocLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="console-info"
|
||||
><i class="icon icon-info" /></a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -249,5 +250,13 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.console-button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.console-info {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -166,6 +166,7 @@ export default {
|
||||
:schema="schema"
|
||||
:groupable="true"
|
||||
:rows="filterRows"
|
||||
:ignore-filter="true"
|
||||
key-field="_key"
|
||||
>
|
||||
<template #cell:state="{row}">
|
||||
|
||||
@@ -106,8 +106,8 @@ export default {
|
||||
name: 'AttachedVM',
|
||||
labelKey: 'tableHeaders.attachedVM',
|
||||
type: 'attached',
|
||||
value: 'spec.claimRef',
|
||||
sort: 'name',
|
||||
value: 'attachVMName',
|
||||
sort: 'attachVMName',
|
||||
},
|
||||
{
|
||||
name: 'VolumeSnapshotCounts',
|
||||
@@ -134,8 +134,8 @@ export default {
|
||||
return row?.attachVM?.detailLocation;
|
||||
},
|
||||
|
||||
getVMName(row) {
|
||||
return row.attachVM?.metadata?.name || '';
|
||||
getAttachedVMName(row) {
|
||||
return row.attachVMName || '';
|
||||
},
|
||||
|
||||
isInternalStorageClass(storageClassName) {
|
||||
@@ -173,10 +173,10 @@ export default {
|
||||
<template #cell:AttachedVM="{row}">
|
||||
<div>
|
||||
<router-link
|
||||
v-if="getVMName(row)"
|
||||
v-if="getAttachedVMName(row)"
|
||||
:to="goTo(row)"
|
||||
>
|
||||
{{ getVMName(row) }}
|
||||
{{ getAttachedVMName(row) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +67,13 @@ export default {
|
||||
NAMESPACE,
|
||||
CIDR_BLOCK,
|
||||
PROTOCOL,
|
||||
{
|
||||
name: 'vlan',
|
||||
labelKey: 'harvester.subnet.vlan.label',
|
||||
value: 'spec.vlan',
|
||||
sort: 'spec.vlan',
|
||||
formatter: 'HarvesterVlan',
|
||||
},
|
||||
PROVIDER,
|
||||
AGE
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
|
||||
import {
|
||||
@@ -116,6 +117,8 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ actionCb: 'action-menu/performCallbackData' }),
|
||||
|
||||
headers() {
|
||||
const restoreCol = {
|
||||
name: 'restoreProgress',
|
||||
@@ -182,6 +185,13 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
actionCb(neu) {
|
||||
if (neu?.clearTableSelection) {
|
||||
this.$refs.resourceTable.clearSelection();
|
||||
this.$store.dispatch('action-menu/clearCallbackData');
|
||||
}
|
||||
},
|
||||
|
||||
vmRestartRequiredNames(vmNames) {
|
||||
const count = vmNames.length;
|
||||
|
||||
@@ -220,6 +230,7 @@ export default {
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<div v-else>
|
||||
<ResourceTable
|
||||
ref="resourceTable"
|
||||
v-bind="$attrs"
|
||||
:headers="headers"
|
||||
default-sort-by="age"
|
||||
|
||||
@@ -59,7 +59,7 @@ export default {
|
||||
|
||||
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
|
||||
|
||||
return configs;
|
||||
return configs.filter((config) => config.spec?.clusterNetwork !== 'mgmt');
|
||||
},
|
||||
|
||||
vlanConfigSchema() {
|
||||
@@ -121,6 +121,22 @@ export default {
|
||||
|
||||
return [...this.rows, ...fakeRows];
|
||||
},
|
||||
|
||||
managementVlanConfigs() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
|
||||
|
||||
return configs
|
||||
.filter((vc) => vc.spec?.clusterNetwork === 'mgmt')
|
||||
.map((vc) => ({
|
||||
id: vc.id,
|
||||
node: vc.id.replace('mgmt-vlanconfig-', ''),
|
||||
bondMode: vc.spec?.uplink?.bondOptions?.mode || '-',
|
||||
interfaces: (vc.spec?.uplink?.nics?.length ? vc.spec.uplink.nics.join(', ') : '-'),
|
||||
mtu: vc.spec?.uplink?.linkAttributes?.mtu || '-',
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -224,6 +240,29 @@ export default {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="group.key === 'mgmt'"
|
||||
class="mgmt-summary"
|
||||
>
|
||||
<div class="mgmt-header">
|
||||
<div>{{ t('harvester.tableHeaders.vm.node') }}</div>
|
||||
<div>{{ t('harvester.vlanConfig.uplink.bondOptions.mode.label') }}</div>
|
||||
<div>{{ t('harvester.vlanConfig.uplink.nics.label') }}</div>
|
||||
<div>{{ t('harvester.vlanConfig.uplink.linkAttributes.mtu.label') }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="config in managementVlanConfigs"
|
||||
:key="config.id"
|
||||
class="mgmt-row"
|
||||
>
|
||||
<div>{{ config.node }}</div>
|
||||
<div>{{ config.bondMode }}</div>
|
||||
<div>{{ config.interfaces }}</div>
|
||||
<div>{{ config.mtu }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs"
|
||||
@@ -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 lang="scss">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script>
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
|
||||
import { HCI } from '../types';
|
||||
|
||||
const UNDERLAY = {
|
||||
name: 'underlay',
|
||||
labelKey: 'harvester.tableHeaders.hostNetworkConfig.underlay',
|
||||
tooltip: 'harvester.tableHeaders.hostNetworkConfig.underlayTooltip',
|
||||
value: 'spec.underlay',
|
||||
sort: 'spec.underlay',
|
||||
formatter: 'HarvesterBoolean',
|
||||
};
|
||||
|
||||
const VLAN_ID = {
|
||||
name: 'vlanID',
|
||||
labelKey: 'harvester.tableHeaders.hostNetworkConfig.vlanID',
|
||||
value: 'spec.vlanID',
|
||||
sort: 'spec.vlanID',
|
||||
};
|
||||
|
||||
const MODE = {
|
||||
name: 'mode',
|
||||
labelKey: 'harvester.tableHeaders.hostNetworkConfig.mode',
|
||||
value: 'spec.mode',
|
||||
sort: 'spec.mode',
|
||||
formatter: 'HarvesterHostNetworkConfigMode',
|
||||
};
|
||||
|
||||
const CLUSTER_NETWORK = {
|
||||
name: 'clusterNetwork',
|
||||
labelKey: 'harvester.tableHeaders.hostNetworkConfig.clusterNetwork',
|
||||
value: 'spec.clusterNetwork',
|
||||
sort: 'spec.clusterNetwork',
|
||||
align: 'center',
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'HarvesterListHostNetworkConfig',
|
||||
components: { ResourceTable, Loading },
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HOST_NETWORK_CONFIG });
|
||||
},
|
||||
|
||||
data() {
|
||||
return { rows: [] };
|
||||
},
|
||||
|
||||
computed: {
|
||||
schema() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/schemaFor`](HCI.HOST_NETWORK_CONFIG);
|
||||
},
|
||||
headers() {
|
||||
return [
|
||||
STATE,
|
||||
NAME_COL,
|
||||
UNDERLAY,
|
||||
VLAN_ID,
|
||||
MODE,
|
||||
CLUSTER_NETWORK,
|
||||
AGE,
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<ResourceTable
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:headers="headers"
|
||||
:rows="rows"
|
||||
:schema="schema"
|
||||
key-field="_key"
|
||||
/>
|
||||
</template>
|
||||
@@ -38,6 +38,10 @@ export const SSH_EXISTING_TYPE = {
|
||||
export default {
|
||||
methods: {
|
||||
hasCloudConfigComment(userScript) {
|
||||
if (typeof userScript === 'string' && /(^|\n)\s*#cloud-config(\s|$)/.test(userScript)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check that userData contains: #cloud-config
|
||||
const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({});
|
||||
const items = userDataDoc?.contents?.items || [];
|
||||
@@ -52,6 +56,14 @@ export default {
|
||||
exist = true;
|
||||
}
|
||||
|
||||
if (userDataDoc?.contents?.commentBefore === 'cloud-config' || userDataDoc?.contents?.commentBefore?.includes('cloud-config\n')) {
|
||||
exist = true;
|
||||
}
|
||||
|
||||
if (userDataDoc?.contents?.comment === 'cloud-config' || userDataDoc?.contents?.comment?.includes('cloud-config\n')) {
|
||||
exist = true;
|
||||
}
|
||||
|
||||
items.map((item) => {
|
||||
const key = item.key;
|
||||
|
||||
@@ -255,6 +267,29 @@ export default {
|
||||
return this.convertToJson(userData)?.ssh_authorized_keys || [];
|
||||
},
|
||||
|
||||
getSysprepConfig(spec) {
|
||||
const sysprepVolume = spec?.template?.spec?.volumes?.find(
|
||||
(v) => v.name === 'sysprep' && v.sysprep?.secret
|
||||
);
|
||||
|
||||
if (!sysprepVolume) {
|
||||
return { secretName: '', xmlContent: '' };
|
||||
}
|
||||
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const namespace = this.value.metadata.namespace;
|
||||
const secretName = sysprepVolume.sysprep.secret.name;
|
||||
const secret = this.$store.getters[`${ inStore }/byId`](
|
||||
SECRET,
|
||||
`${ namespace }/${ secretName }`
|
||||
);
|
||||
|
||||
return {
|
||||
secretName: `${ namespace }/${ secretName }`,
|
||||
xmlContent: secret?.decodedData?.['autounattend.xml'] || ''
|
||||
};
|
||||
},
|
||||
|
||||
compareSSHValue(a = '', b = '') {
|
||||
const r = /(\r\n\t|\n|\r\t)|(\s*)/gm;
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ import { HCI } from '../../types';
|
||||
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
|
||||
import impl, { QGA_JSON, USB_TABLET } from './impl';
|
||||
import { GIBIBYTE } from '../../utils/unit';
|
||||
import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
|
||||
import { VOLUME_MODE, 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';
|
||||
|
||||
@@ -162,6 +164,12 @@ export default {
|
||||
machineType: '',
|
||||
machineTypes: [],
|
||||
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,
|
||||
showAdvanced: false,
|
||||
deleteAgent: true,
|
||||
@@ -186,6 +194,7 @@ export default {
|
||||
terminationGracePeriodSeconds: '',
|
||||
cpuPinning: false,
|
||||
cpuModel: '',
|
||||
sysprep: { secretName: '', xmlContent: '' },
|
||||
};
|
||||
},
|
||||
|
||||
@@ -269,7 +278,7 @@ export default {
|
||||
},
|
||||
|
||||
customAccessMode() {
|
||||
return this.storageClassSetting.accessModes || 'ReadWriteMany';
|
||||
return this.storageClassSetting.accessModes || READ_WRITE_MANY;
|
||||
},
|
||||
|
||||
isWindows() {
|
||||
@@ -445,6 +454,17 @@ export default {
|
||||
this['diskRows'] = diskRows;
|
||||
this['filesystemRows'] = this.getFilesystemRows(vm);
|
||||
|
||||
let sysprepConfig = { secretName: '', xmlContent: '' };
|
||||
|
||||
if (osType === 'windows') {
|
||||
sysprepConfig = this.getSysprepConfig(spec);
|
||||
}
|
||||
|
||||
this['sysprep'] = {
|
||||
secretName: sysprepConfig.secretName || this.sysprep?.secretName || '',
|
||||
xmlContent: sysprepConfig.xmlContent || this.sysprep?.xmlContent || '',
|
||||
};
|
||||
|
||||
this.refreshYamlEditor();
|
||||
},
|
||||
|
||||
@@ -486,7 +506,7 @@ export default {
|
||||
id: randomStr(5),
|
||||
source: SOURCE_TYPE.IMAGE,
|
||||
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,
|
||||
volumeName: '',
|
||||
size,
|
||||
@@ -552,7 +572,7 @@ export default {
|
||||
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
|
||||
|
||||
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';
|
||||
storageClassName = pvcResource?.spec?.storageClassName;
|
||||
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
|
||||
@@ -598,6 +618,7 @@ export default {
|
||||
type,
|
||||
storageClassName,
|
||||
hotpluggable,
|
||||
shareable: DISK.shareable || false,
|
||||
volumeStatus,
|
||||
dataSource,
|
||||
namespace,
|
||||
@@ -609,7 +630,8 @@ export default {
|
||||
|
||||
out = sortBy(out, 'bootOrder');
|
||||
|
||||
return out.filter( (O) => O.name !== 'cloudinitdisk');
|
||||
// Filter out cloudinitdisk and sysprep disk from UI display.
|
||||
return out.filter( (O) => O.name !== 'cloudinitdisk' && O.name !== 'sysprep');
|
||||
},
|
||||
|
||||
getNetworkRows(vm, config) {
|
||||
@@ -617,6 +639,8 @@ export default {
|
||||
|
||||
const networks = vm.spec.template.spec.networks || [];
|
||||
const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
|
||||
const annotations = vm.metadata?.annotations || {};
|
||||
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
|
||||
|
||||
const out = interfaces.map( (I, index) => {
|
||||
const network = networks.find( (N) => I.name === N.name);
|
||||
@@ -633,6 +657,7 @@ export default {
|
||||
newCreateId: (fromTemplate || init) ? randomStr(10) : false,
|
||||
model: I.model,
|
||||
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
|
||||
staticIp: annotations[`${ staticIpPrefix }${ I.name }`] || '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -734,6 +759,8 @@ export default {
|
||||
|
||||
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
|
||||
|
||||
this.syncStaticIpAnnotations(vm);
|
||||
|
||||
// parse reserved memory
|
||||
if (!this.reservedMemory) {
|
||||
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
|
||||
@@ -755,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() {
|
||||
if (this.cpuMemoryHotplugEnabled) {
|
||||
// set CPU
|
||||
@@ -824,43 +898,86 @@ 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.secretNamePrefixUsed = this.secretNamePrefix;
|
||||
}
|
||||
|
||||
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
|
||||
if (!this.isWindows) {
|
||||
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
|
||||
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
|
||||
|
||||
// If we have content but no secret name, it's a new secret that needs a name. Also
|
||||
// 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';
|
||||
|
||||
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
|
||||
this.sysprepSecretNamePrefixUsed = this.secretNamePrefix;
|
||||
}
|
||||
|
||||
// Preserve/attach sysprep whenever a secret is selected/known.
|
||||
if (this.sysprep.secretName) {
|
||||
disks.push({
|
||||
name: 'cloudinitdisk',
|
||||
disk: { bus: 'virtio' }
|
||||
name: 'sysprep',
|
||||
cdrom: { bus: 'sata' }
|
||||
});
|
||||
|
||||
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
|
||||
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
|
||||
|
||||
const cloudinitdisk = {
|
||||
name: 'cloudinitdisk',
|
||||
cloudInitNoCloud: {}
|
||||
};
|
||||
|
||||
if (this.saveUserDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.userData = userData;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
if (this.saveNetworkDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
volumes.push(cloudinitdisk);
|
||||
volumes.push({
|
||||
name: 'sysprep',
|
||||
sysprep: { secret: { name: secretName } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
|
||||
disks.push({
|
||||
name: 'cloudinitdisk',
|
||||
disk: { bus: 'virtio' }
|
||||
});
|
||||
|
||||
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
|
||||
const cloudinitdisk = {
|
||||
name: 'cloudinitdisk',
|
||||
cloudInitNoCloud: {}
|
||||
};
|
||||
|
||||
if (this.saveUserDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.userData = userData;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
if (this.saveNetworkDataAsClearText) {
|
||||
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
|
||||
} else {
|
||||
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
|
||||
}
|
||||
|
||||
volumes.push(cloudinitdisk);
|
||||
}
|
||||
|
||||
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
|
||||
const mergedDisks = this.mergeDeviceList(specDisks, disks);
|
||||
|
||||
// `shareable` is an attachment-level opt-in, remove the field
|
||||
// inherited from the old spec when the disk row no longer requests it
|
||||
mergedDisks.forEach((disk) => {
|
||||
if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) {
|
||||
delete disk.shareable;
|
||||
}
|
||||
});
|
||||
|
||||
let spec = {
|
||||
...this.spec,
|
||||
runStrategy: this.runStrategy,
|
||||
@@ -1057,6 +1174,16 @@ export default {
|
||||
},
|
||||
|
||||
getInitUserData(config) {
|
||||
// Windows guests don't use qemu-guest-agent via systemd (VMDP installs
|
||||
// the QGA as a Windows service), and the Linux `runcmd` recipe would
|
||||
// fail on Cloudbase-Init. Return an empty string so `this.userData`
|
||||
// stays falsy on Windows until the user actually enters cloud-config
|
||||
// content — this prevents emitting a spurious cloudinitdisk volume
|
||||
// and Secret for every Windows VM (Copilot review comment on #984).
|
||||
if (config.osType === 'windows') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const _QGA_JSON = this.getMatchQGA(config.osType);
|
||||
|
||||
const out = jsyaml.dump(_QGA_JSON);
|
||||
@@ -1084,9 +1211,10 @@ export default {
|
||||
|
||||
userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config });
|
||||
const userDataYaml = userDataDoc.toString();
|
||||
const userDataValue = userDataDoc.toJSON();
|
||||
|
||||
if (userDataYaml === '{}\n') {
|
||||
// When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned.
|
||||
if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) {
|
||||
// Empty userData should not create cloud-init content like `null`.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1135,6 +1263,10 @@ export default {
|
||||
out.cdrom = { bus: R.bus };
|
||||
}
|
||||
|
||||
if (R.shareable) {
|
||||
out.shareable = true;
|
||||
}
|
||||
|
||||
out.bootOrder = index + 1;
|
||||
|
||||
return out;
|
||||
@@ -1259,13 +1391,22 @@ export default {
|
||||
*/
|
||||
deleteYamlDocProp(doc, paths) {
|
||||
try {
|
||||
const item = doc.getIn([])?.items[0];
|
||||
const key = item?.key;
|
||||
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
|
||||
const isMatchProp = key.source === paths[paths.length - 1];
|
||||
const items = doc.getIn([])?.items;
|
||||
const firstKey = items?.[0]?.key;
|
||||
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
|
||||
const isFirstProp = firstKey?.source === paths[paths.length - 1];
|
||||
|
||||
if (key && hasCloudConfigComment && isMatchProp) {
|
||||
// Comments are mounted on the next node and we should not delete the node containing cloud-config
|
||||
if (firstKey && hasCloudConfigComment && isFirstProp) {
|
||||
const comment = firstKey.commentBefore;
|
||||
|
||||
doc.deleteIn(paths);
|
||||
|
||||
// Move the comment to the new first key; if no keys remain the comment is lost.
|
||||
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
|
||||
|
||||
if (newFirstKey) {
|
||||
newFirstKey.commentBefore = comment;
|
||||
}
|
||||
} else {
|
||||
doc.deleteIn(paths);
|
||||
}
|
||||
@@ -1316,7 +1457,6 @@ export default {
|
||||
if (packages.length > 0) {
|
||||
userDataDoc.setIn(['packages'], packages);
|
||||
} else {
|
||||
userDataDoc.setIn(['packages'], []); // It needs to be set empty first, as it is possible that cloud-init comments are mounted on this node
|
||||
this.deleteYamlDocProp(userDataDoc, ['packages']);
|
||||
this.deleteYamlDocProp(userDataDoc, ['package_update']);
|
||||
}
|
||||
@@ -1331,7 +1471,7 @@ export default {
|
||||
},
|
||||
|
||||
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 || '';
|
||||
|
||||
@@ -1340,6 +1480,15 @@ export default {
|
||||
const packages = userDataJSON?.packages || [];
|
||||
const runcmd = userDataJSON?.runcmd || [];
|
||||
|
||||
// Special handling of OS types.
|
||||
let deletePackage = config.deletePackage ?? false;
|
||||
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
deletePackage = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (Array.isArray(packages) && deletePackage) {
|
||||
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
|
||||
|
||||
@@ -1365,7 +1514,6 @@ export default {
|
||||
if (packages.length > 0) {
|
||||
userDataDoc.setIn(['packages'], packages);
|
||||
} else {
|
||||
userDataDoc.setIn(['packages'], []);
|
||||
this.deleteYamlDocProp(userDataDoc, ['packages']);
|
||||
this.deleteYamlDocProp(userDataDoc, ['package_update']);
|
||||
}
|
||||
@@ -1398,7 +1546,7 @@ export default {
|
||||
},
|
||||
|
||||
async saveSecret(vm) {
|
||||
if (!vm?.spec || !this.secretName || this.isWindows) {
|
||||
if (!vm?.spec || !this.secretName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1482,6 +1630,42 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async saveSysprepConfig(vm) {
|
||||
if (!this.isWindows) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.sysprep.secretName?.trim?.() && !this.sysprep.xmlContent?.trim?.()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
|
||||
const namespace = vm.metadata.namespace;
|
||||
const namespacedName = `${ namespace }/${ secretName }`;
|
||||
|
||||
let secret;
|
||||
|
||||
try {
|
||||
secret = await this.$store.dispatch('harvester/find', { type: SECRET, id: namespacedName });
|
||||
} catch (e) {
|
||||
if (e?.status !== 404) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
secret = await this.$store.dispatch('harvester/create', {
|
||||
type: SECRET,
|
||||
metadata: {
|
||||
name: secretName,
|
||||
namespace,
|
||||
labels: { [HCI_ANNOTATIONS.WINDOWS_SYSPREP]: 'true' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
secret.setData('autounattend.xml', this.sysprep.xmlContent);
|
||||
await secret.save();
|
||||
},
|
||||
|
||||
getAccessCredentialsValidation() {
|
||||
const errors = [];
|
||||
|
||||
@@ -1685,7 +1869,18 @@ export default {
|
||||
const specDevice = specDeviceMap.get(device.name);
|
||||
|
||||
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;
|
||||
@@ -1733,6 +1928,15 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
networkRows: {
|
||||
handler() {
|
||||
const vm = this.resourceType === HCI.VM ? this.value : this.value?.spec?.vm;
|
||||
|
||||
this.syncStaticIpAnnotations(vm);
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
diskRows: {
|
||||
handler(neu, old) {
|
||||
if (Array.isArray(neu)) {
|
||||
@@ -1742,7 +1946,7 @@ export default {
|
||||
|
||||
const oldImageId = old[0]?.image;
|
||||
|
||||
if (this.isCreate && oldImageId === imageId && imageId) {
|
||||
if (this.isCreate && oldImageId !== imageId && imageId && osType) {
|
||||
this.osType = osType;
|
||||
}
|
||||
}
|
||||
@@ -1763,8 +1967,6 @@ export default {
|
||||
isWindows(val) {
|
||||
if (val) {
|
||||
this['sshKey'] = [];
|
||||
this['userScript'] = undefined;
|
||||
this['networkScript'] = undefined;
|
||||
this['installAgent'] = false;
|
||||
}
|
||||
},
|
||||
@@ -1824,9 +2026,8 @@ export default {
|
||||
|
||||
osType(neu, old) {
|
||||
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();
|
||||
},
|
||||
|
||||
|
||||
@@ -69,16 +69,15 @@ export default class SRIOVDevice extends SteveModel {
|
||||
return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0;
|
||||
}
|
||||
|
||||
async enableDevice() {
|
||||
try {
|
||||
this.spec.enabled = true;
|
||||
await this.save();
|
||||
} catch (err) {
|
||||
this.$dispatch('growl/fromError', {
|
||||
title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }),
|
||||
err,
|
||||
}, { root: true });
|
||||
}
|
||||
get isTimesliced() {
|
||||
return this.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true';
|
||||
}
|
||||
|
||||
enableDevice() {
|
||||
this.$dispatch('promptModal', {
|
||||
resources: [this],
|
||||
component: 'EnableSriovGpuDevice'
|
||||
});
|
||||
}
|
||||
|
||||
async disableDevice() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,10 @@ export default class HciPv extends HarvesterResource {
|
||||
return allVMs.find(findAttachVM);
|
||||
}
|
||||
|
||||
get attachVMName() {
|
||||
return this.attachVM?.nameDisplay || this.attachVM?.metadata?.name || '';
|
||||
}
|
||||
|
||||
get isAvailable() {
|
||||
const unAvailable = ['Resizing', 'Not Ready'];
|
||||
|
||||
|
||||
@@ -123,7 +123,12 @@ const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims'];
|
||||
|
||||
export default class VirtVm extends HarvesterResource {
|
||||
get availableActions() {
|
||||
const out = super._availableActions;
|
||||
let out = super._availableActions;
|
||||
|
||||
if (this.isCloneBackendStorageCloning || this.isCloneBackendStorageFailed) {
|
||||
out = out.filter(({ action }) => action !== 'goToClone');
|
||||
}
|
||||
|
||||
const clone = out.find((action) => action.action === 'goToClone');
|
||||
|
||||
if (clone) {
|
||||
@@ -179,11 +184,12 @@ export default class VirtVm extends HarvesterResource {
|
||||
label: this.t('harvester.action.softreboot')
|
||||
},
|
||||
{
|
||||
action: 'startVM',
|
||||
enabled: !!this.actions?.start,
|
||||
icon: 'icon icon-play',
|
||||
label: this.t('harvester.action.start'),
|
||||
bulkable: true
|
||||
action: 'startVM',
|
||||
enabled: !!this.actions?.start,
|
||||
icon: 'icon icon-play',
|
||||
label: this.t('harvester.action.start'),
|
||||
bulkable: true,
|
||||
bulkAction: 'startVM'
|
||||
},
|
||||
{
|
||||
action: 'backupVM',
|
||||
@@ -373,10 +379,6 @@ export default class VirtVm extends HarvesterResource {
|
||||
this.metadata.annotations[HCI_ANNOTATIONS.VOLUME_CLAIM_TEMPLATE] = JSON.stringify(deleteDataSource);
|
||||
}
|
||||
|
||||
altRestartVM() {
|
||||
this.doActionGrowl('restart', {});
|
||||
}
|
||||
|
||||
restartVM(resources = this) {
|
||||
this.$dispatch('promptModal', {
|
||||
resources,
|
||||
@@ -548,16 +550,38 @@ export default class VirtVm extends HarvesterResource {
|
||||
});
|
||||
}
|
||||
|
||||
altStopVM() {
|
||||
this.doActionGrowl('stop', {});
|
||||
async altRestartVM() {
|
||||
await this.doActionGrowl('restart', {});
|
||||
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
|
||||
}
|
||||
|
||||
forceStop() {
|
||||
this.doActionGrowl('forceStop', {});
|
||||
async altStopVM() {
|
||||
await this.doActionGrowl('stop', {});
|
||||
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
|
||||
}
|
||||
|
||||
startVM() {
|
||||
this.doActionGrowl('start', {});
|
||||
async forceStop() {
|
||||
await this.doActionGrowl('forceStop', {});
|
||||
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
|
||||
}
|
||||
|
||||
async startVM(resources = this) {
|
||||
const list = Array.isArray(resources) ? resources : [resources];
|
||||
|
||||
for (const r of list) {
|
||||
await r.doActionGrowl('start', {});
|
||||
}
|
||||
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
|
||||
}
|
||||
|
||||
async download() {
|
||||
await super.download();
|
||||
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
|
||||
}
|
||||
|
||||
async downloadBulk(items) {
|
||||
await super.downloadBulk(items);
|
||||
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
|
||||
}
|
||||
|
||||
migrateVM(resources = this) {
|
||||
@@ -770,6 +794,18 @@ export default class VirtVm extends HarvesterResource {
|
||||
return this.volumes.filter((volume) => volume?.isLonghornV2);
|
||||
}
|
||||
|
||||
get cloneBackendStorageStatus() {
|
||||
return this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS]?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
get isCloneBackendStorageCloning() {
|
||||
return this.cloneBackendStorageStatus === 'cloning';
|
||||
}
|
||||
|
||||
get isCloneBackendStorageFailed() {
|
||||
return this.cloneBackendStorageStatus === 'failed';
|
||||
}
|
||||
|
||||
get encryptedVolumeType() {
|
||||
if (!this.volumes || this.volumes.length === 0) {
|
||||
return 'none';
|
||||
@@ -825,13 +861,21 @@ export default class VirtVm extends HarvesterResource {
|
||||
!this.isVMExpectedRunning &&
|
||||
this.isVMCreated &&
|
||||
this.vmi?.status?.phase === VMIPhase.Pending
|
||||
) || (this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS] === 'cloning')) {
|
||||
) || this.isCloneBackendStorageCloning) {
|
||||
return { status: VMIPhase.Pending };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get isCloneFailed() {
|
||||
if (this.isCloneBackendStorageFailed) {
|
||||
return { status: VMIPhase.Failed };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get isStopping() {
|
||||
if (this &&
|
||||
!this.isVMExpectedRunning &&
|
||||
@@ -988,6 +1032,7 @@ export default class VirtVm extends HarvesterResource {
|
||||
this.isUnschedulable?.status ||
|
||||
this.isPaused?.status ||
|
||||
this.isVMError?.status ||
|
||||
this.isCloneFailed?.status ||
|
||||
this.isPending?.status ||
|
||||
this.isStopping?.status ||
|
||||
this.isOff?.status ||
|
||||
@@ -995,7 +1040,7 @@ export default class VirtVm extends HarvesterResource {
|
||||
this.isRunning?.status ||
|
||||
this.isNotReady?.status ||
|
||||
this.isStarting?.status ||
|
||||
this.isWaitingForVMI?.state ||
|
||||
this.isWaitingForVMI?.status ||
|
||||
this.otherState?.status;
|
||||
|
||||
return state;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "harvester",
|
||||
"description": "Rancher UI Extension for Harvester",
|
||||
"version": "1.9.0-dev",
|
||||
"version": "1.10.0-dev",
|
||||
"private": false,
|
||||
"rancher": {
|
||||
"annotations": {
|
||||
"catalog.cattle.io/display-name": "Harvester",
|
||||
"catalog.cattle.io/kube-version": ">= 1.16.0-0",
|
||||
"catalog.cattle.io/rancher-version": ">= 2.14.0-0",
|
||||
"catalog.cattle.io/rancher-version": ">= 2.15.0-0",
|
||||
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script>
|
||||
import Loading from '@shell/components/Loading';
|
||||
import { HCI } from '../../../../../types';
|
||||
import SerialConsole from '../../../../../components/SerialConsole';
|
||||
|
||||
export default {
|
||||
components: { SerialConsole, Loading },
|
||||
components: { SerialConsole },
|
||||
|
||||
async fetch() {
|
||||
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
|
||||
@@ -38,16 +37,44 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="$fetchState.pending" />
|
||||
<SerialConsole
|
||||
v-else
|
||||
ref="serialConsole"
|
||||
v-model:value="vmi"
|
||||
/>
|
||||
<div class="serial-console-page">
|
||||
<SerialConsole
|
||||
ref="serialConsole"
|
||||
:value="vmi || {}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
body, #__nuxt, #__layout, main {
|
||||
<style lang="scss">
|
||||
html,
|
||||
body,
|
||||
#__nuxt,
|
||||
#__layout,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.serial-console-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
background: var(--body-bg);
|
||||
color: var(--terminal-text);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 6px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.harvester-shell-container {
|
||||
height: 100%;
|
||||
background: var(--terminal-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -123,6 +123,7 @@ export default {
|
||||
this.value?.[0]?.currentRouter().push(goTo);
|
||||
}
|
||||
this.close();
|
||||
this.$store.commit('action-menu/togglePromptModal', { performCallback: true, clearTableSelection: true });
|
||||
}).catch((err) => {
|
||||
this.$emit('errors', err);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
|
||||
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
||||
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.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 = [
|
||||
{
|
||||
@@ -83,6 +86,18 @@ const routes = [
|
||||
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
||||
path: `/:product/c/:cluster/projectsnamespaces`,
|
||||
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`,
|
||||
path: `/:product/c/:cluster/:resource`,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ClusterNotFoundError } from '@shell/utils/error';
|
||||
import { SETTING } from '@shell/config/settings';
|
||||
import { COUNT, NAMESPACE, MANAGEMENT } from '@shell/config/types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { DEV } from '@shell/store/prefs';
|
||||
import { DEV, NAMESPACE_FILTERS } from '@shell/store/prefs';
|
||||
import { createNamespaceFilterKeyWithId } from '@shell/utils/namespace-filter';
|
||||
import { HCI } from '../../types';
|
||||
|
||||
export default {
|
||||
@@ -121,8 +122,11 @@ export default {
|
||||
|
||||
await dispatch('cleanNamespaces', null, { root: true });
|
||||
|
||||
const namespaceFilterKey = createNamespaceFilterKeyWithId(id, 'harvester');
|
||||
const savedFilters = rootGetters['prefs/get'](NAMESPACE_FILTERS)?.[namespaceFilterKey];
|
||||
|
||||
commit('updateNamespaces', {
|
||||
filters: [],
|
||||
filters: savedFilters || [],
|
||||
all: getters.filterNamespace(),
|
||||
getters
|
||||
}, { root: true });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user