mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-18 21:59:15 +00:00
Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f32a9ade5 | ||
|
|
3f39d561be | ||
|
|
7c84269ed6 | ||
|
|
f63be73b11 | ||
|
|
fabc1cec21 | ||
|
|
ed2b6f2551 | ||
|
|
72282ae50e | ||
|
|
8fba4e7050 | ||
|
|
fbd4a1108f | ||
|
|
823a88c85b | ||
|
|
3a2c6e3a3d | ||
|
|
be6e9b5f06 | ||
|
|
a88bed9eb4 | ||
|
|
f97f14c811 | ||
|
|
f8a29a1c54 | ||
|
|
9a935434c2 | ||
|
|
afe632b723 | ||
|
|
d050f21f9a | ||
|
|
b87f367583 | ||
|
|
92aa8ec603 | ||
|
|
e013534373 | ||
|
|
d23c3d3b72 | ||
|
|
719c33fa12 | ||
|
|
a6bd7ec00e | ||
|
|
ac26fe2f0e | ||
|
|
790cade04d | ||
|
|
2903e86c02 | ||
|
|
2d747e435a | ||
|
|
eadda2a18b | ||
|
|
a178302f73 | ||
|
|
d376bfb3a5 | ||
|
|
7a84e08f99 | ||
|
|
3d39626b93 | ||
|
|
1c3b40b631 | ||
|
|
87baf75301 | ||
|
|
a10d651827 | ||
|
|
11ff7c8a7c | ||
|
|
f6dffe9816 | ||
|
|
b813bc578e | ||
|
|
7c3b2730ab | ||
|
|
429d9e18ad | ||
|
|
dc166d8fc9 | ||
|
|
3ff4534294 | ||
|
|
d4223ad2ae | ||
|
|
44297ada16 | ||
|
|
aea53fdd65 | ||
|
|
9640c87947 | ||
|
|
2cefd7cb78 | ||
|
|
fb6ffa3c0f | ||
|
|
e0436bff28 | ||
|
|
219f39ae52 | ||
|
|
41cf44d231 | ||
|
|
7d09cb7142 | ||
|
|
560643d299 | ||
|
|
c3bb0ea4be | ||
|
|
3c37f04ef6 | ||
|
|
d98db6ba49 | ||
|
|
531ed03796 | ||
|
|
59f260eab8 | ||
|
|
495e80267d | ||
|
|
479c298f4a | ||
|
|
3fdde25d17 | ||
|
|
5090d8ecad | ||
|
|
b9334bafb7 | ||
|
|
9a675da756 | ||
|
|
8c0c36e022 | ||
|
|
140a85f6a8 | ||
|
|
e62ae7e1d8 | ||
|
|
d03cff645b | ||
|
|
34dfe4027e | ||
|
|
135d520b8d | ||
|
|
62a19ee3fb | ||
|
|
64f0f5fb87 | ||
|
|
93a692ff0c | ||
|
|
5e3f12de35 | ||
|
|
f115261889 | ||
|
|
5985913f5e | ||
|
|
661ab995f6 | ||
|
|
d3f63df883 | ||
|
|
44ef9195eb | ||
|
|
b18aebbbd4 | ||
|
|
d2db23d69a | ||
|
|
55918232a6 | ||
|
|
ce2adbdc3b | ||
|
|
ae65037083 | ||
|
|
b4ea7c8f98 | ||
|
|
836b04f222 | ||
|
|
0908c7fc6b | ||
|
|
b34d618c7c | ||
|
|
8945b9f158 | ||
|
|
c5af2b576b | ||
|
|
45872cef3b | ||
|
|
3e5ee422ce | ||
|
|
75202a9e55 | ||
|
|
5f5ce291fb | ||
|
|
a27e38fc81 | ||
|
|
89e1484884 | ||
|
|
eacca055c7 | ||
|
|
56c1738055 | ||
|
|
8f4b335cae | ||
|
|
91232beffc | ||
|
|
09e8946cc3 | ||
|
|
e70c684382 | ||
|
|
1446aac168 | ||
|
|
8e65274b0c | ||
|
|
e5432210b9 | ||
|
|
af52df0ba0 | ||
|
|
e5a1929ac5 | ||
|
|
9de065a5c9 | ||
|
|
cd933bdbf8 | ||
|
|
5fe642a42d | ||
|
|
18c66083ab | ||
|
|
d291a35754 | ||
|
|
5cc8b4c301 | ||
|
|
032700293c | ||
|
|
8cb793e7ad | ||
|
|
2c45b71d1f | ||
|
|
5a301dcf55 | ||
|
|
1a92265d03 | ||
|
|
8f65915bad | ||
|
|
67bb6dfbd5 | ||
|
|
e74428d951 | ||
|
|
629f7df6b9 | ||
|
|
b7119d5c4c | ||
|
|
6fdd1e3954 | ||
|
|
7d0f33f31d | ||
|
|
9ce95daf76 | ||
|
|
ce72232bc3 | ||
|
|
afc0e0f531 | ||
|
|
e941cc9a90 | ||
|
|
9961523d08 | ||
|
|
c4d1018388 | ||
|
|
be64329776 | ||
|
|
35411ed87a | ||
|
|
15eb0f07f7 | ||
|
|
fb78f24fdd | ||
|
|
81ad827829 | ||
|
|
6dd9b33336 | ||
|
|
1f9e9b336b | ||
|
|
c5b4f6cd1e | ||
|
|
4ce35ce075 | ||
|
|
27c26bd782 | ||
|
|
9d698b1230 | ||
|
|
566e79eda5 | ||
|
|
42ddcfc1fe | ||
|
|
ad3decf71f | ||
|
|
8083a41df0 | ||
|
|
46b860260a | ||
|
|
62801b3b13 | ||
|
|
161e3bbd97 | ||
|
|
97e93dba0b | ||
|
|
9a8a709e56 | ||
|
|
d1949641a7 | ||
|
|
9c9f59c939 | ||
|
|
ccc14c7fb9 | ||
|
|
2ba471907e | ||
|
|
5aea476f64 | ||
|
|
519c7d9f1f | ||
|
|
a9c392c13f | ||
|
|
888ec7a50f | ||
|
|
a2486a7d38 | ||
|
|
df3d249923 | ||
|
|
23344e0c07 |
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
@ -1,14 +1,23 @@
|
||||
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
|
||||
### Summary
|
||||
<!-- 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 -->
|
||||
|
||||
|
||||
|
||||
16
.github/renovate.json
vendored
16
.github/renovate.json
vendored
@ -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
|
||||
@ -39,11 +46,12 @@
|
||||
"reviewers": ["a110605", "houhoucoop"]
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["patch", "digest"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"],
|
||||
"automerge": false,
|
||||
"minimumReleaseAge": "7 days",
|
||||
"groupName": "patch digest dependencies",
|
||||
"labels": ["patch-update", "automerge"]
|
||||
"groupName": "patch dependencies",
|
||||
"labels": ["patch-update"],
|
||||
"reviewers": ["a110605", "houhoucoop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
33
.github/workflows/build-standalone-on-merge.yaml
vendored
33
.github/workflows/build-standalone-on-merge.yaml
vendored
@ -1,6 +1,7 @@
|
||||
name: Build Standalone on PR Merge
|
||||
|
||||
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"
|
||||
|
||||
40
AGENTS.md
40
AGENTS.md
@ -1,4 +1,4 @@
|
||||
> This `./AGENTS.md` file is generated by running `yarn agents:generate`.
|
||||
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
|
||||
|
||||
|
||||
# Project Overview
|
||||
@ -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]
|
||||
|
||||
|
||||
22
README.md
22
README.md
@ -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': [2, 'always', 100],
|
||||
'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
|
||||
|
||||
12
package.json
12
package.json
@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "harvester-ui-extension",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.0-dev",
|
||||
"private": false,
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-class-static-block": "7.29.7",
|
||||
"@rancher/shell": "3.0.12-rc.1",
|
||||
"@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,6 +15,12 @@
|
||||
"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",
|
||||
@ -29,7 +35,7 @@
|
||||
"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",
|
||||
|
||||
@ -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>
|
||||
|
||||
186
pkg/harvester/components/HarvesterNodeSelector.vue
Normal file
186
pkg/harvester/components/HarvesterNodeSelector.vue
Normal file
@ -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;
|
||||
|
||||
59
pkg/harvester/components/MappingsCell.vue
Normal file
59
pkg/harvester/components/MappingsCell.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
networkEntries: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
storageEntries: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mappings-cell">
|
||||
<div
|
||||
v-for="(entry, idx) in networkEntries"
|
||||
:key="'net-' + idx"
|
||||
class="mapping-entry"
|
||||
>
|
||||
<i
|
||||
class="icon icon-network"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ entry }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(entry, idx) in storageEntries"
|
||||
:key="'stor-' + idx"
|
||||
class="mapping-entry"
|
||||
>
|
||||
<i
|
||||
class="icon icon-datastore"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ entry }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mappings-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mapping-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -122,7 +122,7 @@ export default {
|
||||
return this.$store.getters['currentCluster'].isLocal;
|
||||
},
|
||||
canEditClusterMembers() {
|
||||
return this.normanClusterRTBSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
|
||||
return this.schema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,14 +55,21 @@ 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 }`;
|
||||
|
||||
window.open(
|
||||
url,
|
||||
'_blank',
|
||||
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
|
||||
);
|
||||
// Defer so v-select can finish closing the dropdown before the popup steals focus
|
||||
this.$nextTick(() => {
|
||||
window.open(
|
||||
url,
|
||||
'_blank',
|
||||
`toolbars=0,width=${ popupWidth },height=${ popupHeight },left=${ popupLeft },top=${ popupTop },noreferrer`
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
isEmpty(o) {
|
||||
|
||||
@ -0,0 +1,196 @@
|
||||
<script>
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { NAMESPACE } from '@shell/config/types';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterClusterPodSecurityStandard',
|
||||
|
||||
components: {
|
||||
RadioGroup,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView],
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
|
||||
},
|
||||
|
||||
data() {
|
||||
let enabled = false;
|
||||
let whitelistedNamespaces = [];
|
||||
let privilegedNamespaces = [];
|
||||
let restrictedNamespaces = [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(this.value.value || this.value.default || '{}');
|
||||
|
||||
enabled = !!parsed.enabled;
|
||||
whitelistedNamespaces = parsed.whitelistedNamespacesList ? parsed.whitelistedNamespacesList.split(',') : [];
|
||||
privilegedNamespaces = parsed.privilegedNamespacesList ? parsed.privilegedNamespacesList.split(',') : [];
|
||||
restrictedNamespaces = parsed.restrictedNamespacesList ? parsed.restrictedNamespacesList.split(',') : [];
|
||||
} catch (e) {
|
||||
enabled = false;
|
||||
whitelistedNamespaces = [];
|
||||
privilegedNamespaces = [];
|
||||
restrictedNamespaces = [];
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
whitelistedNamespaces,
|
||||
privilegedNamespaces,
|
||||
restrictedNamespaces,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
enabledOptions() {
|
||||
return [
|
||||
{ label: this.t('generic.enabled'), value: true },
|
||||
{ label: this.t('generic.disabled'), value: false },
|
||||
];
|
||||
},
|
||||
|
||||
allNamespaces() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
return this.$store.getters[`${ inStore }/all`](NAMESPACE).filter((ns) => !ns.isSystem).map((ns) => ns.id);
|
||||
},
|
||||
|
||||
whitelistedOptions() {
|
||||
const excluded = new Set([...this.privilegedNamespaces, ...this.restrictedNamespaces]);
|
||||
|
||||
return this.allNamespaces.filter((ns) => !excluded.has(ns));
|
||||
},
|
||||
|
||||
privilegedOptions() {
|
||||
const excluded = new Set([...this.whitelistedNamespaces, ...this.restrictedNamespaces]);
|
||||
|
||||
return this.allNamespaces.filter((ns) => !excluded.has(ns));
|
||||
},
|
||||
|
||||
restrictedOptions() {
|
||||
const excluded = new Set([...this.whitelistedNamespaces, ...this.privilegedNamespaces]);
|
||||
|
||||
return this.allNamespaces.filter((ns) => !excluded.has(ns));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
useDefault() {
|
||||
try {
|
||||
const parsed = JSON.parse(this.value.default || '{}');
|
||||
|
||||
this.enabled = !!parsed.enabled;
|
||||
this.whitelistedNamespaces = parsed.whitelistedNamespacesList ? parsed.whitelistedNamespacesList.split(',') : [];
|
||||
this.privilegedNamespaces = parsed.privilegedNamespacesList ? parsed.privilegedNamespacesList.split(',') : [];
|
||||
this.restrictedNamespaces = parsed.restrictedNamespacesList ? parsed.restrictedNamespacesList.split(',') : [];
|
||||
} catch (e) {
|
||||
this.enabled = false;
|
||||
this.whitelistedNamespaces = [];
|
||||
this.privilegedNamespaces = [];
|
||||
this.restrictedNamespaces = [];
|
||||
}
|
||||
this.save();
|
||||
},
|
||||
|
||||
onUpdateEnabled() {
|
||||
if (!this.enabled) {
|
||||
this.whitelistedNamespaces = [];
|
||||
this.privilegedNamespaces = [];
|
||||
this.restrictedNamespaces = [];
|
||||
}
|
||||
this.save();
|
||||
},
|
||||
|
||||
updateWhitelisted(selected) {
|
||||
this.whitelistedNamespaces = selected;
|
||||
this.save();
|
||||
},
|
||||
|
||||
updatePrivileged(selected) {
|
||||
this.privilegedNamespaces = selected;
|
||||
this.save();
|
||||
},
|
||||
|
||||
updateRestricted(selected) {
|
||||
this.restrictedNamespaces = selected;
|
||||
this.save();
|
||||
},
|
||||
|
||||
save() {
|
||||
this.value.value = JSON.stringify({
|
||||
enabled: this.enabled,
|
||||
whitelistedNamespacesList: this.whitelistedNamespaces.join(','),
|
||||
privilegedNamespacesList: this.privilegedNamespaces.join(','),
|
||||
restrictedNamespacesList: this.restrictedNamespaces.join(','),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model:value="enabled"
|
||||
name="enabled"
|
||||
:options="enabledOptions"
|
||||
@update:value="onUpdateEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="enabled">
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="whitelistedNamespaces"
|
||||
:label="t('harvester.setting.clusterPodSecurityStandard.whitelistedNamespaces.label')"
|
||||
:options="whitelistedOptions"
|
||||
:multiple="true"
|
||||
:mode="mode"
|
||||
@update:value="updateWhitelisted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="privilegedNamespaces"
|
||||
:label="t('harvester.setting.clusterPodSecurityStandard.privilegedNamespaces.label')"
|
||||
:options="privilegedOptions"
|
||||
:multiple="true"
|
||||
:mode="mode"
|
||||
@update:value="updatePrivileged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<LabeledSelect
|
||||
v-model:value="restrictedNamespaces"
|
||||
:label="t('harvester.setting.clusterPodSecurityStandard.restrictedNamespaces.label')"
|
||||
:options="restrictedOptions"
|
||||
:multiple="true"
|
||||
:mode="mode"
|
||||
@update:value="updateRestricted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@ -64,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"
|
||||
|
||||
649
pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue
Normal file
649
pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue
Normal file
@ -0,0 +1,649 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, toRefs } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { HCI } from '../../types';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
|
||||
import { isInternalStorageClass } from '../../utils/storage-class';
|
||||
import MappingColumn from './MappingColumn.vue';
|
||||
import StorageDefaultsModal from './StorageDefaultsModal.vue';
|
||||
|
||||
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
|
||||
const DEFAULT_ACCESS_MODE = ACCESS_MODE?.READ_WRITE_MANY ?? 'ReadWriteMany';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
provider: { type: Object, default: null },
|
||||
selectedVms: { type: Array, default: () => [] },
|
||||
stepData: { type: Object, required: true },
|
||||
useAllProviderData: { type: Boolean, default: false },
|
||||
existingNetworkMap: { type: Object, default: null },
|
||||
existingStorageMap: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['ready']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const vms = ref([]);
|
||||
const harvesterNetworks = ref([]);
|
||||
const storageClasses = ref([]);
|
||||
const allNetworkMaps = ref([]);
|
||||
const allStorageMaps = ref([]);
|
||||
const errors = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
// Storage defaults edit modal state.
|
||||
const showStorageDefaultsModal = ref(false);
|
||||
const editingStorageEntry = ref(null);
|
||||
|
||||
const { networkEntries, storageEntries } = toRefs(props.stepData);
|
||||
|
||||
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||
|
||||
const harvesterNetworkOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
|
||||
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
|
||||
];
|
||||
|
||||
harvesterNetworks.value.forEach((net) => {
|
||||
const ns = net.metadata?.namespace || '';
|
||||
const name = net.metadata?.name || net.id;
|
||||
const fullName = ns ? `${ ns }/${ name }` : name;
|
||||
|
||||
options.push({ label: fullName, value: fullName });
|
||||
});
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const storageClassOptions = computed(() => {
|
||||
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => {
|
||||
const value = sc.name;
|
||||
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value;
|
||||
let disabled = false;
|
||||
|
||||
if (isInternalStorageClass(value)) {
|
||||
label += ` (${ t('harvester.storage.internal.label') })`;
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
|
||||
if (!options.find((o) => o.value === 'harvester-longhorn')) {
|
||||
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const applyNetworkMapTargets = (mapSpec) => {
|
||||
if (!mapSpec) {
|
||||
return;
|
||||
}
|
||||
|
||||
networkEntries.value.forEach((entry) => {
|
||||
const match = mapSpec.find(
|
||||
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||
);
|
||||
|
||||
if (match?.destination) {
|
||||
const dest = match.destination;
|
||||
|
||||
if (dest.type === 'pod') {
|
||||
entry.target = 'pod';
|
||||
} else if (dest.type === 'ignored') {
|
||||
entry.target = 'ignored';
|
||||
} else if (dest.type === 'multus' && dest.name) {
|
||||
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
|
||||
if (!mapSpec) {
|
||||
return;
|
||||
}
|
||||
|
||||
storageEntries.value.forEach((entry) => {
|
||||
const match = mapSpec.find(
|
||||
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||
);
|
||||
|
||||
if (match?.destination?.storageClass) {
|
||||
entry.target = match.destination.storageClass;
|
||||
|
||||
if (captureInherited) {
|
||||
entry.inheritedFromProvider = true;
|
||||
}
|
||||
|
||||
if (match.destination.volumeMode) {
|
||||
entry.volumeMode = match.destination.volumeMode;
|
||||
|
||||
if (captureInherited) {
|
||||
entry.inheritedVolumeMode = match.destination.volumeMode;
|
||||
}
|
||||
}
|
||||
|
||||
if (match.destination.accessMode) {
|
||||
entry.accessModes = [match.destination.accessMode];
|
||||
|
||||
if (captureInherited) {
|
||||
entry.inheritedAccessModes = [match.destination.accessMode];
|
||||
}
|
||||
}
|
||||
|
||||
if (markOverridden) {
|
||||
entry.overridden = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyDefaultNetworkMap = () => {
|
||||
const defaultMap = allNetworkMaps.value.find(
|
||||
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
|
||||
);
|
||||
|
||||
applyNetworkMapTargets(defaultMap?.spec?.map);
|
||||
};
|
||||
|
||||
const applyDefaultStorageMap = () => {
|
||||
const defaultMap = allStorageMaps.value.find(
|
||||
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
|
||||
);
|
||||
|
||||
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
|
||||
};
|
||||
|
||||
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
|
||||
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
|
||||
|
||||
// The "Inherited from provider" hint only appears in the migration plan wizard
|
||||
// (where mappings inherit from the provider default), not on the provider creation page.
|
||||
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
|
||||
|
||||
const openStorageDefaults = (entry) => {
|
||||
editingStorageEntry.value = entry;
|
||||
showStorageDefaultsModal.value = true;
|
||||
};
|
||||
|
||||
const closeStorageDefaults = () => {
|
||||
showStorageDefaultsModal.value = false;
|
||||
editingStorageEntry.value = null;
|
||||
};
|
||||
|
||||
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
|
||||
if (editingStorageEntry.value) {
|
||||
editingStorageEntry.value.volumeMode = volumeMode;
|
||||
editingStorageEntry.value.accessModes = accessModes;
|
||||
|
||||
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
|
||||
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
|
||||
const selectedAccessMode = accessModes?.[0];
|
||||
|
||||
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (props.useAllProviderData) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
|
||||
allNetworksMapped.value && allStorageMapped.value;
|
||||
});
|
||||
|
||||
watch(canSave, (val) => {
|
||||
emit('ready', val);
|
||||
});
|
||||
|
||||
// After restore, check if ready
|
||||
if (canSave.value) {
|
||||
emit('ready', true);
|
||||
}
|
||||
|
||||
const buildNetworkEntries = () => {
|
||||
const networkMap = {};
|
||||
|
||||
vms.value.forEach((vm) => {
|
||||
const vmName = vm.name || vm.id;
|
||||
|
||||
if (vm.networks && vm.networks.length > 0) {
|
||||
vm.networks.forEach((net) => {
|
||||
const netKey = net.id || net.name || 'default';
|
||||
|
||||
if (!networkMap[netKey]) {
|
||||
networkMap[netKey] = {
|
||||
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||
id: net.id || '',
|
||||
vlanId: net.vlanId || '',
|
||||
target: '',
|
||||
usedBy: [],
|
||||
_key: `net-${ netKey }`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!networkMap[netKey].usedBy.includes(vmName)) {
|
||||
networkMap[netKey].usedBy.push(vmName);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
networkEntries.value = Object.values(networkMap);
|
||||
};
|
||||
|
||||
const buildStorageEntries = () => {
|
||||
const datastoreMap = {};
|
||||
|
||||
vms.value.forEach((vm) => {
|
||||
const vmName = vm.name || vm.id;
|
||||
|
||||
if (vm.disks && vm.disks.length > 0) {
|
||||
vm.disks.forEach((disk) => {
|
||||
const ds = disk.datastore;
|
||||
|
||||
if (ds && ds.id) {
|
||||
if (!datastoreMap[ds.id]) {
|
||||
datastoreMap[ds.id] = {
|
||||
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||
id: ds.id,
|
||||
type: ds.type || '',
|
||||
capacity: 0,
|
||||
target: '',
|
||||
volumeMode: DEFAULT_VOLUME_MODE,
|
||||
accessModes: [DEFAULT_ACCESS_MODE],
|
||||
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
|
||||
inheritedFromProvider: false,
|
||||
overridden: false,
|
||||
usedBy: [],
|
||||
_key: `stor-${ ds.id }`,
|
||||
};
|
||||
}
|
||||
|
||||
datastoreMap[ds.id].capacity += disk.capacity || 0;
|
||||
|
||||
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
|
||||
datastoreMap[ds.id].usedBy.push(vmName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
storageEntries.value = Object.values(datastoreMap);
|
||||
};
|
||||
|
||||
const buildNetworkEntriesFromProvider = (networksData) => {
|
||||
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
|
||||
name: net.name || net.id,
|
||||
id: net.id || '',
|
||||
vlanId: net.vlanId || '',
|
||||
target: '',
|
||||
usedBy: [],
|
||||
_key: `net-${ net.id || net.name }`,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildStorageEntriesFromProvider = (datastoresData) => {
|
||||
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
|
||||
name: ds.name || ds.id,
|
||||
id: ds.id || '',
|
||||
type: ds.type || '',
|
||||
capacity: ds.capacity || 0,
|
||||
target: '',
|
||||
volumeMode: DEFAULT_VOLUME_MODE,
|
||||
accessModes: [DEFAULT_ACCESS_MODE],
|
||||
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
|
||||
inheritedFromProvider: false,
|
||||
overridden: false,
|
||||
usedBy: [],
|
||||
_key: `stor-${ ds.id || ds.name }`,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatStorageDetail = (entry) => {
|
||||
const parts = [];
|
||||
|
||||
if (entry.type) {
|
||||
parts.push(entry.type);
|
||||
}
|
||||
|
||||
if (entry.capacity) {
|
||||
const gb = entry.capacity / (1024 * 1024 * 1024);
|
||||
|
||||
if (gb >= 1024) {
|
||||
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
|
||||
} else {
|
||||
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const buildNetworkMapSpec = (providerRef) => ({
|
||||
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
|
||||
provider: providerRef,
|
||||
});
|
||||
|
||||
const buildStorageMapSpec = (providerRef) => ({
|
||||
map: buildStorageMapEntries(storageEntries.value),
|
||||
provider: providerRef,
|
||||
});
|
||||
|
||||
const saveAndReturn = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
const providerRef = {
|
||||
source: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: props.providerName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
destination: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: 'host',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
// Update existing maps in place (edit mode)
|
||||
if (props.existingNetworkMap && props.existingStorageMap) {
|
||||
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
|
||||
await props.existingNetworkMap.save();
|
||||
|
||||
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
|
||||
await props.existingStorageMap.save();
|
||||
|
||||
return {
|
||||
networkMapName: props.existingNetworkMap.metadata.name,
|
||||
storageMapName: props.existingStorageMap.metadata.name,
|
||||
};
|
||||
}
|
||||
|
||||
const providerOwnerRef = props.provider ? [{
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: props.provider.metadata.name,
|
||||
uid: props.provider.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
}] : [];
|
||||
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: {
|
||||
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: buildNetworkMapSpec(providerRef),
|
||||
});
|
||||
|
||||
await networkMap.save();
|
||||
|
||||
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: {
|
||||
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: buildStorageMapSpec(providerRef),
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
|
||||
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
|
||||
};
|
||||
|
||||
defineExpose({ saveMappings: saveAndReturn });
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
try {
|
||||
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
|
||||
} catch (e) {
|
||||
harvesterNetworks.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
|
||||
} catch (e) {
|
||||
storageClasses.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||
} catch (e) {
|
||||
allNetworkMaps.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||
} catch (e) {
|
||||
allStorageMaps.value = [];
|
||||
}
|
||||
|
||||
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
|
||||
try {
|
||||
const providerUid = props.provider?.metadata?.uid;
|
||||
const providerType = props.provider?.spec?.type || 'vsphere';
|
||||
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||
);
|
||||
|
||||
const [networksData, datastoresData] = await Promise.all([
|
||||
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
|
||||
]);
|
||||
|
||||
if (props.useAllProviderData) {
|
||||
if (networkEntries.value.length === 0) {
|
||||
buildNetworkEntriesFromProvider(networksData);
|
||||
}
|
||||
if (storageEntries.value.length === 0) {
|
||||
buildStorageEntriesFromProvider(datastoresData);
|
||||
}
|
||||
} else {
|
||||
vms.value = props.selectedVms;
|
||||
|
||||
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
|
||||
map[n.id] = n.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||
map[d.id] = { name: d.name, type: d.type || '' };
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
vms.value = vms.value.map((vm) => {
|
||||
const resolved = { ...vm };
|
||||
|
||||
if (resolved.networks) {
|
||||
resolved.networks = resolved.networks.map((n) => ({
|
||||
...n,
|
||||
name: n.name || networkNameMap[n.id] || n.id,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resolved.disks) {
|
||||
resolved.disks = resolved.disks.map((d) => {
|
||||
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
|
||||
|
||||
return {
|
||||
...d,
|
||||
datastore: d.datastore ? {
|
||||
...d.datastore,
|
||||
name: d.datastore.name || dsInfo?.name || d.datastore.id,
|
||||
type: dsInfo?.type || '',
|
||||
} : d.datastore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
|
||||
if (networkEntries.value.length === 0) {
|
||||
buildNetworkEntries();
|
||||
}
|
||||
if (storageEntries.value.length === 0) {
|
||||
buildStorageEntries();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
|
||||
}
|
||||
}
|
||||
|
||||
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
|
||||
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
|
||||
|
||||
if (!hasExistingNetworkTargets) {
|
||||
if (props.existingNetworkMap?.spec?.map) {
|
||||
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
|
||||
} else {
|
||||
applyDefaultNetworkMap();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExistingStorageTargets) {
|
||||
if (props.existingStorageMap?.spec?.map) {
|
||||
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
|
||||
} else {
|
||||
applyDefaultStorageMap();
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="configure-mappings"
|
||||
>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
<p class="text-deemphasized line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
|
||||
</p>
|
||||
<div class="mappings-columns">
|
||||
<MappingColumn
|
||||
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
|
||||
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
|
||||
:entries="networkEntries"
|
||||
:options="harvesterNetworkOptions"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
|
||||
:show-used-by="!useAllProviderData"
|
||||
:clearable="useAllProviderData"
|
||||
>
|
||||
<template #source-detail="{ entry }">
|
||||
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
|
||||
</template>
|
||||
</MappingColumn>
|
||||
|
||||
<MappingColumn
|
||||
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
|
||||
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
|
||||
:entries="storageEntries"
|
||||
:options="storageClassOptions"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||
:show-used-by="!useAllProviderData"
|
||||
:clearable="useAllProviderData"
|
||||
:show-volume-settings="true"
|
||||
:inherited-provider-name="inheritedProviderName"
|
||||
@edit-defaults="openStorageDefaults"
|
||||
>
|
||||
<template #source-detail="{ entry }">
|
||||
<span
|
||||
v-if="entry.type || entry.capacity"
|
||||
class="source-detail text-deemphasized"
|
||||
>{{ formatStorageDetail(entry) }}</span>
|
||||
</template>
|
||||
</MappingColumn>
|
||||
</div>
|
||||
|
||||
<StorageDefaultsModal
|
||||
v-if="showStorageDefaultsModal && editingStorageEntry"
|
||||
:storage-class-name="editingStorageEntry.target"
|
||||
:provider-name="providerName"
|
||||
:show-inherited="!!inheritedProviderName"
|
||||
:volume-mode="editingStorageEntry.volumeMode"
|
||||
:access-modes="editingStorageEntry.accessModes"
|
||||
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
|
||||
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
|
||||
@apply="applyStorageDefaults"
|
||||
@close="closeStorageDefaults"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mappings-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
|
||||
gap: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.configure-mappings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
573
pkg/harvester/components/vm-migration/ConfigureProviderStep.vue
Normal file
573
pkg/harvester/components/vm-migration/ConfigureProviderStep.vue
Normal file
@ -0,0 +1,573 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, toRefs } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { Banner } from '@components/Banner';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { SECRET } from '@shell/config/types';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import { decodeSecretValue } from '../../utils/forklift';
|
||||
|
||||
const CREATE_NEW = '__create_new__';
|
||||
|
||||
const props = defineProps({
|
||||
stepData: { type: Object, required: true },
|
||||
createOnly: { type: Boolean, default: false },
|
||||
editMode: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const allProviders = ref([]);
|
||||
const allSecrets = ref([]);
|
||||
const errors = ref([]);
|
||||
const testBtnRef = ref(null);
|
||||
const loading = ref(true);
|
||||
const testing = ref(false);
|
||||
|
||||
const {
|
||||
selectedProvider,
|
||||
providerName,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
skipTlsVerify,
|
||||
vddkInitImage,
|
||||
testPassed,
|
||||
testResult,
|
||||
testError,
|
||||
createdProvider,
|
||||
createdSecret,
|
||||
} = toRefs(props.stepData);
|
||||
|
||||
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
|
||||
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
|
||||
|
||||
const providerOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
|
||||
];
|
||||
|
||||
allProviders.value.forEach((p) => {
|
||||
if (!p.spec?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretRef = p.spec?.secret;
|
||||
|
||||
if (secretRef) {
|
||||
const secret = allSecrets.value.find(
|
||||
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||
);
|
||||
|
||||
if (!secret?.data?.user || !secret?.data?.password) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: p.metadata.name,
|
||||
value: p.metadata.name,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
// When the provider selection changes, populate or clear the fields
|
||||
watch(selectedProvider, (val) => {
|
||||
testPassed.value = false;
|
||||
testResult.value = null;
|
||||
testError.value = null;
|
||||
|
||||
if (val === CREATE_NEW) {
|
||||
providerName.value = '';
|
||||
url.value = '';
|
||||
username.value = '';
|
||||
password.value = '';
|
||||
skipTlsVerify.value = false;
|
||||
vddkInitImage.value = '';
|
||||
createdProvider.value = null;
|
||||
createdSecret.value = null;
|
||||
} else {
|
||||
const provider = allProviders.value.find(
|
||||
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
|
||||
);
|
||||
|
||||
if (provider) {
|
||||
providerName.value = provider.metadata.name;
|
||||
url.value = provider.spec?.url || '';
|
||||
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
|
||||
|
||||
const secretRef = provider.spec?.secret;
|
||||
|
||||
if (secretRef) {
|
||||
const secret = allSecrets.value.find(
|
||||
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||
);
|
||||
|
||||
if (secret?.data) {
|
||||
username.value = decodeSecretValue(secret.data.user);
|
||||
password.value = decodeSecretValue(secret.data.password);
|
||||
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
createdProvider.value = provider;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset test state when any field changes (only for create new)
|
||||
watch([providerName, url, username, password], () => {
|
||||
if (!isExistingProvider.value) {
|
||||
testPassed.value = false;
|
||||
testResult.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Emit ready/complete when testPassed changes
|
||||
watch(testPassed, (val) => {
|
||||
emit('ready', val);
|
||||
if (val) {
|
||||
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Emit form-valid when form validity changes
|
||||
watch(isFormValid, (val) => {
|
||||
emit('form-valid', val);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(testing, (val) => {
|
||||
emit('testing', val);
|
||||
}, { immediate: true });
|
||||
|
||||
const pollProviderReady = async(name) => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
const namespace = FORKLIFT_NAMESPACE;
|
||||
const maxAttempts = 15;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
attempts++;
|
||||
|
||||
const refreshed = await store.dispatch(`${ inStore }/find`, {
|
||||
type: HCI.FORKLIFT_PROVIDER,
|
||||
id: `${ namespace }/${ name }`,
|
||||
opt: { force: true }
|
||||
});
|
||||
|
||||
const conditions = refreshed?.status?.conditions || [];
|
||||
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
|
||||
const readyCondition = conditions.find((c) => c.type === 'Ready');
|
||||
|
||||
if (connectionCondition) {
|
||||
if (connectionCondition.status === 'True') {
|
||||
return { connected: true };
|
||||
}
|
||||
|
||||
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
|
||||
}
|
||||
|
||||
if (readyCondition) {
|
||||
if (readyCondition.status === 'True') {
|
||||
return { connected: true };
|
||||
} else if (readyCondition.status === 'False') {
|
||||
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { connected: false, errorMsg: '' };
|
||||
};
|
||||
|
||||
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
|
||||
if (connected) {
|
||||
testPassed.value = true;
|
||||
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
|
||||
testing.value = false;
|
||||
buttonCb(true);
|
||||
} else {
|
||||
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupCreatedResources = async() => {
|
||||
if (createdProvider.value) {
|
||||
try {
|
||||
await createdProvider.value.remove();
|
||||
} catch (e) {}
|
||||
createdProvider.value = null;
|
||||
createdSecret.value = null;
|
||||
} else if (createdSecret.value) {
|
||||
try {
|
||||
await createdSecret.value.remove();
|
||||
} catch (e) {}
|
||||
createdSecret.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async(buttonCb) => {
|
||||
testResult.value = null;
|
||||
testError.value = null;
|
||||
testing.value = true;
|
||||
|
||||
if (!providerName.value || !url.value || !username.value || !password.value) {
|
||||
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
// Edit mode: update existing provider URL + secret, then poll
|
||||
if (props.editMode && createdProvider.value) {
|
||||
try {
|
||||
createdProvider.value.spec.url = url.value;
|
||||
|
||||
const vddkImage = vddkInitImage.value?.trim();
|
||||
|
||||
if (vddkImage) {
|
||||
createdProvider.value.spec.settings = {
|
||||
...(createdProvider.value.spec.settings || {}),
|
||||
vddkInitImage: vddkImage,
|
||||
};
|
||||
} else if (createdProvider.value.spec.settings) {
|
||||
delete createdProvider.value.spec.settings.vddkInitImage;
|
||||
}
|
||||
|
||||
await createdProvider.value.save();
|
||||
|
||||
const secretRef = createdProvider.value.spec?.secret;
|
||||
|
||||
if (secretRef && createdSecret.value) {
|
||||
createdSecret.value.data = {
|
||||
user: btoa(username.value),
|
||||
password: btoa(password.value),
|
||||
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||
url: btoa(url.value),
|
||||
};
|
||||
await createdSecret.value.save();
|
||||
}
|
||||
|
||||
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||
} catch (err) {
|
||||
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
|
||||
if (isExistingProvider.value) {
|
||||
try {
|
||||
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||
} catch (err) {
|
||||
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For new providers, create provider + secret then poll
|
||||
try {
|
||||
await cleanupCreatedResources();
|
||||
|
||||
const namespace = FORKLIFT_NAMESPACE;
|
||||
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
|
||||
|
||||
const spec = {
|
||||
type: 'vsphere',
|
||||
url: url.value,
|
||||
secret: {
|
||||
name: secretName,
|
||||
namespace,
|
||||
},
|
||||
};
|
||||
|
||||
const vddkImage = vddkInitImage.value?.trim();
|
||||
|
||||
if (vddkImage) {
|
||||
spec.settings = { vddkInitImage: vddkImage };
|
||||
}
|
||||
|
||||
const provider = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_PROVIDER,
|
||||
metadata: {
|
||||
name: providerName.value,
|
||||
namespace,
|
||||
},
|
||||
spec,
|
||||
});
|
||||
|
||||
await provider.save();
|
||||
createdProvider.value = provider;
|
||||
|
||||
const newSecret = await store.dispatch(`${ inStore }/create`, {
|
||||
type: SECRET,
|
||||
metadata: {
|
||||
name: secretName,
|
||||
namespace,
|
||||
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: provider.metadata.name,
|
||||
uid: provider.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
newSecret['_type'] = 'Opaque';
|
||||
newSecret['data'] = {
|
||||
user: btoa(username.value),
|
||||
password: btoa(password.value),
|
||||
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||
url: btoa(url.value),
|
||||
};
|
||||
|
||||
await newSecret.save();
|
||||
createdSecret.value = newSecret;
|
||||
|
||||
const result = await pollProviderReady(providerName.value);
|
||||
|
||||
if (!result.connected) {
|
||||
await cleanupCreatedResources();
|
||||
}
|
||||
|
||||
handlePollResult(result, buttonCb);
|
||||
} catch (err) {
|
||||
await cleanupCreatedResources();
|
||||
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
try {
|
||||
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||
|
||||
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
|
||||
type: SECRET,
|
||||
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
|
||||
});
|
||||
} catch (e) {
|
||||
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
const clickTestButton = () => {
|
||||
testBtnRef.value?.$el?.click();
|
||||
};
|
||||
|
||||
defineExpose({ testConnection, clickTestButton });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="configure-provider-step"
|
||||
>
|
||||
<p class="text-deemphasized line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
|
||||
</p>
|
||||
|
||||
<div class="configure-provider-step-content">
|
||||
<h3 class="table-title m-0">
|
||||
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
|
||||
</h3>
|
||||
|
||||
<Banner
|
||||
class="requirements-banner m-0"
|
||||
color="info"
|
||||
>
|
||||
<div class="requirements-banner-content">
|
||||
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
|
||||
<br>
|
||||
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="configure-provider-step-form">
|
||||
<div v-if="!createOnly && !editMode">
|
||||
<LabeledSelect
|
||||
v-model:value="selectedProvider"
|
||||
required
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
|
||||
:options="providerOptions"
|
||||
:reduce="(opt) => opt.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isExistingProvider || editMode"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model:value="providerName"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.name')"
|
||||
:disabled="editMode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="url"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
required
|
||||
/>
|
||||
<p class="text-deemphasized mt-5">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="vddkInitImage"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
|
||||
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="username"
|
||||
:label="t('harvester.addons.vmMigration.fields.username')"
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
:label="t('harvester.addons.vmMigration.fields.password')"
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
v-model:value="skipTlsVerify"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
/>
|
||||
<p class="text-deemphasized ml-20">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<AsyncButton
|
||||
ref="testBtnRef"
|
||||
mode="test"
|
||||
:disabled="!isFormValid"
|
||||
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
|
||||
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
|
||||
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
|
||||
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
|
||||
:action-color="'role-secondary'"
|
||||
:waiting-color="'role-disabled'"
|
||||
@click="testConnection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="testResult"
|
||||
color="success"
|
||||
>
|
||||
{{ testResult }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-if="testError"
|
||||
color="error"
|
||||
>
|
||||
{{ testError }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
>
|
||||
{{ err }}
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.requirements-banner {
|
||||
font-weight: 400;
|
||||
|
||||
.requirements-banner-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.configure-provider-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.configure-provider-step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.configure-provider-step-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.checkbox-label) {
|
||||
color: var(--body-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
252
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
252
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<script setup>
|
||||
import { useStore } from 'vuex';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { VOLUME_MODE } from '../../config/types';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
entries: { type: Array, default: () => [] },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
showUsedBy: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: false },
|
||||
// Storage-specific: show the volume/access mode defaults row with an Edit action.
|
||||
showVolumeSettings: { type: Boolean, default: false },
|
||||
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
|
||||
inheritedProviderName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit-defaults']);
|
||||
|
||||
// Only offer "Remove Map" for entries that already have a target selected;
|
||||
// entries without a selection just show the regular options.
|
||||
const optionsFor = (entry) => {
|
||||
if (!props.clearable || !entry.target) {
|
||||
return props.options;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
|
||||
value: null,
|
||||
kind: 'highlighted',
|
||||
},
|
||||
{
|
||||
label: 'divider',
|
||||
disabled: true,
|
||||
kind: 'divider',
|
||||
},
|
||||
...props.options,
|
||||
];
|
||||
};
|
||||
|
||||
const formatModes = (entry) => {
|
||||
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
|
||||
const accessModes = (entry.accessModes || []).join(', ');
|
||||
|
||||
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="text-deemphasized line-height-20"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RcItemCard
|
||||
v-for="entry in entries"
|
||||
:id="entry._key"
|
||||
:key="entry._key"
|
||||
:variant="'small'"
|
||||
:header="{}"
|
||||
class="bg-light-gray"
|
||||
>
|
||||
<template #item-card-content>
|
||||
<div class="card-content-column">
|
||||
<div class="card-content-row">
|
||||
<div class="mapping-source">
|
||||
<span class="source-name">{{ entry.name }}</span>
|
||||
<slot
|
||||
name="source-detail"
|
||||
:entry="entry"
|
||||
/>
|
||||
</div>
|
||||
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
|
||||
<i
|
||||
class="icon icon-right-arrow-alt"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mapping-target">
|
||||
<LabeledSelect
|
||||
v-model:value="entry.target"
|
||||
:options="optionsFor(entry)"
|
||||
:placeholder="placeholder+'...'"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showVolumeSettings && entry.target"
|
||||
class="storage-defaults-row"
|
||||
>
|
||||
<div class="storage-defaults">
|
||||
<div class="storage-defaults-info">
|
||||
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
|
||||
<span
|
||||
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
|
||||
class="text-deemphasized storage-defaults-inherited"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
role="button"
|
||||
class="storage-defaults-edit"
|
||||
@click.prevent="emit('edit-defaults', entry)"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
|
||||
<span class="used-by">
|
||||
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RcItemCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mapping-column {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.item-card-header) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-section-title {
|
||||
font-weight: 600;
|
||||
margin: 0 0 5px 0;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.card-content-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-source {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 2;
|
||||
min-width: 100px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.used-by {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapping-target {
|
||||
flex: 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.storage-defaults-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.storage-defaults {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--body-bg);
|
||||
|
||||
.storage-defaults-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-defaults-summary {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.storage-defaults-inherited {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.storage-defaults-edit {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
background-color: var(--category-active) !important;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
674
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
674
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
@ -0,0 +1,674 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import SortableTable from '@shell/components/SortableTable';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { BadgeState } from '@components/BadgeState';
|
||||
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { NAMESPACE } from '@shell/config/types';
|
||||
import { HCI } from '../../types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import {
|
||||
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
|
||||
} from '../../utils/forklift';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
provider: { type: Object, default: null },
|
||||
selectedVms: { type: Array, default: () => [] },
|
||||
mappingEntries: { type: Object, default: null },
|
||||
stepData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const vms = computed(() => props.selectedVms);
|
||||
const networkMappings = ref([]);
|
||||
const storageMappings = ref([]);
|
||||
const planName = ref('');
|
||||
const targetNamespace = ref('');
|
||||
const namespaceOptions = ref([]);
|
||||
const existingProviderNames = ref([]);
|
||||
const existingPlanNames = ref([]);
|
||||
const errors = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
// Restore persisted state
|
||||
planName.value = props.stepData.planName;
|
||||
targetNamespace.value = props.stepData.targetNamespace || '';
|
||||
|
||||
const NS = FORKLIFT_NAMESPACE;
|
||||
|
||||
const nameConflictError = computed(() => {
|
||||
const name = (planName.value || '').trim();
|
||||
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (existingProviderNames.value.includes(name)) {
|
||||
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
|
||||
}
|
||||
|
||||
if (existingPlanNames.value.includes(name)) {
|
||||
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
watch(planName, (val) => {
|
||||
props.stepData.planName = val;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(targetNamespace, (val) => {
|
||||
props.stepData.targetNamespace = val;
|
||||
});
|
||||
|
||||
watch([planName, targetNamespace, nameConflictError], () => {
|
||||
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
|
||||
}, { immediate: true });
|
||||
|
||||
const formatNetworkTarget = (target = '') => {
|
||||
if (target === 'pod') {
|
||||
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
|
||||
}
|
||||
|
||||
if (target === 'ignored') {
|
||||
return t('harvester.addons.vmMigration.generic.ignored');
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
|
||||
|
||||
const totalMemoryGB = computed(() => {
|
||||
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
|
||||
|
||||
return mbToGB(totalMB);
|
||||
});
|
||||
|
||||
const totalStorageGB = computed(() => {
|
||||
let totalBytes = 0;
|
||||
|
||||
vms.value.forEach((vm) => {
|
||||
if (vm.disks && vm.disks.length > 0) {
|
||||
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||
}
|
||||
});
|
||||
|
||||
return bytesToGB(totalBytes);
|
||||
});
|
||||
|
||||
const vmRows = computed(() => {
|
||||
return vms.value.map((vm) => {
|
||||
const cpus = vm.cpuCount || vm.numCPU || 0;
|
||||
const memMB = vm.memoryMB || vm.memory || 0;
|
||||
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||
|
||||
let totalDiskBytes = 0;
|
||||
|
||||
if (vm.disks && vm.disks.length > 0) {
|
||||
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||
}
|
||||
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||
const os = vm.guestName || vm.guestOS || vm.os || '-';
|
||||
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
|
||||
|
||||
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||
|
||||
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
|
||||
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
|
||||
|
||||
return {
|
||||
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||
id: vm.id,
|
||||
name: vm.name || vm.id,
|
||||
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||
os,
|
||||
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
|
||||
resourcesSub: `${ memGB } • ${ diskDisplay }`,
|
||||
powerState,
|
||||
powerStateClass,
|
||||
networkMappings: vmNetworkMappings,
|
||||
storageMappings: vmStorageMappings,
|
||||
networkMapping: networkMappingSort,
|
||||
storageMapping: storageMappingSort,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const vmHeaders = [
|
||||
{
|
||||
name: 'vmName',
|
||||
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
|
||||
value: 'name',
|
||||
sort: ['name'],
|
||||
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
name: 'os',
|
||||
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
|
||||
value: 'os',
|
||||
sort: ['os'],
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
name: 'resources',
|
||||
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
|
||||
value: 'resources',
|
||||
sort: false,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
name: 'powerState',
|
||||
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
|
||||
value: 'powerState',
|
||||
sort: ['powerState'],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'networkMapping',
|
||||
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
|
||||
value: 'networkMapping',
|
||||
sort: ['networkMapping'],
|
||||
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
name: 'storageMapping',
|
||||
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
|
||||
value: 'storageMapping',
|
||||
sort: ['storageMapping'],
|
||||
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: ' ',
|
||||
align: 'right',
|
||||
sort: false,
|
||||
width: 40,
|
||||
},
|
||||
];
|
||||
|
||||
const onEditSelection = () => emit('edit-selection');
|
||||
const onEditMappings = () => emit('edit-mappings');
|
||||
const onRemoveVm = (id) => emit('remove-vm', id);
|
||||
|
||||
const startMigrationAction = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
const providerRef = {
|
||||
source: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Provider',
|
||||
name: props.providerName,
|
||||
namespace: NS,
|
||||
},
|
||||
destination: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Provider',
|
||||
name: 'host',
|
||||
namespace: NS,
|
||||
},
|
||||
};
|
||||
|
||||
const networkMapName = `${ planName.value }-network-map`;
|
||||
const storageMapName = `${ planName.value }-storage-map`;
|
||||
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: { name: networkMapName, namespace: NS },
|
||||
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
|
||||
});
|
||||
|
||||
await networkMap.save();
|
||||
|
||||
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: { name: storageMapName, namespace: NS },
|
||||
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
|
||||
const plan = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_PLAN,
|
||||
metadata: { name: planName.value, namespace: NS },
|
||||
spec: {
|
||||
provider: providerRef,
|
||||
map: {
|
||||
network: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'NetworkMap',
|
||||
name: networkMapName,
|
||||
namespace: NS,
|
||||
},
|
||||
storage: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'StorageMap',
|
||||
name: storageMapName,
|
||||
namespace: NS,
|
||||
},
|
||||
},
|
||||
targetNamespace: targetNamespace.value,
|
||||
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
|
||||
warm: false,
|
||||
},
|
||||
});
|
||||
|
||||
await plan.save();
|
||||
|
||||
// Kick off the first migration through the model so the Migration payload
|
||||
// lives in a single place (also reused by the dashboard start/restart action).
|
||||
await plan.startMigration();
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
try {
|
||||
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
|
||||
|
||||
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
|
||||
} catch (e) {
|
||||
namespaceOptions.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||
} catch (e) {}
|
||||
|
||||
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
|
||||
.map((p) => p.metadata?.name)
|
||||
.filter(Boolean);
|
||||
|
||||
try {
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
|
||||
} catch (e) {}
|
||||
|
||||
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
|
||||
.map((p) => p.metadata?.name)
|
||||
.filter(Boolean);
|
||||
|
||||
if (props.mappingEntries) {
|
||||
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
|
||||
source: entry.name || entry.id,
|
||||
target: entry.target || '',
|
||||
usedBy: entry.usedBy || [],
|
||||
}));
|
||||
|
||||
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
|
||||
source: entry.name || entry.id,
|
||||
target: entry.target || '',
|
||||
usedBy: entry.usedBy || [],
|
||||
}));
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
defineExpose({ startMigration: startMigrationAction });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="review-migration"
|
||||
>
|
||||
<div
|
||||
class="review-migration-content"
|
||||
>
|
||||
<Banner
|
||||
v-if="nameConflictError"
|
||||
color="error"
|
||||
:label="nameConflictError"
|
||||
/>
|
||||
|
||||
<!-- Migration Details Summary -->
|
||||
<div class="migration-details">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title m-0">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
|
||||
</h3>
|
||||
<span class="section-description text-deemphasized">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="name-row">
|
||||
<LabeledInput
|
||||
v-model:value="planName"
|
||||
class="name-input"
|
||||
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
|
||||
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
|
||||
required
|
||||
/>
|
||||
<LabeledSelect
|
||||
v-model:value="targetNamespace"
|
||||
class="namespace-select"
|
||||
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
|
||||
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
|
||||
:options="namespaceOptions"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<div class="migration-mode-column">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
|
||||
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
|
||||
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
|
||||
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-column">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
|
||||
<span class="detail-value">{{ providerName }}</span>
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
|
||||
<span class="detail-value">{{ vms.length }}</span>
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
|
||||
<span class="detail-value">{{ totalVCpu }}</span>
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
|
||||
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
|
||||
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Machines -->
|
||||
<div class="vm-section">
|
||||
<SortableTable
|
||||
class="vm-sortable-table"
|
||||
:rows="vmRows"
|
||||
:headers="vmHeaders"
|
||||
:search="false"
|
||||
:table-actions="false"
|
||||
:row-actions="false"
|
||||
:groupable="false"
|
||||
:paging="true"
|
||||
key-field="_key"
|
||||
>
|
||||
<template #header-left>
|
||||
<h4 class="section-title m-0">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
|
||||
</h4>
|
||||
</template>
|
||||
<template #header-right>
|
||||
<a
|
||||
href="#"
|
||||
class="edit-selection-link"
|
||||
@click.prevent="onEditSelection"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #cell:vmName="{ row }">
|
||||
<div class="vm-name-cell">
|
||||
<span class="vm-name">{{ row.name }}</span>
|
||||
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:resources="{ row }">
|
||||
<span>{{ row.resources }}</span><br>
|
||||
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||
</template>
|
||||
<template #cell:powerState="{ row }">
|
||||
<BadgeState
|
||||
:label="row.powerState"
|
||||
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
|
||||
/>
|
||||
</template>
|
||||
<template #cell:networkMapping="{ row }">
|
||||
<template v-if="row.networkMappings.length">
|
||||
<div
|
||||
v-for="(m, i) in row.networkMappings"
|
||||
:key="'net-' + i"
|
||||
class="mapping-cell"
|
||||
>
|
||||
<span class="mapping-source">{{ m.source }}</span>
|
||||
<span class="mapping-target text-deemphasized">→ {{ formatNetworkTarget(m.target) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-deemphasized"
|
||||
>-</span>
|
||||
</template>
|
||||
<template #cell:storageMapping="{ row }">
|
||||
<template v-if="row.storageMappings.length">
|
||||
<div
|
||||
v-for="(m, i) in row.storageMappings"
|
||||
:key="'stor-' + i"
|
||||
class="mapping-cell"
|
||||
>
|
||||
<span class="mapping-source">{{ m.source }}</span>
|
||||
<span class="mapping-target text-deemphasized">→ {{ m.target }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-deemphasized"
|
||||
>-</span>
|
||||
</template>
|
||||
<template #cell:actions="{ row }">
|
||||
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
|
||||
<rc-dropdown-trigger
|
||||
variant="ghost"
|
||||
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
|
||||
>
|
||||
<i class="icon icon-actions" />
|
||||
</rc-dropdown-trigger>
|
||||
<template #dropdownCollection>
|
||||
<rc-dropdown-item @click="onEditMappings">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
|
||||
</rc-dropdown-item>
|
||||
<rc-dropdown-item
|
||||
class="text-error"
|
||||
@click="onRemoveVm(row.id)"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
|
||||
</rc-dropdown-item>
|
||||
</template>
|
||||
</rc-dropdown>
|
||||
</template>
|
||||
<template #no-rows>
|
||||
<tr class="no-rows">
|
||||
<td
|
||||
:colspan="vmHeaders.length"
|
||||
class="text-center"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
|
||||
<a
|
||||
href="#"
|
||||
class="edit-selection-link"
|
||||
@click.prevent="onEditSelection"
|
||||
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</SortableTable>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-migration {
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.review-migration-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px clamp(12px, 1vw, 96px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px clamp(12px, 1vw, 96px);
|
||||
line-height: 20px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.section-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.migration-mode-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
.cold-migration-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
font-weight: 600;
|
||||
background: rgba(224, 191, 90, 0.16);
|
||||
color: #E0BF5A;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
.theme-light & {
|
||||
background: rgba(168, 128, 26, 0.16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-column {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: start;
|
||||
align-content: start;
|
||||
column-gap: clamp(24px, 4vw, 64px);
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-selection-link {
|
||||
cursor: pointer;
|
||||
color: var(--link);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vm-sortable-table {
|
||||
:deep(.fixed-header-actions) {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
:deep(td) {
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(th:first-child),
|
||||
:deep(td:first-child) {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.vm-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vm-id {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-cell {
|
||||
.mapping-target {
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-cell + .mapping-cell {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.migration-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vm-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
668
pkg/harvester/components/vm-migration/SelectVmsStep.vue
Normal file
668
pkg/harvester/components/vm-migration/SelectVmsStep.vue
Normal file
@ -0,0 +1,668 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
|
||||
} from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import SortableTable from '@shell/components/SortableTable';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { BadgeState } from '@components/BadgeState';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { bytesToGB, mbToGB } from '../../utils/forklift';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
provider: { type: Object, default: null },
|
||||
stepData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['complete', 'loading']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
|
||||
|
||||
const selectedVMs = ref([]);
|
||||
const loading = ref(true);
|
||||
// Restored from stepData so friendly names survive step navigation / remount.
|
||||
const networkMap = ref(props.stepData.networkMap || {});
|
||||
const datastoreMap = ref(props.stepData.datastoreMap || {});
|
||||
const sortableTableRef = ref(null);
|
||||
const allVMsSelected = ref(false);
|
||||
const errors = ref([]);
|
||||
let skipNextSelectionEvent = false;
|
||||
|
||||
const lastFetchedAt = ref(null);
|
||||
const now = ref(Date.now());
|
||||
|
||||
const formatElapsed = (ms) => {
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
|
||||
}
|
||||
|
||||
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
|
||||
};
|
||||
|
||||
const lastSyncedTime = computed(() => {
|
||||
if (!lastFetchedAt.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatElapsed(now.value - lastFetchedAt.value);
|
||||
});
|
||||
|
||||
const nowTimer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 30000);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(nowTimer);
|
||||
});
|
||||
|
||||
// Restore selection state from stepData
|
||||
if (selectedVMIds.value.size > 0) {
|
||||
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||
}
|
||||
|
||||
const vmCount = computed(() => discoveredVMs.value.length);
|
||||
const selectedCount = computed(() => selectedVMs.value.length);
|
||||
|
||||
const showSelectAllBanner = computed(() => {
|
||||
if (allVMsSelected.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pagedRows = sortableTableRef.value?.pagedRows || [];
|
||||
|
||||
if (pagedRows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
|
||||
|
||||
return allPageSelected && tableRows.value.length > pagedRows.length;
|
||||
});
|
||||
|
||||
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
||||
|
||||
const headers = ref([
|
||||
{
|
||||
name: 'vmName',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||
value: 'vmName',
|
||||
sort: ['vmName'],
|
||||
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||
},
|
||||
{
|
||||
name: 'os',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
|
||||
value: 'os',
|
||||
sort: ['os'],
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
name: 'resources',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
|
||||
value: 'resources',
|
||||
sort: false,
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
name: 'powerState',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
|
||||
value: 'powerState',
|
||||
sort: ['powerState'],
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
name: 'network',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
|
||||
value: 'network',
|
||||
sort: ['network'],
|
||||
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
name: 'datastore',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
|
||||
value: 'datastore',
|
||||
sort: ['datastore'],
|
||||
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||
width: 200,
|
||||
},
|
||||
]);
|
||||
|
||||
const buildTableRows = () => {
|
||||
return discoveredVMs.value.map((vm) => {
|
||||
const cpus = vm.cpuCount || vm.numCPU || '-';
|
||||
const memMB = vm.memoryMB || vm.memory || 0;
|
||||
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||
|
||||
let totalDiskBytes = 0;
|
||||
|
||||
if (vm.disks && vm.disks.length > 0) {
|
||||
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||
}
|
||||
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
let networks = [];
|
||||
|
||||
if (vm.networks && vm.networks.length > 0) {
|
||||
networks = vm.networks.map((n) => ({
|
||||
name: n.name || networkMap.value[n.id] || n.id || n,
|
||||
id: n.id || '',
|
||||
vlanId: n.vlanId || '',
|
||||
}));
|
||||
}
|
||||
|
||||
const datastores = [];
|
||||
|
||||
if (vm.disks && vm.disks.length > 0) {
|
||||
const seen = new Set();
|
||||
|
||||
vm.disks.forEach((d) => {
|
||||
const dsId = d.datastore?.id || d.datastore?.name;
|
||||
|
||||
if (dsId && !seen.has(dsId)) {
|
||||
seen.add(dsId);
|
||||
datastores.push({
|
||||
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
|
||||
id: d.datastore?.id || '',
|
||||
type: d.datastore?.type || '',
|
||||
capacity: d.capacity || 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
_original: vm,
|
||||
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
|
||||
vmName: vm.name || vm.metadata?.name || '-',
|
||||
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||
os: vm.guestName || vm.guestOS || vm.os || '-',
|
||||
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
|
||||
resourcesSub: `${ memGB } • ${ diskDisplay }`,
|
||||
powerState,
|
||||
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
|
||||
networks,
|
||||
network: networks.map((n) => n.name).join(', ') || '-',
|
||||
datastores,
|
||||
datastore: datastores.map((d) => d.name).join(', ') || '-',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (rows) => {
|
||||
if (skipNextSelectionEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPageRows = sortableTableRef.value?.pagedRows || [];
|
||||
const selectedKeys = new Set(rows.map((r) => r._key));
|
||||
|
||||
currentPageRows.forEach((row) => {
|
||||
const vmId = row._original?.id;
|
||||
|
||||
if (selectedKeys.has(row._key)) {
|
||||
selectedVMIds.value.add(vmId);
|
||||
} else {
|
||||
selectedVMIds.value.delete(vmId);
|
||||
}
|
||||
});
|
||||
|
||||
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
allVMsSelected.value = false;
|
||||
selectedVMIds.value = new Set();
|
||||
selectedVMs.value = [];
|
||||
|
||||
nextTick(() => {
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (table) {
|
||||
table.clearSelection();
|
||||
|
||||
if (typeof table.setPage === 'function') {
|
||||
table.setPage(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllVMs = () => {
|
||||
allVMsSelected.value = true;
|
||||
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
|
||||
selectedVMs.value = discoveredVMs.value.slice();
|
||||
};
|
||||
|
||||
// Moves currently-selected VMs to the first page(s) and re-checks them.
|
||||
const sortSelectedToFront = () => {
|
||||
if (selectedVMIds.value.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
tableRows.value.forEach((row) => {
|
||||
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
|
||||
});
|
||||
|
||||
headers.value = headers.value.map((h) => (
|
||||
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
|
||||
));
|
||||
|
||||
skipNextSelectionEvent = true;
|
||||
|
||||
nextTick(() => {
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (!table) {
|
||||
skipNextSelectionEvent = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof table.changeSort === 'function') {
|
||||
table.changeSort('vmName', false);
|
||||
} else if (typeof table.setPage === 'function') {
|
||||
table.setPage(1);
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||
|
||||
if (rowsToReselect.length > 0) {
|
||||
table.update(rowsToReselect, []);
|
||||
}
|
||||
|
||||
skipNextSelectionEvent = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => sortableTableRef.value?.page,
|
||||
() => {
|
||||
skipNextSelectionEvent = true;
|
||||
|
||||
nextTick(() => {
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||
|
||||
if (rowsToReselect.length > 0) {
|
||||
table.update(rowsToReselect, []);
|
||||
}
|
||||
|
||||
skipNextSelectionEvent = false;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const refreshing = ref(false);
|
||||
|
||||
const isLoading = computed(() => loading.value || refreshing.value);
|
||||
|
||||
watch(isLoading, (val) => {
|
||||
emit('loading', val);
|
||||
}, { immediate: true });
|
||||
|
||||
const fetchVMs = async() => {
|
||||
if (!props.provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
const providerUid = props.provider.metadata.uid;
|
||||
const providerType = props.provider.spec?.type || 'vsphere';
|
||||
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||
);
|
||||
|
||||
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
|
||||
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
|
||||
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
|
||||
]);
|
||||
|
||||
if (Array.isArray(vmsResp)) {
|
||||
discoveredVMs.value = vmsResp;
|
||||
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
|
||||
discoveredVMs.value = vmsResp.data;
|
||||
}
|
||||
|
||||
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
|
||||
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
|
||||
|
||||
networkMap.value = networks.reduce((map, n) => {
|
||||
map[n.id] = n.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
datastoreMap.value = datastores.reduce((map, d) => {
|
||||
map[d.id] = d.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
props.stepData.networkMap = networkMap.value;
|
||||
props.stepData.datastoreMap = datastoreMap.value;
|
||||
|
||||
lastFetchedAt.value = Date.now();
|
||||
tableRows.value = buildTableRows();
|
||||
};
|
||||
|
||||
const refreshVMs = async() => {
|
||||
refreshing.value = true;
|
||||
skipNextSelectionEvent = true;
|
||||
|
||||
const previousSelectedIds = new Set(selectedVMIds.value);
|
||||
|
||||
try {
|
||||
errors.value = [];
|
||||
await fetchVMs();
|
||||
} catch (e) {
|
||||
discoveredVMs.value = [];
|
||||
tableRows.value = [];
|
||||
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
|
||||
}
|
||||
|
||||
selectedVMIds.value = new Set(
|
||||
discoveredVMs.value
|
||||
.filter((vm) => previousSelectedIds.has(vm.id))
|
||||
.map((vm) => vm.id)
|
||||
);
|
||||
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
|
||||
|
||||
props.stepData.discoveredVMs = discoveredVMs.value;
|
||||
props.stepData.selectedVMIds = selectedVMIds.value;
|
||||
props.stepData.tableRows = tableRows.value;
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
refreshing.value = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (table) {
|
||||
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||
|
||||
if (rowsToReselect.length > 0) {
|
||||
table.update(rowsToReselect, []);
|
||||
}
|
||||
}
|
||||
|
||||
skipNextSelectionEvent = false;
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
if (discoveredVMs.value.length > 0) {
|
||||
if (!lastFetchedAt.value) {
|
||||
lastFetchedAt.value = Date.now();
|
||||
}
|
||||
tableRows.value = buildTableRows();
|
||||
loading.value = false;
|
||||
|
||||
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
|
||||
await nextTick();
|
||||
sortSelectedToFront();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchVMs();
|
||||
} catch (e) {
|
||||
discoveredVMs.value = [];
|
||||
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// Emit initial complete if VMs are already selected (restored from stepData)
|
||||
if (selectedVMs.value.length > 0) {
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading || refreshing" />
|
||||
<div
|
||||
v-else
|
||||
class="select-vms-step"
|
||||
>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
<p class="text-deemphasized line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
|
||||
<router-link
|
||||
v-if="provider"
|
||||
class="provider-link"
|
||||
:to="provider._detailLocation"
|
||||
>
|
||||
{{ providerName }}
|
||||
</router-link>
|
||||
<br>
|
||||
<span
|
||||
v-if="lastSyncedTime"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
|
||||
<a
|
||||
role="button"
|
||||
class="text-bold"
|
||||
:class="{ disabled: refreshing }"
|
||||
@click.prevent="refreshVMs"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
<!-- Discovered VMs table -->
|
||||
<SortableTable
|
||||
ref="sortableTableRef"
|
||||
:rows="tableRows"
|
||||
:headers="headers"
|
||||
:search="true"
|
||||
:table-actions="true"
|
||||
:row-actions="false"
|
||||
:groupable="false"
|
||||
:paging="true"
|
||||
key-field="_key"
|
||||
@selection="onSelect"
|
||||
>
|
||||
<template #header-left>
|
||||
<div class="vm-table-title">
|
||||
<h3 class="m-0">
|
||||
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||
</h3>
|
||||
<span class="selected-actions">
|
||||
<span class="text-deemphasized">
|
||||
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
|
||||
</span>
|
||||
<template v-if="selectedCount > 0">
|
||||
<span class="text-deemphasized">|</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="clearSelection"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
|
||||
</a>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:vmName="{ row }">
|
||||
<div class="vm-name-cell">
|
||||
<span class="vm-name">{{ row.vmName }}</span>
|
||||
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:resources="{ row }">
|
||||
<span>{{ row.resourcesDisplay }}</span><br>
|
||||
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||
</template>
|
||||
<template #cell:powerState="{ row }">
|
||||
<BadgeState
|
||||
:label="row.powerState"
|
||||
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
|
||||
/>
|
||||
</template>
|
||||
<template #cell:network="{ row }">
|
||||
<div>
|
||||
<div
|
||||
v-for="(net, i) in row.networks"
|
||||
:key="i"
|
||||
:class="{ 'mt-4': i > 0 }"
|
||||
>
|
||||
<span>{{ net.name }}</span><br>
|
||||
<span class="text-deemphasized">{{ net.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:datastore="{ row }">
|
||||
<div>
|
||||
<div
|
||||
v-for="(ds, i) in row.datastores"
|
||||
:key="i"
|
||||
:class="{ 'mt-4': i > 0 }"
|
||||
>
|
||||
<span>{{ ds.name }}</span><br>
|
||||
<span class="text-deemphasized">{{ ds.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SortableTable>
|
||||
|
||||
<Teleport
|
||||
v-if="showSelectAllBanner && theadElement"
|
||||
:to="theadElement"
|
||||
>
|
||||
<tr class="select-all-banner-row">
|
||||
<td
|
||||
:colspan="headers.length + 1"
|
||||
class="select-all-banner-cell"
|
||||
>
|
||||
<template v-if="allVMsSelected">
|
||||
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="clearSelection"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="selectAllVMs"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||
</a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vm-table-title {
|
||||
.text-deemphasized {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.selected-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vm-name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
|
||||
.vm-name {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vm-id {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-vms-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.select-all-banner-row) {
|
||||
.select-all-banner-cell {
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--sortable-table-header-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-left: 5px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
181
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Card } from '@components/Card';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import AppModal from '@shell/components/AppModal';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
|
||||
|
||||
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
|
||||
const {
|
||||
READ_WRITE_ONCE = 'ReadWriteOnce',
|
||||
READ_ONLY_MANY = 'ReadOnlyMany',
|
||||
READ_WRITE_MANY = 'ReadWriteMany',
|
||||
READ_WRITE_ONCE_POD = 'ReadWriteOncePod'
|
||||
} = ACCESS_MODE || {};
|
||||
const ACCESS_MODE_OPTIONS = [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY, READ_WRITE_ONCE_POD];
|
||||
|
||||
const props = defineProps({
|
||||
storageClassName: { type: String, default: '' },
|
||||
providerName: { type: String, default: '' },
|
||||
showInherited: { type: Boolean, default: false },
|
||||
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||
accessModes: { type: Array, default: () => ['ReadWriteMany'] },
|
||||
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||
inheritedAccessModes: { type: Array, default: () => ['ReadWriteMany'] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['apply', 'close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
|
||||
const localAccessMode = ref(props.accessModes?.[0] || READ_WRITE_MANY);
|
||||
|
||||
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
|
||||
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
|
||||
|
||||
const apply = () => {
|
||||
emit('apply', {
|
||||
volumeMode: localVolumeMode.value,
|
||||
accessModes: [localAccessMode.value],
|
||||
});
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// Repopulate the dropdowns with the provider default values; the user then applies.
|
||||
const reset = () => {
|
||||
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
|
||||
localAccessMode.value = props.inheritedAccessModes?.[0] || READ_WRITE_MANY;
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-modal
|
||||
class="storage-defaults-modal"
|
||||
name="storageDefaultsDialog"
|
||||
:width="620"
|
||||
height="auto"
|
||||
:click-to-close="false"
|
||||
@close="cancel"
|
||||
>
|
||||
<Card
|
||||
class="storage-defaults-card"
|
||||
:show-highlight-border="false"
|
||||
>
|
||||
<template #title>
|
||||
<h4 class="text-default-text">
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
|
||||
</h4>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<p class="text-deemphasized description">
|
||||
<template v-if="showInherited">
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="settings-box">
|
||||
<LabeledSelect
|
||||
v-model:value="localVolumeMode"
|
||||
class="volume-mode"
|
||||
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
|
||||
:options="volumeModeOptions"
|
||||
:searchable="false"
|
||||
:clearable="false"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="localAccessMode"
|
||||
class="access-mode"
|
||||
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
|
||||
:options="accessModeOptions"
|
||||
:searchable="false"
|
||||
:clearable="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
|
||||
</button>
|
||||
<div class="right-buttons">
|
||||
<button
|
||||
v-if="showInherited"
|
||||
class="btn role-secondary mr-10"
|
||||
@click="reset"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
:disabled="!localAccessMode"
|
||||
@click="apply"
|
||||
>
|
||||
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</app-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.storage-defaults-modal {
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.storage-defaults-card {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.settings-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--category-active);
|
||||
}
|
||||
|
||||
.volume-mode {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.access-mode {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
.right-buttons {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -65,7 +65,17 @@ const FEATURE_FLAGS = {
|
||||
'vGPUAsPCIDevice',
|
||||
'instanceManagerResourcesSetting',
|
||||
'rwxNetworkSetting',
|
||||
'createPVCWithDataVolume'
|
||||
'createPVCWithDataVolume',
|
||||
'clusterPodSecurityStandardSetting',
|
||||
],
|
||||
'v1.8.1': [],
|
||||
'v1.9.0': [
|
||||
'supportFilesystem',
|
||||
'disableResourcePooling',
|
||||
'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';
|
||||
|
||||
@ -237,6 +252,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.addons.vmImport.labels.vmimport',
|
||||
group: 'vmimport',
|
||||
namespaced: true,
|
||||
ifHaveType: HCI.VMIMPORT,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VMIMPORT }
|
||||
@ -266,6 +282,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare',
|
||||
group: 'vmimport',
|
||||
namespaced: true,
|
||||
ifHaveType: HCI.VMIMPORT_SOURCE_V,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VMIMPORT_SOURCE_V }
|
||||
@ -295,6 +312,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack',
|
||||
group: 'vmimport',
|
||||
namespaced: true,
|
||||
ifHaveType: HCI.VMIMPORT_SOURCE_O,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VMIMPORT_SOURCE_O }
|
||||
@ -323,6 +341,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA',
|
||||
group: 'vmimport',
|
||||
namespaced: true,
|
||||
ifHaveType: HCI.VMIMPORT_SOURCE_OVA,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
|
||||
@ -484,6 +503,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.CLUSTER_FLOW,
|
||||
labelKey: 'harvester.logging.clusterFlow.label',
|
||||
name: HCI.CLUSTER_FLOW,
|
||||
namespaced: true,
|
||||
@ -507,6 +527,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.CLUSTER_OUTPUT,
|
||||
labelKey: 'harvester.logging.clusterOutput.label',
|
||||
name: HCI.CLUSTER_OUTPUT,
|
||||
namespaced: true,
|
||||
@ -530,6 +551,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.FLOW,
|
||||
labelKey: 'harvester.logging.flow.label',
|
||||
name: HCI.FLOW,
|
||||
namespaced: true,
|
||||
@ -553,6 +575,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.OUTPUT,
|
||||
labelKey: 'harvester.logging.output.label',
|
||||
name: HCI.OUTPUT,
|
||||
namespaced: true,
|
||||
@ -573,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,
|
||||
@ -592,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(
|
||||
[
|
||||
@ -671,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 }
|
||||
@ -686,14 +752,14 @@ export function init($plugin, store) {
|
||||
},
|
||||
resource: NETWORK_ATTACHMENT,
|
||||
resourceDetail: HCI.NETWORK_ATTACHMENT,
|
||||
resourceEdit: HCI.NETWORK_ATTACHMENT
|
||||
resourceEdit: HCI.NETWORK_ATTACHMENT,
|
||||
});
|
||||
|
||||
virtualType({
|
||||
labelKey: 'harvester.network.label',
|
||||
name: HCI.NETWORK_ATTACHMENT,
|
||||
namespaced: true,
|
||||
weight: 188,
|
||||
weight: 485,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.NETWORK_ATTACHMENT }
|
||||
@ -707,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 }
|
||||
@ -716,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 }
|
||||
@ -731,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: {
|
||||
@ -1085,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 }
|
||||
@ -1124,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 }
|
||||
@ -1133,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 = {
|
||||
@ -88,8 +89,13 @@ export const CSI_SECRETS = {
|
||||
CSI_NODE_PUBLISH_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-publish-secret-namespace',
|
||||
CSI_NODE_STAGE_SECRET_NAME: 'csi.storage.k8s.io/node-stage-secret-name',
|
||||
CSI_NODE_STAGE_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-stage-secret-namespace',
|
||||
CSI_NODE_EXPAND_SECRET_NAME: 'csi.storage.k8s.io/node-expand-secret-name',
|
||||
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
||||
};
|
||||
|
||||
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||
export const FORKLIFT_NAMESPACE = 'forklift';
|
||||
|
||||
// Some harvester CRD type is not equal to model file name, define the mapping here
|
||||
export const HARVESTER_CRD_MAP = {
|
||||
node: HCI.HOST,
|
||||
|
||||
@ -16,9 +16,11 @@ export const HCI = {
|
||||
CREATOR: 'harvesterhci.io/creator',
|
||||
OS: 'harvesterhci.io/os',
|
||||
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
|
||||
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
|
||||
NETWORK_TYPE: 'network.harvesterhci.io/type',
|
||||
VM_NAME: 'harvesterhci.io/vmName',
|
||||
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
|
||||
VM_DISPLAY_NAME: 'harvesterhci.io/vmDisplayName',
|
||||
VM_RESERVED_MEMORY: 'harvesterhci.io/reservedMemory',
|
||||
MAINTENANCE_STATUS: 'harvesterhci.io/maintain-status',
|
||||
HOST_CUSTOM_NAME: 'harvesterhci.io/host-custom-name',
|
||||
@ -79,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,47 +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'
|
||||
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 = {
|
||||
@ -111,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',
|
||||
@ -130,6 +141,9 @@ export const HCI_ALLOWED_SETTINGS = {
|
||||
},
|
||||
[HCI_SETTING.INSTANCE_MANAGER_RESOURCES]: {
|
||||
kind: 'json', from: 'import', featureFlag: 'instanceManagerResourcesSetting'
|
||||
},
|
||||
[HCI_SETTING.CLUSTER_POD_SECURITY_STANDARD]: {
|
||||
kind: 'json', from: 'import', canReset: true, featureFlag: 'clusterPodSecurityStandardSetting'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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',
|
||||
@ -46,3 +53,9 @@ export const CDI_POPULATOR_KIND = {
|
||||
VOLUME_IMPORT_SOURCE: 'VolumeImportSource',
|
||||
VOLUME_CLONE_SOURCE: 'VolumeCloneSource',
|
||||
};
|
||||
|
||||
export const FILESYSTEM_SOURCE_TYPE = {
|
||||
CONFIGMAP: 'configmap',
|
||||
SECRET: 'secret',
|
||||
SERVICEACCOUNT: 'serviceaccount',
|
||||
};
|
||||
|
||||
@ -27,15 +27,11 @@ export default {
|
||||
await allHash({
|
||||
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
|
||||
vmis: this.$store.dispatch('harvester/findAll', { type: HCI.VMI }),
|
||||
allClusterNetwork: this.$store.dispatch('harvester/findAll', { type: HCI.CLUSTER_NETWORK }),
|
||||
vmims: this.$store.dispatch('harvester/findAll', { type: HCI.VMIM }),
|
||||
});
|
||||
},
|
||||
|
||||
computed: {
|
||||
allClusterNetwork() {
|
||||
return this.$store.getters['harvester/all'](HCI.CLUSTER_NETWORK);
|
||||
},
|
||||
|
||||
rows() {
|
||||
const vms = this.$store.getters['harvester/all'](HCI.VM);
|
||||
|
||||
@ -108,7 +104,6 @@ export default {
|
||||
<HarvesterVmState
|
||||
class="vmstate"
|
||||
:row="scope.row"
|
||||
:all-cluster-network="allClusterNetwork"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -390,14 +390,17 @@ export default {
|
||||
:vmi-resource="vmi"
|
||||
:vmim-resource="vmim"
|
||||
/>
|
||||
<DashboardMetrics
|
||||
<div
|
||||
v-if="showVmMetrics && liveMigrationProgressEnabled"
|
||||
:detail-url="VM_MIGRATION_DETAIL_URL"
|
||||
graph-height="640px"
|
||||
:has-summary-and-detail="false"
|
||||
:vars="graphVars"
|
||||
class="mb-30"
|
||||
/>
|
||||
class="migration-metrics-scroll mb-30"
|
||||
>
|
||||
<DashboardMetrics
|
||||
:detail-url="VM_MIGRATION_DETAIL_URL"
|
||||
graph-height="960px"
|
||||
:has-summary-and-detail="false"
|
||||
:vars="graphVars"
|
||||
/>
|
||||
</div>
|
||||
<Events
|
||||
v-if="liveMigrationProgressEnabled"
|
||||
:resource="vmi"
|
||||
@ -436,3 +439,10 @@ export default {
|
||||
</Tabbed>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.migration-metrics-scroll {
|
||||
max-height: 640px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -98,9 +98,9 @@ export default {
|
||||
methods: {
|
||||
escapeHtml,
|
||||
|
||||
close() {
|
||||
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);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { Card } from '@components/Card';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { escapeHtml } from '@shell/utils/string';
|
||||
import { HCI } from '../types';
|
||||
@ -13,7 +15,9 @@ export default {
|
||||
|
||||
components: {
|
||||
AsyncButton,
|
||||
Banner,
|
||||
Card,
|
||||
Checkbox,
|
||||
},
|
||||
|
||||
props: {
|
||||
@ -24,10 +28,16 @@ export default {
|
||||
},
|
||||
|
||||
data() {
|
||||
return {};
|
||||
return { disableResourcePooling: false };
|
||||
},
|
||||
|
||||
computed: { ...mapGetters({ t: 'i18n/t' }) },
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
disableResourcePoolingEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('disableResourcePooling');
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
@ -52,9 +62,10 @@ export default {
|
||||
}]
|
||||
},
|
||||
spec: {
|
||||
address: actionResource.status.address,
|
||||
nodeName: actionResource.status.nodeName,
|
||||
userName
|
||||
address: actionResource.status.address,
|
||||
nodeName: actionResource.status.nodeName,
|
||||
userName,
|
||||
disableResourcePooling: this.disableResourcePooling,
|
||||
}
|
||||
} );
|
||||
|
||||
@ -85,7 +96,19 @@ export default {
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
{{ t('harvester.pci.enablePassthroughWarning') }}
|
||||
<p class="mb-20">
|
||||
{{ t('harvester.pci.enablePassthroughWarning') }}
|
||||
</p>
|
||||
<template v-if="disableResourcePoolingEnabled">
|
||||
<Checkbox
|
||||
v-model:value="disableResourcePooling"
|
||||
label-key="harvester.pci.disableResourcePooling"
|
||||
/>
|
||||
<Banner
|
||||
color="info"
|
||||
:label="t('harvester.pci.disableResourcePoolingDescription')"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
|
||||
133
pkg/harvester/dialog/EnableSriovGpuDevice.vue
Normal file
133
pkg/harvester/dialog/EnableSriovGpuDevice.vue
Normal file
@ -0,0 +1,133 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Card } from '@components/Card';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { escapeHtml } from '@shell/utils/string';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEnableSriovGpuDevice',
|
||||
|
||||
emits: ['close'],
|
||||
|
||||
components: {
|
||||
AsyncButton,
|
||||
Card,
|
||||
Checkbox,
|
||||
},
|
||||
|
||||
props: {
|
||||
resources: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return { skipMigConfiguration: this.resources[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true' };
|
||||
},
|
||||
|
||||
computed: { ...mapGetters({ t: 'i18n/t' }) },
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
const actionResource = this.resources[0];
|
||||
const prevEnabled = actionResource.spec.enabled;
|
||||
const prevAnnotation = actionResource.metadata.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
|
||||
|
||||
try {
|
||||
if (this.skipMigConfiguration) {
|
||||
if (!actionResource.metadata.annotations) {
|
||||
actionResource.metadata.annotations = {};
|
||||
}
|
||||
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = 'true';
|
||||
} else {
|
||||
if (actionResource.metadata.annotations) {
|
||||
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
|
||||
}
|
||||
}
|
||||
|
||||
actionResource.spec.enabled = true;
|
||||
await actionResource.save();
|
||||
buttonCb(true);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
actionResource.spec.enabled = prevEnabled;
|
||||
if (prevAnnotation !== undefined) {
|
||||
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = prevAnnotation;
|
||||
} else if (actionResource.metadata.annotations) {
|
||||
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
|
||||
}
|
||||
|
||||
this.$store.dispatch('growl/fromError', {
|
||||
title: this.t('generic.notification.title.error', { name: escapeHtml(actionResource.metadata.name) }),
|
||||
err,
|
||||
}, { root: true });
|
||||
buttonCb(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :show-highlight-border="false">
|
||||
<template #title>
|
||||
<h4
|
||||
v-clean-html="t('harvester.sriovgpu.enable.title')"
|
||||
class="text-default-text"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="body">
|
||||
<Checkbox
|
||||
v-model:value="skipMigConfiguration"
|
||||
:label="t('harvester.sriovgpu.enable.skipMigConfiguration')"
|
||||
/>
|
||||
<p class="note mt-10">
|
||||
{{ t('harvester.sriovgpu.enable.note') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="buttons actions">
|
||||
<button
|
||||
class="btn role-secondary mr-10"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
|
||||
<AsyncButton
|
||||
mode="enable"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -2,13 +2,16 @@
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { 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"
|
||||
|
||||
154
pkg/harvester/dialog/HarvesterEnableNvidiaDriverToolkit.vue
Normal file
154
pkg/harvester/dialog/HarvesterEnableNvidiaDriverToolkit.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<script>
|
||||
import merge from 'lodash/merge';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Card } from '@components/Card';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { escapeHtml } from '@shell/utils/string';
|
||||
|
||||
const DEFAULT_VALUE = { image: { repository: 'rancher/harvester-nvidia-driver-toolkit' } };
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEnableNvidiaDriverToolkit',
|
||||
|
||||
emits: ['close'],
|
||||
|
||||
components: {
|
||||
AsyncButton,
|
||||
Card,
|
||||
LabeledInput,
|
||||
},
|
||||
|
||||
props: {
|
||||
resources: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
const addon = this.resources[0];
|
||||
let valuesContentJson;
|
||||
|
||||
try {
|
||||
valuesContentJson = merge({}, DEFAULT_VALUE, jsyaml.load(addon.spec.valuesContent));
|
||||
} catch (e) {
|
||||
valuesContentJson = { ...DEFAULT_VALUE };
|
||||
}
|
||||
|
||||
return { valuesContentJson };
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
buttonDisabled() {
|
||||
const { image, driverLocation } = this.valuesContentJson;
|
||||
|
||||
return !(image?.repository || '').trim() || !(image?.tag || '').trim() || !(driverLocation || '').trim();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async enable(buttonCb) {
|
||||
const addon = this.resources[0];
|
||||
|
||||
try {
|
||||
addon.spec.valuesContent = jsyaml.dump(this.valuesContentJson);
|
||||
addon.spec.enabled = true;
|
||||
await addon.save();
|
||||
buttonCb(true);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
addon.spec.enabled = false;
|
||||
this.$store.dispatch('growl/fromError', {
|
||||
title: this.t('generic.notification.title.error', { name: escapeHtml(addon.metadata.name) }),
|
||||
err,
|
||||
}, { root: true });
|
||||
buttonCb(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :show-highlight-border="false">
|
||||
<template #title>
|
||||
<h4
|
||||
v-clean-html="t('harvester.addons.nvidiaDriverToolkit.enable.title')"
|
||||
class="text-default-text"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="body">
|
||||
<div class="row mb-15">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="valuesContentJson.image.repository"
|
||||
:required="true"
|
||||
:label="t('harvester.addons.nvidiaDriverToolkit.image.repository')"
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="valuesContentJson.image.tag"
|
||||
:required="true"
|
||||
:label="t('harvester.addons.nvidiaDriverToolkit.image.tag')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-15">
|
||||
<div class="col span-12">
|
||||
<LabeledInput
|
||||
v-model:value="valuesContentJson.driverLocation"
|
||||
:required="true"
|
||||
:label="t('harvester.addons.nvidiaDriverToolkit.driver.location')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="buttons actions">
|
||||
<button
|
||||
class="btn role-secondary mr-10"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
|
||||
<AsyncButton
|
||||
mode="enable"
|
||||
:disabled="buttonDisabled"
|
||||
@click="enable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@ -1,15 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import { NODE } from '@shell/config/types';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
|
||||
import { Card } from '@components/Card';
|
||||
import { Banner } from '@components/Banner';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { HCI } from '../types';
|
||||
|
||||
export default {
|
||||
emits: ['close'],
|
||||
@ -62,28 +59,46 @@ export default {
|
||||
return this.resources[0];
|
||||
},
|
||||
|
||||
vmi() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const vmiResources = this.$store.getters[`${ inStore }/all`](HCI.VMI);
|
||||
const resource = vmiResources.find((VMI) => VMI.id === this.actionResource?.id) || null;
|
||||
anyCpuPinning() {
|
||||
return this.resources.some((r) => r.isCpuPinning);
|
||||
},
|
||||
|
||||
return resource;
|
||||
vmsByNode() {
|
||||
const groups = {};
|
||||
|
||||
for (const r of this.resources) {
|
||||
const node = r.nodeName || '';
|
||||
const name = r.nameDisplay || r.name || r.id;
|
||||
|
||||
if (!groups[node]) {
|
||||
groups[node] = [];
|
||||
}
|
||||
groups[node].push(name);
|
||||
}
|
||||
|
||||
return Object.entries(groups).map(([node, vms]) => ({ node, vms })).sort((a, b) => a.node.localeCompare(b.node));
|
||||
},
|
||||
|
||||
cpuPinningAlertMessage() {
|
||||
return this.t('harvester.virtualMachine.cpuPinning.migrationMessage');
|
||||
},
|
||||
|
||||
allVmsOnTargetNode() {
|
||||
if (!this.nodeName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.resources.every((r) => r.nodeName === this.nodeName);
|
||||
},
|
||||
|
||||
nodeNameList() {
|
||||
const nodes = this.$store.getters['harvester/all'](NODE);
|
||||
|
||||
return nodes.filter((n) => {
|
||||
const isNotSelfNode = !!this.availableNodes.includes(n.id);
|
||||
const isNotWitnessNode = n.isEtcd !== 'true'; // do not allow to migrate to self node and witness node
|
||||
const isCpuPinning = this.actionResource?.isCpuPinning;
|
||||
const matchingCpuManagerConfig = !isCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
|
||||
const matchingCpuManagerConfig = !this.anyCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
|
||||
|
||||
return isNotSelfNode && isNotWitnessNode && matchingCpuManagerConfig;
|
||||
return isNotWitnessNode && matchingCpuManagerConfig;
|
||||
}).map((n) => {
|
||||
let label = n?.metadata?.name;
|
||||
const value = n?.metadata?.name;
|
||||
@ -102,10 +117,10 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
close(data) {
|
||||
this.nodeName = '';
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
this.$emit('close', data);
|
||||
},
|
||||
|
||||
async apply(buttonDone) {
|
||||
@ -126,10 +141,32 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.actionResource.doAction('migrate', { nodeName: this.nodeName }, {}, false);
|
||||
// Filter out VMs already running on the selected node
|
||||
const toMigrate = this.resources.filter((r) => r.nodeName !== this.nodeName);
|
||||
|
||||
// await Promise.allSettled(toMigrate.map((r) => r.doAction('migrate', { nodeName: this.nodeName }, {}, false)));
|
||||
// We want to show all migration errors if there are multiple VMs, so we use allSettled here and handle the results accordingly.
|
||||
const results = await Promise.allSettled(toMigrate.map((r) => r.doAction('migrate', { nodeName: this.nodeName }, {}, false)));
|
||||
|
||||
const failedMigrations = results
|
||||
.map((result, index) => ({ resource: toMigrate[index], result }))
|
||||
.filter(({ result }) => result.status === 'rejected');
|
||||
|
||||
if (failedMigrations.length) {
|
||||
this['errors'] = failedMigrations.flatMap(({ resource, result }) => {
|
||||
const vmName = resource?.nameDisplay || resource?.name || resource?.metadata?.name || this.$store.getters['i18n/t']('generic.unknown');
|
||||
const error = result.reason?.data || result.reason;
|
||||
const messages = exceptionToErrorsArray(error);
|
||||
|
||||
return messages.map((message) => `${ vmName }: ${ message }`);
|
||||
});
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
buttonDone(true);
|
||||
this.close();
|
||||
this.close({ performCallback: true, clearTableSelection: true });
|
||||
} catch (err) {
|
||||
const error = err?.data || err;
|
||||
const message = exceptionToErrorsArray(error);
|
||||
@ -146,17 +183,35 @@ export default {
|
||||
<template>
|
||||
<Card :show-highlight-border="false">
|
||||
<template #title>
|
||||
{{ t('harvester.modal.migration.title') }}
|
||||
{{ t('harvester.modal.migration.vmMigrationTitle', { count: resources.length }) }}
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<Banner
|
||||
v-if="actionResource?.isCpuPinning"
|
||||
v-if="anyCpuPinning"
|
||||
color="warning"
|
||||
:label="cpuPinningAlertMessage"
|
||||
/>
|
||||
<p>
|
||||
{{ t('harvester.modal.migration.selectedVMs') }}
|
||||
</p>
|
||||
<ul class="vm-list">
|
||||
<li
|
||||
v-for="group in vmsByNode"
|
||||
:key="group.node"
|
||||
>
|
||||
{{ group.node || t('harvester.modal.migration.unknownNode') }}: {{ group.vms.join(', ') }}
|
||||
<span
|
||||
v-if="nodeName && group.node === nodeName"
|
||||
class="already-on-target"
|
||||
>
|
||||
({{ t('harvester.modal.migration.alreadyOnTarget') }})
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<LabeledSelect
|
||||
v-model:value="nodeName"
|
||||
class="mt-15"
|
||||
:label="t('harvester.modal.migration.fields.nodeName.label')"
|
||||
:placeholder="t('harvester.modal.migration.fields.nodeName.placeholder')"
|
||||
:options="nodeNameList"
|
||||
@ -183,7 +238,7 @@ export default {
|
||||
|
||||
<AsyncButton
|
||||
mode="apply"
|
||||
:disabled="!nodeName"
|
||||
:disabled="!nodeName || allVmsOnTargetNode"
|
||||
@click="apply"
|
||||
/>
|
||||
</div>
|
||||
@ -201,4 +256,16 @@ export default {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.already-on-target {
|
||||
color: var(--warning);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.vm-list {
|
||||
list-style: disc;
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -5,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"
|
||||
|
||||
24
pkg/harvester/edit/forklift.konveyor.io.provider.vue
Normal file
24
pkg/harvester/edit/forklift.konveyor.io.provider.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { h } from 'vue';
|
||||
import { PRODUCT_NAME } from '../config/harvester';
|
||||
|
||||
export default {
|
||||
name: 'ForkliftProviderEdit',
|
||||
|
||||
created() {
|
||||
const { namespace, id } = this.$route.params;
|
||||
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
|
||||
|
||||
this.$router.replace({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||
params: {
|
||||
product: this.$route.params.product,
|
||||
cluster: this.$route.params.cluster,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
|
||||
render: () => h('div'),
|
||||
};
|
||||
</script>
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -8,6 +8,7 @@ import FileSelector, { createOnSelected } from '@shell/components/form/FileSelec
|
||||
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { getLoginAwareErrors } from '../utils/error';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditKeypair',
|
||||
@ -63,6 +64,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
normalizedErrors() {
|
||||
const message = this.t('harvester.virtualMachine.genericLoginError');
|
||||
|
||||
return getLoginAwareErrors(this.errors, message);
|
||||
}
|
||||
},
|
||||
|
||||
methods: { onKeySelected: createOnSelected('publicKey') },
|
||||
};
|
||||
</script>
|
||||
@ -72,10 +81,9 @@ export default {
|
||||
:done-route="doneRoute"
|
||||
:resource="value"
|
||||
:mode="mode"
|
||||
:errors="errors"
|
||||
:errors="normalizedErrors"
|
||||
:apply-hooks="applyHooks"
|
||||
@finish="save"
|
||||
@error="e=>errors=e"
|
||||
>
|
||||
<div class="header mb-20">
|
||||
<FileSelector
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@ -3,8 +3,9 @@ import KeyValue from '@shell/components/form/KeyValue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import RadioGroup from '@components/Form/Radio/RadioGroup';
|
||||
import Checkbox from '@components/Form/Checkbox/Checkbox';
|
||||
import { SECRET, LONGHORN } from '@shell/config/types';
|
||||
import { _CREATE, _VIEW } from '@shell/config/query-params';
|
||||
import { _CREATE, _VIEW, _EDIT } from '@shell/config/query-params';
|
||||
import { CSI_SECRETS } from '@pkg/harvester/config/harvester-map';
|
||||
import { clone } from '@shell/utils/object';
|
||||
import { uniq } from '@shell/utils/array';
|
||||
@ -16,7 +17,9 @@ const {
|
||||
CSI_NODE_PUBLISH_SECRET_NAME,
|
||||
CSI_NODE_PUBLISH_SECRET_NAMESPACE,
|
||||
CSI_NODE_STAGE_SECRET_NAME,
|
||||
CSI_NODE_STAGE_SECRET_NAMESPACE
|
||||
CSI_NODE_STAGE_SECRET_NAMESPACE,
|
||||
CSI_NODE_EXPAND_SECRET_NAME,
|
||||
CSI_NODE_EXPAND_SECRET_NAMESPACE
|
||||
} = CSI_SECRETS;
|
||||
|
||||
export default {
|
||||
@ -27,6 +30,7 @@ export default {
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
RadioGroup,
|
||||
Checkbox,
|
||||
},
|
||||
|
||||
props: {
|
||||
@ -57,7 +61,10 @@ export default {
|
||||
};
|
||||
}
|
||||
|
||||
return { };
|
||||
const hasExpandSecret = !!(this.value.parameters?.[CSI_NODE_EXPAND_SECRET_NAME] && this.value.parameters?.[CSI_NODE_EXPAND_SECRET_NAMESPACE]);
|
||||
const volumeExpansionCheckBoxEnabled = this.realMode === _CREATE ? true : hasExpandSecret;
|
||||
|
||||
return { volumeExpansionCheckBoxEnabled };
|
||||
},
|
||||
|
||||
computed: {
|
||||
@ -98,8 +105,11 @@ export default {
|
||||
}, []);
|
||||
},
|
||||
|
||||
isEdit() {
|
||||
return this.realMode === _EDIT;
|
||||
},
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
return this.realMode === _VIEW;
|
||||
},
|
||||
|
||||
migratableOptions() {
|
||||
@ -152,6 +162,10 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
enableOnlineExpansionVolumeEncryption() {
|
||||
return this.value.expandOnlineEncryptedVolumeFeatureEnabled;
|
||||
},
|
||||
|
||||
volumeEncryption: {
|
||||
set(neu) {
|
||||
this.value['parameters'] = {
|
||||
@ -180,6 +194,11 @@ export default {
|
||||
set(selectedSecret) {
|
||||
const [namespace, name] = selectedSecret.split('/');
|
||||
|
||||
const expandSecretParams = (this.enableOnlineExpansionVolumeEncryption && this.volumeExpansionCheckBoxEnabled) ? {
|
||||
[CSI_NODE_EXPAND_SECRET_NAME]: name,
|
||||
[CSI_NODE_EXPAND_SECRET_NAMESPACE]: namespace,
|
||||
} : {};
|
||||
|
||||
this.value['parameters'] = {
|
||||
...this.value.parameters,
|
||||
[CSI_PROVISIONER_SECRET_NAME]: name,
|
||||
@ -187,7 +206,8 @@ export default {
|
||||
[CSI_NODE_STAGE_SECRET_NAME]: name,
|
||||
[CSI_PROVISIONER_SECRET_NAMESPACE]: namespace,
|
||||
[CSI_NODE_PUBLISH_SECRET_NAMESPACE]: namespace,
|
||||
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace
|
||||
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace,
|
||||
...expandSecretParams,
|
||||
};
|
||||
}
|
||||
},
|
||||
@ -240,6 +260,32 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
volumeExpansionCheckBoxEnabled(enabled) {
|
||||
const currentSecret = this.secret;
|
||||
|
||||
if (!currentSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [namespace, name] = currentSecret.split('/');
|
||||
|
||||
if (enabled && this.enableOnlineExpansionVolumeEncryption) {
|
||||
this.value['parameters'] = {
|
||||
...this.value.parameters,
|
||||
[CSI_NODE_EXPAND_SECRET_NAME]: name,
|
||||
[CSI_NODE_EXPAND_SECRET_NAMESPACE]: namespace,
|
||||
};
|
||||
} else {
|
||||
const params = { ...this.value.parameters };
|
||||
|
||||
delete params[CSI_NODE_EXPAND_SECRET_NAME];
|
||||
delete params[CSI_NODE_EXPAND_SECRET_NAMESPACE];
|
||||
this.value['parameters'] = params;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
@ -337,6 +383,16 @@ export default {
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="enableOnlineExpansionVolumeEncryption"
|
||||
class="col span-6 flex items-center mt-20"
|
||||
>
|
||||
<Checkbox
|
||||
v-model:value="volumeExpansionCheckBoxEnabled"
|
||||
:label="t('harvester.storage.volumeExpansionCheckbox')"
|
||||
:disabled="isEdit || isView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<KeyValue
|
||||
|
||||
@ -254,15 +254,24 @@ export default {
|
||||
v-if="restoreNewVm"
|
||||
v-model:value="restoreResource.spec.keepMacAddress"
|
||||
type="checkbox"
|
||||
class="check mb-20"
|
||||
:label="t('harvester.backup.restore.keepMacAddress')"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="!restoreNewVm"
|
||||
v-model:value="deletionPolicy"
|
||||
class="mb-20"
|
||||
:label="t('harvester.backup.restore.deletePreviousVolumes')"
|
||||
:options="deletionPolicyOption"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model:value="restoreResource.spec.haltAfterRestore"
|
||||
type="checkbox"
|
||||
class="check mb-20"
|
||||
:label="t('harvester.backup.restore.haltAfterRestore')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Footer
|
||||
|
||||
@ -26,6 +26,7 @@ import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
|
||||
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
|
||||
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
|
||||
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
|
||||
import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEditVMTemplate',
|
||||
@ -51,6 +52,7 @@ export default {
|
||||
UnitInput,
|
||||
Banner,
|
||||
KeyValue,
|
||||
Filesystem,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView, VM_MIXIN],
|
||||
@ -95,6 +97,10 @@ export default {
|
||||
secretNamePrefix() {
|
||||
return this.templateValue?.metadata?.name;
|
||||
},
|
||||
|
||||
filesystemEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -154,6 +160,7 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.imageId = this.diskRows[0]?.image || '';
|
||||
this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm);
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -349,6 +356,19 @@ export default {
|
||||
</template>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
v-if="filesystemEnabled"
|
||||
name="filesystem"
|
||||
:label="t('harvester.tab.filesystem')"
|
||||
:weight="-8"
|
||||
>
|
||||
<Filesystem
|
||||
v-model:value="filesystemRows"
|
||||
:mode="mode"
|
||||
:namespace="templateValue.metadata.namespace"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="labels"
|
||||
:label="t('generic.labels')"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import Footer from '@shell/components/form/Footer';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import Checkbox from '@components/Form/Checkbox/Checkbox';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import CreateEditView from '@shell/mixins/create-edit-view';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
@ -32,6 +33,7 @@ const createObject = {
|
||||
export default {
|
||||
name: 'CreateRestore',
|
||||
components: {
|
||||
Checkbox,
|
||||
Footer,
|
||||
RadioGroup,
|
||||
LabeledInput,
|
||||
@ -249,9 +251,17 @@ export default {
|
||||
<LabeledSelect
|
||||
v-if="!restoreNewVm"
|
||||
v-model:value="deletionPolicy"
|
||||
class="mb-20"
|
||||
:label="t('harvester.backup.restore.deletePreviousVolumes')"
|
||||
:options="deletionPolicyOption"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model:value="restoreResource.spec.haltAfterRestore"
|
||||
type="checkbox"
|
||||
class="check mb-20"
|
||||
:label="t('harvester.backup.restore.haltAfterRestore')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Footer
|
||||
|
||||
@ -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 = {
|
||||
|
||||
190
pkg/harvester/edit/kubeovn.io.iptablesdnatrule.vue
Normal file
190
pkg/harvester/edit/kubeovn.io.iptablesdnatrule.vue
Normal file
@ -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>
|
||||
168
pkg/harvester/edit/kubeovn.io.iptableseip.vue
Normal file
168
pkg/harvester/edit/kubeovn.io.iptableseip.vue
Normal file
@ -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>
|
||||
140
pkg/harvester/edit/kubeovn.io.iptablessnatrule.vue
Normal file
140
pkg/harvester/edit/kubeovn.io.iptablessnatrule.vue
Normal file
@ -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>
|
||||
384
pkg/harvester/edit/kubeovn.io.providernetwork.vue
Normal file
384
pkg/harvester/edit/kubeovn.io.providernetwork.vue
Normal file
@ -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';
|
||||
@ -43,18 +43,20 @@ export default {
|
||||
created() {
|
||||
const vpc = this.$route.query.vpc || '';
|
||||
const enableDHCP = this.value?.spec?.enableDHCP || false;
|
||||
const natOutgoing = this.value?.spec?.natOutgoing || false;
|
||||
|
||||
set(this.value.spec, 'enableDHCP', enableDHCP);
|
||||
set(this.value, 'spec', this.value.spec || {
|
||||
cidrBlock: '',
|
||||
protocol: NETWORK_PROTOCOL.IPv4,
|
||||
provider: '',
|
||||
cidrBlock: '',
|
||||
protocol: NETWORK_PROTOCOL.IPv4,
|
||||
provider: '',
|
||||
vpc,
|
||||
gatewayIP: '',
|
||||
excludeIps: [],
|
||||
private: false,
|
||||
gateway: '',
|
||||
excludeIps: [],
|
||||
private: false,
|
||||
enableDHCP,
|
||||
acls: []
|
||||
natOutgoing,
|
||||
acls: []
|
||||
});
|
||||
},
|
||||
|
||||
@ -62,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);
|
||||
@ -129,6 +132,22 @@ export default {
|
||||
label: n.id,
|
||||
value: n.id,
|
||||
}));
|
||||
},
|
||||
natOutgoingDisabled() {
|
||||
// Disable the NAT Outgoing option when the subnet belongs to the ovn-cluster VPC and its name is join or ovn-default.
|
||||
return this.value?.spec?.vpc === 'ovn-cluster' && ['join', 'ovn-default'].includes(this.value?.metadata?.name);
|
||||
},
|
||||
|
||||
vlanOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
|
||||
|
||||
const options = vlans.map((vlan) => ({
|
||||
label: vlan.id,
|
||||
value: vlan.id,
|
||||
}));
|
||||
|
||||
return [{ label: this.t('generic.none'), value: '' }, ...options];
|
||||
}
|
||||
},
|
||||
|
||||
@ -143,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;
|
||||
@ -264,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">
|
||||
@ -304,6 +342,20 @@ export default {
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
v-model:value="value.spec.natOutgoing"
|
||||
name="enableExternalConnectivity"
|
||||
:disabled="natOutgoingDisabled"
|
||||
:options="[true, false]"
|
||||
:label="t('harvester.subnet.externalConnectivity.label')"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
:mode="mode"
|
||||
:tooltip="t('harvester.subnet.externalConnectivity.tooltip')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-6">
|
||||
<RadioGroup
|
||||
|
||||
146
pkg/harvester/edit/kubeovn.io.vlan.vue
Normal file
146
pkg/harvester/edit/kubeovn.io.vlan.vue
Normal file
@ -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>
|
||||
246
pkg/harvester/edit/kubeovn.io.vpcnatgateway.vue
Normal file
246
pkg/harvester/edit/kubeovn.io.vpcnatgateway.vue
Normal file
@ -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>
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -0,0 +1,310 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Banner } from '@components/Banner';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { CONFIG_MAP, SECRET, SERVICE_ACCOUNT } from '@shell/config/types';
|
||||
import { _VIEW } from '@shell/config/query-params';
|
||||
import CopyToClipboard from '@shell/components/CopyToClipboard';
|
||||
import MessageLink from '@shell/components/MessageLink';
|
||||
import { FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
|
||||
|
||||
const MAX_FILESYSTEMS = 3;
|
||||
|
||||
const { CONFIGMAP: FS_TYPE_CONFIGMAP, SECRET: FS_TYPE_SECRET, SERVICEACCOUNT: FS_TYPE_SERVICEACCOUNT } = FILESYSTEM_SOURCE_TYPE;
|
||||
|
||||
const DEFAULT_VOLUME_NAMES = {
|
||||
[FS_TYPE_CONFIGMAP]: 'appconfigfs',
|
||||
[FS_TYPE_SECRET]: 'appsecretfs',
|
||||
[FS_TYPE_SERVICEACCOUNT]: 'appserviceaccountfs',
|
||||
};
|
||||
|
||||
const FS_TYPE_OPTIONS = [
|
||||
{ label: 'ConfigMap', value: FS_TYPE_CONFIGMAP },
|
||||
{ label: 'Secret', value: FS_TYPE_SECRET },
|
||||
{ label: 'ServiceAccount', value: FS_TYPE_SERVICEACCOUNT },
|
||||
];
|
||||
|
||||
function emptyRow() {
|
||||
return {
|
||||
fsType: FS_TYPE_CONFIGMAP,
|
||||
volumeName: DEFAULT_VOLUME_NAMES[FS_TYPE_CONFIGMAP],
|
||||
resourceName: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'VirtualMachineFilesystem',
|
||||
|
||||
emits: ['update:value'],
|
||||
|
||||
components: {
|
||||
Banner,
|
||||
LabeledSelect,
|
||||
LabeledInput,
|
||||
CopyToClipboard,
|
||||
MessageLink,
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
namespace: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return { rows: this.value.length > 0 ? this.value.map((r) => ({ ...r })) : [emptyRow()] };
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(newVal) {
|
||||
if (newVal) {
|
||||
const incoming = JSON.stringify(newVal);
|
||||
const current = JSON.stringify(this.rows);
|
||||
|
||||
if (incoming !== current) {
|
||||
this.rows = newVal.map((r) => ({ ...r }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
rows: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.$emit('update:value', val.map((r) => ({ ...r })));
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
inStore() {
|
||||
return this.$store.getters['currentProduct'].inStore;
|
||||
},
|
||||
|
||||
configMaps() {
|
||||
return this.$store.getters[`${ this.inStore }/all`](CONFIG_MAP)
|
||||
.filter((cm) => !this.namespace || cm.metadata.namespace === this.namespace)
|
||||
.map((cm) => ({ label: cm.metadata.name, value: cm.metadata.name }));
|
||||
},
|
||||
|
||||
secrets() {
|
||||
return this.$store.getters[`${ this.inStore }/all`](SECRET)
|
||||
.filter((s) => !this.namespace || s.metadata.namespace === this.namespace)
|
||||
.map((s) => ({ label: s.metadata.name, value: s.metadata.name }));
|
||||
},
|
||||
|
||||
serviceAccounts() {
|
||||
return this.$store.getters[`${ this.inStore }/all`](SERVICE_ACCOUNT)
|
||||
.filter((sa) => !this.namespace || sa.metadata.namespace === this.namespace)
|
||||
.map((sa) => ({ label: sa.metadata.name, value: sa.metadata.name }));
|
||||
},
|
||||
|
||||
canAddRow() {
|
||||
return this.rows.length < MAX_FILESYSTEMS;
|
||||
},
|
||||
|
||||
isView() {
|
||||
return this.mode === _VIEW;
|
||||
},
|
||||
|
||||
completedRows() {
|
||||
return this.rows.filter((r) => r.fsType && r.volumeName && r.resourceName);
|
||||
},
|
||||
|
||||
allMountCommands() {
|
||||
return this.completedRows.map((r) => this.mountCommands(r)).join('\n');
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
fsTypeOptions(currentIndex) {
|
||||
const usedTypes = this.rows
|
||||
.filter((_, i) => i !== currentIndex)
|
||||
.map((r) => r.fsType);
|
||||
|
||||
return FS_TYPE_OPTIONS.filter((opt) => !usedTypes.includes(opt.value));
|
||||
},
|
||||
|
||||
resourceOptions(fsType) {
|
||||
if (fsType === FS_TYPE_CONFIGMAP) return this.configMaps;
|
||||
if (fsType === FS_TYPE_SECRET) return this.secrets;
|
||||
if (fsType === FS_TYPE_SERVICEACCOUNT) return this.serviceAccounts;
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
onFsTypeChange(row, newType) {
|
||||
row.fsType = newType;
|
||||
row.volumeName = DEFAULT_VOLUME_NAMES[newType] || '';
|
||||
row.resourceName = '';
|
||||
},
|
||||
|
||||
addRow() {
|
||||
if (this.canAddRow) {
|
||||
const usedTypes = this.rows.map((r) => r.fsType);
|
||||
const nextType = FS_TYPE_OPTIONS.find((opt) => !usedTypes.includes(opt.value))?.value || FS_TYPE_CONFIGMAP;
|
||||
|
||||
this.rows.push({
|
||||
fsType: nextType,
|
||||
volumeName: DEFAULT_VOLUME_NAMES[nextType] || '',
|
||||
resourceName: '',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeRow(index) {
|
||||
this.rows.splice(index, 1);
|
||||
},
|
||||
|
||||
mountCommands(row) {
|
||||
const vol = row.volumeName || '<volume-name>';
|
||||
|
||||
return `- mkdir -p /mnt/${ vol }\n- mount -t virtiofs ${ vol } /mnt/${ vol }`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vm-filesystem">
|
||||
<p class="mb-20">
|
||||
{{ t('harvester.virtualMachine.filesystem.description') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="index"
|
||||
class="filesystem-row mb-15"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-3">
|
||||
<LabeledSelect
|
||||
:value="row.fsType"
|
||||
:label="t('harvester.virtualMachine.filesystem.type')"
|
||||
:options="fsTypeOptions(index)"
|
||||
:mode="mode"
|
||||
required
|
||||
@update:value="onFsTypeChange(row, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-3">
|
||||
<LabeledInput
|
||||
v-model:value="row.volumeName"
|
||||
:label="t('harvester.virtualMachine.filesystem.volume')"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col span-5">
|
||||
<LabeledSelect
|
||||
v-model:value="row.resourceName"
|
||||
:label="t('harvester.virtualMachine.filesystem.resource')"
|
||||
:options="resourceOptions(row.fsType)"
|
||||
:mode="mode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isView"
|
||||
class="col span-1 remove-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn role-link remove-btn"
|
||||
@click="removeRow(index)"
|
||||
>
|
||||
{{ t('generic.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="completedRows.length > 0 && mode === 'create'"
|
||||
color="warning"
|
||||
class="mt-10"
|
||||
>
|
||||
<div>
|
||||
<MessageLink
|
||||
:to="{ hash: '#advanced' }"
|
||||
prefix-label="harvester.virtualMachine.filesystem.mountBannerHint"
|
||||
middle-label="harvester.virtualMachine.filesystem.mountBannerHintLink"
|
||||
suffix-label="harvester.virtualMachine.filesystem.mountBannerHintSuffix"
|
||||
/>
|
||||
<div class="pre-wrapper mt-10">
|
||||
<pre class="mt-5 mb-0">{{ allMountCommands }}</pre>
|
||||
<CopyToClipboard
|
||||
:text="allMountCommands"
|
||||
:show-label="false"
|
||||
class="icon-btn"
|
||||
action-color="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<button
|
||||
v-if="!isView && canAddRow"
|
||||
type="button"
|
||||
class="btn role-tertiary add"
|
||||
@click="addRow"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.filesystem.add') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vm-filesystem {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.filesystem-row {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.remove-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pre-wrapper {
|
||||
position: relative;
|
||||
|
||||
pre {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 5px;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
|
||||
&:active {
|
||||
background-color: var(--success) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -133,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"
|
||||
>
|
||||
|
||||
@ -152,6 +152,10 @@ export default {
|
||||
return !rows.find((device) => !device.passthroughClaim);
|
||||
},
|
||||
|
||||
canManageGroup(rows = []) {
|
||||
return rows.length > 0 && rows.every((row) => row.canUpdate === true);
|
||||
},
|
||||
|
||||
changeRows(filterRows, parentSriov) {
|
||||
this['filterRows'] = filterRows;
|
||||
this['parentSriov'] = parentSriov;
|
||||
@ -185,22 +189,27 @@ export default {
|
||||
v-trim-whitespace
|
||||
class="group-tab"
|
||||
>
|
||||
<button
|
||||
v-if="groupIsAllEnabled(group.rows)"
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{disableGroup(group.rows); e.target.blur()}"
|
||||
<div
|
||||
v-if="canManageGroup(group.rows)"
|
||||
class="group-actions"
|
||||
>
|
||||
{{ t('harvester.pci.disableGroup') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{enableGroup(group.rows); e.target.blur()}"
|
||||
>
|
||||
{{ t('harvester.pci.enableGroup') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="groupIsAllEnabled(group.rows)"
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{disableGroup(group.rows); e.target.blur()}"
|
||||
>
|
||||
{{ t('harvester.pci.disableGroup') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{enableGroup(group.rows); e.target.blur()}"
|
||||
>
|
||||
{{ t('harvester.pci.enableGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-clean-html="group.key" />
|
||||
</div>
|
||||
</template>
|
||||
@ -232,3 +241,9 @@ export default {
|
||||
</template>
|
||||
</ResourceTable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-actions {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -52,31 +52,21 @@ export default {
|
||||
}
|
||||
|
||||
const selectedDevices = [];
|
||||
const oldFormatDevices = [];
|
||||
|
||||
const vmDevices = this.value?.domain?.devices?.hostDevices || [];
|
||||
const otherDevices = this.otherDevices(vmDevices).map(({ name }) => name);
|
||||
const vmDeviceNames = vmDevices.map(({ name }) => name);
|
||||
|
||||
this.pciDevices.forEach((row) => {
|
||||
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
|
||||
});
|
||||
|
||||
vmDevices.forEach(({ name, deviceName }) => {
|
||||
const checkName = (deviceName || '').split('/')?.[1];
|
||||
|
||||
if (checkName && name.includes(checkName) && !otherDevices.includes(name)) {
|
||||
oldFormatDevices.push(name);
|
||||
} else if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
|
||||
vmDevices.forEach(({ name }) => {
|
||||
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
|
||||
selectedDevices.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
if (oldFormatDevices.length > 0) {
|
||||
this.oldFormatDevices = oldFormatDevices;
|
||||
} else {
|
||||
this.selectedDevices = selectedDevices;
|
||||
}
|
||||
this.selectedDevices = selectedDevices;
|
||||
},
|
||||
|
||||
data() {
|
||||
@ -87,7 +77,6 @@ export default {
|
||||
selectedDevices: [],
|
||||
pciDeviceSchema: this.$store.getters['harvester/schemaFor'](HCI.PCI_DEVICE),
|
||||
showMatrix: false,
|
||||
oldFormatDevices: [],
|
||||
};
|
||||
},
|
||||
|
||||
@ -199,11 +188,6 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
oldFormatDevicesHTML() {
|
||||
return this.oldFormatDevices.map((device) => {
|
||||
return `<li>${ device }</li>`;
|
||||
}).join('');
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -227,101 +211,89 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="oldFormatDevices.length > 0"
|
||||
class="row"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner color="warning">
|
||||
<p v-clean-html="t('harvester.pci.oldFormatDevices.help', {oldFormatDevicesHTML}, true)" />
|
||||
<Banner color="info">
|
||||
<MessageLink
|
||||
:to="toVGpuDevicesPage"
|
||||
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
|
||||
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
|
||||
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
|
||||
/>
|
||||
</Banner>
|
||||
<Banner
|
||||
v-if="selectedDevices.length > 0"
|
||||
color="info"
|
||||
>
|
||||
<t k="harvester.pci.deviceInTheSameHost" />
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<template v-if="enabledDevices.length">
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<Banner color="info">
|
||||
<MessageLink
|
||||
:to="toVGpuDevicesPage"
|
||||
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
|
||||
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
|
||||
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
|
||||
/>
|
||||
</Banner>
|
||||
<Banner
|
||||
v-if="selectedDevices.length > 0"
|
||||
color="info"
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model:value="selectedDevices"
|
||||
label="Available PCI Devices"
|
||||
searchable
|
||||
multiple
|
||||
taggable
|
||||
:options="deviceOpts"
|
||||
:mode="mode"
|
||||
>
|
||||
<t k="harvester.pci.deviceInTheSameHost" />
|
||||
</Banner>
|
||||
<template #option="option">
|
||||
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
|
||||
</template>
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="enabledDevices.length">
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledSelect
|
||||
v-model:value="selectedDevices"
|
||||
label="Available PCI Devices"
|
||||
searchable
|
||||
multiple
|
||||
taggable
|
||||
:options="deviceOpts"
|
||||
:mode="mode"
|
||||
>
|
||||
<template #option="option">
|
||||
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
|
||||
</template>
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
<div
|
||||
v-if="compatibleNodes.length && selectedDevices.length"
|
||||
class="row"
|
||||
>
|
||||
<div class="col span-12 text-muted">
|
||||
Compatible hosts:
|
||||
<!-- eslint-disable-next-line vue/no-parsing-error -->
|
||||
<span
|
||||
v-for="(node, idx) in compatibleNodes"
|
||||
:key="idx"
|
||||
>{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="compatibleNodes.length && selectedDevices.length"
|
||||
class="row"
|
||||
>
|
||||
<div class="col span-12 text-muted">
|
||||
Compatible hosts:
|
||||
<!-- eslint-disable-next-line vue/no-parsing-error -->
|
||||
<span
|
||||
v-for="(node, idx) in compatibleNodes"
|
||||
:key="idx"
|
||||
>{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedDevices.length"
|
||||
class="text-error"
|
||||
>
|
||||
{{ t('harvester.pci.impossibleSelection') }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm role-link pl-0"
|
||||
@click="e=>{showMatrix = !showMatrix; e.target.blur()}"
|
||||
>
|
||||
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="showMatrix"
|
||||
class="row mt-20"
|
||||
>
|
||||
<div class="col span-12">
|
||||
<CompatibilityMatrix
|
||||
:enabled-devices="enabledDevices"
|
||||
:devices-by-node="devicesByNode"
|
||||
:devices-in-use="devicesInUse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="row mt-20">
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedDevices.length"
|
||||
class="text-error"
|
||||
>
|
||||
{{ t('harvester.pci.impossibleSelection') }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm role-link pl-0"
|
||||
@click="e=>{showMatrix = !showMatrix; e.target.blur()}"
|
||||
>
|
||||
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="showMatrix"
|
||||
class="row mt-20"
|
||||
>
|
||||
<div class="col span-12">
|
||||
<DeviceList
|
||||
:schema="pciDeviceSchema"
|
||||
:devices="pciDevices"
|
||||
@submit.prevent
|
||||
<CompatibilityMatrix
|
||||
:enabled-devices="enabledDevices"
|
||||
:devices-by-node="devicesByNode"
|
||||
:devices-in-use="devicesInUse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="row mt-20">
|
||||
<div class="col span-12">
|
||||
<DeviceList
|
||||
:schema="pciDeviceSchema"
|
||||
:devices="pciDevices"
|
||||
@submit.prevent
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -10,6 +10,7 @@ import { _VIEW } from '@shell/config/query-params';
|
||||
|
||||
import { NAMESPACE } from '@shell/config/types';
|
||||
import { HCI } from '../../types';
|
||||
import { getLoginAwareErrors } from '../../utils/error';
|
||||
|
||||
const _NEW = '_NEW';
|
||||
|
||||
@ -214,7 +215,9 @@ export default {
|
||||
buttonCb(true);
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
this.errors = [err.message];
|
||||
const message = this.t('harvester.virtualMachine.genericLoginError');
|
||||
|
||||
this.errors = getLoginAwareErrors(err, message);
|
||||
buttonCb(false);
|
||||
}
|
||||
},
|
||||
|
||||
@ -64,6 +64,12 @@ export default {
|
||||
value: 'status.productID',
|
||||
sort: ['status.productID', 'status.vendorID']
|
||||
},
|
||||
{
|
||||
name: 'classType',
|
||||
labelKey: 'harvester.usb.classType',
|
||||
value: 'status.classType',
|
||||
sort: ['status.classType']
|
||||
},
|
||||
];
|
||||
|
||||
if (!isSingleProduct) {
|
||||
@ -107,6 +113,11 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
canManageGroup(rows = []) {
|
||||
return rows.every((row) => row.canUpdate === true);
|
||||
},
|
||||
|
||||
groupIsAllEnabled(rows = []) {
|
||||
return !rows.find((device) => !device.passthroughClaim);
|
||||
},
|
||||
@ -147,22 +158,27 @@ export default {
|
||||
v-trim-whitespace
|
||||
class="group-tab"
|
||||
>
|
||||
<button
|
||||
v-if="groupIsAllEnabled(group.rows)"
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{disableGroup(group.rows); e.target.blur()}"
|
||||
<div
|
||||
v-if="canManageGroup(group.rows)"
|
||||
class="group-actions"
|
||||
>
|
||||
{{ t('harvester.usb.disableGroup') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{enableGroup(group.rows); e.target.blur()}"
|
||||
>
|
||||
{{ t('harvester.usb.enableGroup') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="groupIsAllEnabled(group.rows)"
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{disableGroup(group.rows); e.target.blur()}"
|
||||
>
|
||||
{{ t('harvester.usb.disableGroup') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm role-secondary mr-5"
|
||||
@click="e=>{enableGroup(group.rows); e.target.blur()}"
|
||||
>
|
||||
{{ t('harvester.usb.enableGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-clean-html="group.key" />
|
||||
</div>
|
||||
</template>
|
||||
@ -175,3 +191,9 @@ export default {
|
||||
</template>
|
||||
</ResourceTable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-actions {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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: {
|
||||
@ -475,7 +477,6 @@ export default {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm bg-primary mr-15 mb-10"
|
||||
:disabled="rows.length === 0"
|
||||
@click="addVolume(SOURCE_TYPE.NEW)"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.volume.addVolume') }}
|
||||
|
||||
@ -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,12 +29,14 @@ import RestartVMDialog from '../../dialog/RestartVMDialog';
|
||||
import PciDevices from './VirtualMachinePciDevices/index';
|
||||
import AccessCredentials from './VirtualMachineAccessCredentials';
|
||||
import CloudConfig from './VirtualMachineCloudConfig';
|
||||
import WindowsSysprep from './VirtualMachineWindowsSysprep';
|
||||
import CpuMemory from './VirtualMachineCpuMemory';
|
||||
import CpuModel from './VirtualMachineCpuModel';
|
||||
import Network from './VirtualMachineNetwork';
|
||||
import Volume from './VirtualMachineVolume';
|
||||
import SSHKey from './VirtualMachineSSHKey';
|
||||
import Reserved from './VirtualMachineReserved';
|
||||
import Filesystem from './VirtualMachineFilesystem';
|
||||
import { Banner } from '@components/Banner';
|
||||
import MessageLink from '@shell/components/MessageLink';
|
||||
|
||||
@ -60,6 +60,7 @@ export default {
|
||||
CpuMemory,
|
||||
CpuModel,
|
||||
CloudConfig,
|
||||
WindowsSysprep,
|
||||
NodeScheduling,
|
||||
PodAffinity,
|
||||
AccessCredentials,
|
||||
@ -72,6 +73,7 @@ export default {
|
||||
Banner,
|
||||
MessageLink,
|
||||
UsbDevices,
|
||||
Filesystem,
|
||||
},
|
||||
|
||||
mixins: [CreateEditView, VM_MIXIN],
|
||||
@ -91,22 +93,39 @@ export default {
|
||||
|
||||
const hostname = this.value.spec.template.spec.hostname || '';
|
||||
|
||||
// Display name can contain arbitrary strings. There is no XSS risk because the value is
|
||||
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
|
||||
// this field. See harvester/harvester#10423 for details.
|
||||
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
|
||||
|
||||
return {
|
||||
cloneVM,
|
||||
count: 2,
|
||||
templateId: '',
|
||||
templateVersionId: '',
|
||||
namePrefix: '',
|
||||
isSingle: true,
|
||||
isOpen: false,
|
||||
count: 2,
|
||||
templateId: '',
|
||||
templateVersionId: '',
|
||||
namePrefix: '',
|
||||
isSingle: true,
|
||||
isOpen: false,
|
||||
hostname,
|
||||
isRestartImmediately,
|
||||
customizeDisplayName,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
// VM display name is stored as an annotation; bind a dedicated input to it
|
||||
// so we don't have to mutate metadata.name (which would break the k8s PUT).
|
||||
displayName: {
|
||||
get() {
|
||||
return this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME] || '';
|
||||
},
|
||||
set(val) {
|
||||
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, val);
|
||||
},
|
||||
},
|
||||
|
||||
to() {
|
||||
return {
|
||||
name: 'harvester-c-cluster-resource',
|
||||
@ -218,6 +237,9 @@ export default {
|
||||
usbPassthroughEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough');
|
||||
},
|
||||
filesystemEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -289,6 +311,12 @@ export default {
|
||||
this.getInitConfig({ value: this.value, init: this.isCreate });
|
||||
}
|
||||
},
|
||||
|
||||
customizeDisplayName(neu) {
|
||||
if (!neu) {
|
||||
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, '');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
@ -301,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));
|
||||
}
|
||||
@ -321,6 +353,7 @@ export default {
|
||||
const diskRows = this.getDiskRows(this.value);
|
||||
|
||||
this['diskRows'] = diskRows;
|
||||
this['filesystemRows'] = this.getFilesystemRows(this.value);
|
||||
const templateId = this.$route.query.templateId;
|
||||
const templateVersionId = this.$route.query.versionId;
|
||||
|
||||
@ -345,6 +378,7 @@ export default {
|
||||
clear(this.errors);
|
||||
|
||||
this.validateCPUMemory();
|
||||
this.validateWindowsSysprep();
|
||||
|
||||
// block create VM flow if has validation errors
|
||||
if (this.errors.length) {
|
||||
@ -372,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;
|
||||
@ -435,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);
|
||||
}
|
||||
@ -522,6 +588,7 @@ export default {
|
||||
onTabChanged({ tab }) {
|
||||
if (tab.name === 'advanced') {
|
||||
this.$refs.yamlEditor?.refresh();
|
||||
this.$refs.sysprepConfig?.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
@ -610,6 +677,33 @@ export default {
|
||||
</template>
|
||||
</NameNsDescription>
|
||||
|
||||
<div v-if="isSingle">
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<Checkbox
|
||||
v-model:value="customizeDisplayName"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
:label="t('harvester.virtualMachine.input.customizeDisplayName')"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="customizeDisplayName"
|
||||
class="row mb-20"
|
||||
>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="displayName"
|
||||
:mode="mode"
|
||||
:label="t('harvester.virtualMachine.input.displayName')"
|
||||
:placeholder="t('harvester.virtualMachine.input.displayNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
v-if="isCreate"
|
||||
v-model:value="useTemplate"
|
||||
@ -783,10 +877,23 @@ export default {
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
v-if="filesystemEnabled"
|
||||
name="filesystem"
|
||||
:label="t('harvester.tab.filesystem')"
|
||||
:weight="-9"
|
||||
>
|
||||
<Filesystem
|
||||
v-model:value="filesystemRows"
|
||||
:mode="isCreate ? mode : 'view'"
|
||||
:namespace="value.metadata.namespace"
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
name="labels"
|
||||
:label="t('generic.labels')"
|
||||
:weight="-9"
|
||||
:weight="-10"
|
||||
>
|
||||
<Banner color="info">
|
||||
<t k="harvester.virtualMachine.labels.banner" />
|
||||
@ -805,7 +912,7 @@ export default {
|
||||
<Tab
|
||||
name="instanceLabel"
|
||||
:label="t('harvester.tab.instanceLabel')"
|
||||
:weight="-10"
|
||||
:weight="-11"
|
||||
>
|
||||
<Banner color="info">
|
||||
<t k="harvester.virtualMachine.instanceLabels.banner" />
|
||||
@ -826,7 +933,7 @@ export default {
|
||||
<Tab
|
||||
name="annotations"
|
||||
:label="t('harvester.tab.annotations')"
|
||||
:weight="-11"
|
||||
:weight="-12"
|
||||
>
|
||||
<Banner color="info">
|
||||
<t k="harvester.virtualMachine.annotations.banner" />
|
||||
@ -847,7 +954,7 @@ export default {
|
||||
<Tab
|
||||
name="advanced"
|
||||
:label="t('harvester.tab.advanced')"
|
||||
:weight="-12"
|
||||
:weight="-13"
|
||||
>
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
@ -959,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"
|
||||
@ -967,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
|
||||
|
||||
419
pkg/harvester/edit/network.harvesterhci.io.hostnetworkconfig.vue
Normal file
419
pkg/harvester/edit/network.harvesterhci.io.hostnetworkconfig.vue
Normal file
@ -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>
|
||||
|
||||
23
pkg/harvester/formatters/HarvesterBoolean.vue
Normal file
23
pkg/harvester/formatters/HarvesterBoolean.vue
Normal file
@ -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;
|
||||
}
|
||||
|
||||
29
pkg/harvester/formatters/HarvesterHostNetworkConfigMode.vue
Normal file
29
pkg/harvester/formatters/HarvesterHostNetworkConfigMode.vue
Normal file
@ -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;
|
||||
}
|
||||
|
||||
@ -20,12 +20,7 @@ export default {
|
||||
|
||||
computed: {
|
||||
vmiResource() {
|
||||
const vmiList = this.$store.getters['harvester/all'](HCI.VMI) || [];
|
||||
const vmi = vmiList.find( (VMI) => {
|
||||
return VMI?.metadata?.ownerReferences?.[0]?.uid === this.vmResource?.metadata?.uid;
|
||||
});
|
||||
|
||||
return vmi;
|
||||
return this.$store.getters['harvester/byId'](HCI.VMI, this.vmResource?.id) || null;
|
||||
},
|
||||
migrationState() {
|
||||
return this.vmiResource?.migrationState?.status || '';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
24
pkg/harvester/formatters/HarvesterVlan.vue
Normal file
24
pkg/harvester/formatters/HarvesterVlan.vue
Normal file
@ -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>
|
||||
@ -14,20 +14,6 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
allNodeNetwork: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
allClusterNetwork: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
|
||||
@ -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
|
||||
@ -144,6 +165,10 @@ harvester:
|
||||
migration:
|
||||
failedMessage: Latest migration failed!
|
||||
title: Migration
|
||||
vmMigrationTitle: '{count, plural, one {Migrating # VM} other {Migrating # VMs}}'
|
||||
selectedVMs: "The following virtual machine(s) will be migrated to the target node"
|
||||
unknownNode: (unknown node)
|
||||
alreadyOnTarget: Already on Target
|
||||
fields:
|
||||
nodeName:
|
||||
label: Target Node
|
||||
@ -209,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.
|
||||
@ -248,6 +275,7 @@ harvester:
|
||||
suspendSchedule: Suspend
|
||||
restoreExistingVM: Replace Existing
|
||||
migrate: Migrate
|
||||
vmMigrate: Virtual Machine Migration
|
||||
cpuAndMemoryHotplug: Edit CPU and Memory
|
||||
abortMigration: Abort Migration
|
||||
storageMigration: Storage Migration
|
||||
@ -290,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
|
||||
@ -297,7 +331,9 @@ 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
|
||||
actions: Actions
|
||||
@ -336,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
|
||||
@ -349,6 +395,7 @@ harvester:
|
||||
snapshots: Snapshots
|
||||
instanceLabel: Instance Labels
|
||||
annotations: Annotations
|
||||
filesystem: Filesystem Volume
|
||||
fields:
|
||||
version: Version
|
||||
name: Name
|
||||
@ -359,6 +406,7 @@ harvester:
|
||||
network: Network
|
||||
model: Model
|
||||
macAddress: MAC address
|
||||
staticIp: Static IP
|
||||
port: Port
|
||||
protocol: Protocol
|
||||
remove: Remove
|
||||
@ -385,21 +433,6 @@ harvester:
|
||||
middle: vGPU Devices
|
||||
suffix: page.
|
||||
deviceInTheSameHost: 'You can only select devices on the same host.'
|
||||
oldFormatDevices:
|
||||
help: |-
|
||||
<p>
|
||||
The following PCI devices are using the old naming convention and need to be updated in the YAML file:
|
||||
</p>
|
||||
<ul>
|
||||
{oldFormatDevicesHTML}
|
||||
</ul>
|
||||
<p>
|
||||
Please use the following instructions to update the virtual machine:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Stop the virtual machine, edit the virtual machine YAML, and remove the <Code>hostDevices</Code> section, and save virtual machine the changes to the YAML file.</li>
|
||||
<li>Edit the virtual machine, and add the already enabled PCI Device from the list of available PCIDevices, and save and start VM.</li>
|
||||
</ol>
|
||||
showCompatibility: Show device compatibility matrix
|
||||
hideCompatibility: Hide device compatibility matrix
|
||||
claimError: Error enabling passthrough on {name}
|
||||
@ -417,6 +450,8 @@ harvester:
|
||||
suffix: to enable the add-on to successfully manage your PCI devices.
|
||||
noPCIPermission: Please contact your system administrator to enable the PCI devices first.
|
||||
enablePassthroughWarning: Please be careful not to use host-owned PCI devices (e.g., management and VLAN NICs). Incorrect device allocation may cause damage to your cluster, including node failure.
|
||||
disableResourcePooling: Disable Resource Pooling
|
||||
disableResourcePoolingDescription: Assigns this device a unique resource name so it can be pinned to a specific VM, instead of sharing the pool with other identical devices.
|
||||
|
||||
devices:
|
||||
matrixHostName: Host Name
|
||||
@ -453,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.'
|
||||
@ -607,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.
|
||||
@ -726,6 +761,7 @@ harvester:
|
||||
other {Start}
|
||||
} Now
|
||||
createSSHKey: Create a New...
|
||||
genericLoginError: Authentication failed. Please re-log in and try again.
|
||||
installAgent: Install guest agent
|
||||
enableUsb: Enable USB Tablet
|
||||
advancedOptions:
|
||||
@ -745,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
|
||||
@ -761,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.
|
||||
@ -780,7 +821,7 @@ harvester:
|
||||
addPort: Add Port
|
||||
cloudConfig:
|
||||
title: Cloud Configuration
|
||||
createTemplateTitle: 'Create {name}.'
|
||||
createTemplateTitle: 'Create {name}'
|
||||
createNew: Create new...
|
||||
cloudInit:
|
||||
label: Cloud Init
|
||||
@ -793,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'
|
||||
@ -823,6 +875,18 @@ harvester:
|
||||
username: Username
|
||||
password: Password
|
||||
reservedMemory: Reserved Memory
|
||||
customizeDisplayName: Customize virtual machine display name
|
||||
displayNamePlaceholder: Virtual machine alias name
|
||||
displayName: Display Name
|
||||
filesystem:
|
||||
description: Harvester supports filesystem volumes for VM via virtiofs.
|
||||
type: Filesystem Type
|
||||
volume: Volume
|
||||
resource: Resource
|
||||
add: Add
|
||||
mountBannerHint: "Please update the mount path (e.g. /mnt/appconfigfs) to your preferred location, then add the corresponding commands to the"
|
||||
mountBannerHintLink: "runcmd"
|
||||
mountBannerHintSuffix: "in Advanced tab User Data."
|
||||
machineTypeTip: 'Specify a processor architecture to emulate. To see a list of supported architectures, run: qemu-system-x86_64 -cpu ?'
|
||||
detail:
|
||||
tabs:
|
||||
@ -1039,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.
|
||||
@ -1111,6 +1177,7 @@ harvester:
|
||||
replaceExisting: Replace existing
|
||||
virtualMachineName: Virtual Machine Name
|
||||
keepMacAddress: Keep MAC Address
|
||||
haltAfterRestore: Keep powered off after restore
|
||||
matchTarget: The current backup target does not match the existing one.
|
||||
progress:
|
||||
details: Volume details
|
||||
@ -1141,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
|
||||
@ -1148,6 +1218,9 @@ harvester:
|
||||
placeholder: key1=value1, key2=value2
|
||||
dhcpOptionBanner: DHCP options is a key/value string concatenate with comma. For more detail, please refer to <a href="https://kubeovn.github.io/docs/v1.13.x/en/kubevirt/dhcp/" target="_blank">KubOVN document</a>
|
||||
tooltip: Enable DHCP server for this subnet. When enabled, VMs can automatically obtain IP addresses from this subnet.
|
||||
externalConnectivity:
|
||||
label: NAT Outgoing
|
||||
tooltip: Enable NAT for VMs using this subnet to access external networks.
|
||||
private:
|
||||
label: Private Subnet
|
||||
tooltip: Enable network isolation for this Subnet. When enabled, VMs can only communicate within this subnet, even if other subnets exist under the same VPC.
|
||||
@ -1223,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
|
||||
@ -1285,6 +1441,13 @@ harvester:
|
||||
rancherCluster:
|
||||
kubeConfig: Rancher KubeConfig
|
||||
removeUpstreamClusterWhenNamespaceIsDeleted: Remove Upstream Cluster When Namespace Is Deleted
|
||||
clusterPodSecurityStandard:
|
||||
whitelistedNamespaces:
|
||||
label: 'Whitelisted Namespaces'
|
||||
privilegedNamespaces:
|
||||
label: 'Privileged Namespaces'
|
||||
restrictedNamespaces:
|
||||
label: 'Restricted Namespaces'
|
||||
storageNetwork:
|
||||
range:
|
||||
placeholder: e.g. 172.16.0.0/24
|
||||
@ -1292,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
|
||||
@ -1508,6 +1672,7 @@ harvester:
|
||||
vmSnapshot:
|
||||
label: Virtual Machine Snapshots
|
||||
createText: Restore Snapshot
|
||||
title: Restore Virtual Machine
|
||||
snapshot: Snapshot
|
||||
|
||||
storage:
|
||||
@ -1515,6 +1680,7 @@ harvester:
|
||||
useDefault: Use the default storage
|
||||
volumeEncryption: Volume Encryption
|
||||
secret: Secret
|
||||
volumeExpansionCheckbox: Enable Expansion
|
||||
migratable:
|
||||
label: Migratable
|
||||
numberOfReplicas:
|
||||
@ -1607,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
|
||||
@ -1670,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
|
||||
@ -1764,6 +2147,8 @@ harvester:
|
||||
repository: Image Repository
|
||||
driver:
|
||||
location: Driver Location
|
||||
enable:
|
||||
title: Enable NVIDIA Driver Toolkit
|
||||
parsingSpecError:
|
||||
The field 'spec.valuesContent' has invalid format.
|
||||
usbController:
|
||||
@ -1848,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:
|
||||
@ -1890,9 +2301,17 @@ 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
|
||||
status:
|
||||
outOfSync: Out of Sync
|
||||
infoBanner: To configure the MIG configuration, please disable it first and re-enable after editing the configuration.
|
||||
profileSpec: Profile Specs
|
||||
profileStatus: Profile Status
|
||||
@ -1955,6 +2374,7 @@ harvester:
|
||||
title: Cannot Disable Passthrough
|
||||
message: Please detach the device from the VM and save it first before disabling passthrough.
|
||||
enablePassthroughWarning: 'Please re-enable the USB device if the device path changes in the following situations:<br/> 1) Re-plugging the USB device.<br/> 2) Rebooting the node.<br/><br/>An incorrect device path may cause passthrough to fail.'
|
||||
classType: Class Type
|
||||
|
||||
harvesterVlanConfigMigrateDialog:
|
||||
targetClusterNetwork:
|
||||
@ -2032,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.'
|
||||
@ -2039,6 +2461,7 @@ advancedSettings:
|
||||
'harv-max-hotplug-ratio': 'The ratio for kubevirt to limit the maximum CPU and memory that can be hotplugged to a VM. The value could be an integer between 1 and 20, default to 4.'
|
||||
'harv-kubevirt-migration': 'Configure cluster-wide KubeVirt live migration parameters.'
|
||||
'harv-instance-manager-resources': 'Configure resource percentage reservations for Longhorn instance manager V1 and V2. Valid instance manager CPU range between 0 - 40.'
|
||||
'harv-cluster-pod-security-standard': 'Enforce Kubernetes Pod Security Standards (PSS) at the cluster level.'
|
||||
|
||||
typeLabel:
|
||||
kubevirt.io.virtualmachine: |-
|
||||
@ -2101,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 }
|
||||
@ -2161,16 +2614,17 @@ typeLabel:
|
||||
one { PCI Device }
|
||||
other { PCI Devices }
|
||||
}
|
||||
persistentvolumeclaim: |-
|
||||
{count, plural,
|
||||
one { Volume }
|
||||
other { Volumes }
|
||||
}
|
||||
|
||||
network.harvesterhci.io.clusternetwork: |-
|
||||
{count, plural,
|
||||
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 }
|
||||
@ -2240,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>
|
||||
|
||||
@ -3,7 +3,6 @@ import { Banner } from '@components/Banner';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import ResourceTable from '@shell/components/ResourceTable';
|
||||
import BadgeState from '@shell/components/formatter/BadgeStateFormatter';
|
||||
|
||||
import { NAME, AGE, NAMESPACE, STATE } from '@shell/config/table-headers';
|
||||
import { NETWORK_ATTACHMENT, SCHEMA } from '@shell/config/types';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
@ -113,6 +112,7 @@ export default {
|
||||
value: 'connectivity',
|
||||
labelKey: 'tableHeaders.routeConnectivity',
|
||||
formatter: 'NetworkRouteConnectivity',
|
||||
tooltip: 'harvester.tableHeaders.routeConnectivityTooltip',
|
||||
formatterOpts: { arbitrary: true },
|
||||
width: 130,
|
||||
},
|
||||
@ -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 {
|
||||
@ -12,11 +13,18 @@ import { HCI } from '../types';
|
||||
import HarvesterVmState from '../formatters/HarvesterVmState';
|
||||
import ConsoleBar from '../components/VMConsoleBar';
|
||||
|
||||
const ENCRYPTED_VOLUME_TOOLTIP_KEYS = {
|
||||
all: 'harvester.virtualMachine.volume.lockTooltip.all',
|
||||
partial: 'harvester.virtualMachine.volume.lockTooltip.partial',
|
||||
};
|
||||
|
||||
export const VM_HEADERS = [
|
||||
STATE,
|
||||
{
|
||||
...NAME,
|
||||
width: 350,
|
||||
value: 'nameDisplay',
|
||||
sort: ['nameDisplay'],
|
||||
},
|
||||
NAMESPACE,
|
||||
{
|
||||
@ -93,19 +101,9 @@ export default {
|
||||
this.hasNode = true;
|
||||
}
|
||||
|
||||
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.NODE_NETWORK)) {
|
||||
_hash.nodeNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.NODE_NETWORK });
|
||||
}
|
||||
|
||||
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.CLUSTER_NETWORK)) {
|
||||
_hash.clusterNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK });
|
||||
}
|
||||
|
||||
const hash = await allHash(_hash);
|
||||
|
||||
this.allVMs = hash.vms;
|
||||
this.allNodeNetworks = hash.nodeNetworks || [];
|
||||
this.allClusterNetworks = hash.clusterNetworks || [];
|
||||
},
|
||||
|
||||
data() {
|
||||
@ -113,14 +111,14 @@ export default {
|
||||
hasNode: false,
|
||||
allVMs: [],
|
||||
allVMIs: [],
|
||||
allNodeNetworks: [],
|
||||
allClusterNetworks: [],
|
||||
restartNotificationDisplayed: false,
|
||||
HCI
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ actionCb: 'action-menu/performCallbackData' }),
|
||||
|
||||
headers() {
|
||||
const restoreCol = {
|
||||
name: 'restoreProgress',
|
||||
@ -163,6 +161,12 @@ export default {
|
||||
*/
|
||||
hasBackUpRestoreInProgress() {
|
||||
return !!this.rows.find((r) => r.restoreResource && !r.restoreResource.fromSnapshot && !r.restoreResource.isComplete);
|
||||
},
|
||||
|
||||
vmRestartRequiredNames() {
|
||||
return this.allVMs
|
||||
.filter((vm) => vm.isRestartRequired)
|
||||
.map((vm) => vm.metadata.name);
|
||||
}
|
||||
},
|
||||
|
||||
@ -181,53 +185,42 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
allVMs: {
|
||||
handler(neu) {
|
||||
const vmNames = [];
|
||||
actionCb(neu) {
|
||||
if (neu?.clearTableSelection) {
|
||||
this.$refs.resourceTable.clearSelection();
|
||||
this.$store.dispatch('action-menu/clearCallbackData');
|
||||
}
|
||||
},
|
||||
|
||||
neu.forEach((vm) => {
|
||||
if (vm.isRestartRequired) {
|
||||
vmNames.push(vm.metadata.name);
|
||||
}
|
||||
});
|
||||
const count = vmNames.length;
|
||||
vmRestartRequiredNames(vmNames) {
|
||||
const count = vmNames.length;
|
||||
|
||||
if ( count === 0 && this.restartNotificationDisplayed) {
|
||||
this.restartNotificationDisplayed = false;
|
||||
if (count === 0 && this.restartNotificationDisplayed) {
|
||||
this.restartNotificationDisplayed = false;
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
// clear old notification before showing new one
|
||||
if (this.restartNotificationDisplayed) {
|
||||
this.$store.dispatch('growl/clear');
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
// clear old notification before showing new one
|
||||
if (this.restartNotificationDisplayed) {
|
||||
this.$store.dispatch('growl/clear');
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0 && vmNames.length > 0) {
|
||||
this.$store.dispatch('growl/warning', {
|
||||
title: this.t('harvester.notification.restartRequired.title', { count }),
|
||||
message: this.t('harvester.notification.restartRequired.message', { vmNames: vmNames.join(', ') }),
|
||||
timeout: 10000,
|
||||
}, { root: true });
|
||||
this.restartNotificationDisplayed = true;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
this.$store.dispatch('growl/warning', {
|
||||
title: this.t('harvester.notification.restartRequired.title', { count }),
|
||||
message: this.t('harvester.notification.restartRequired.message', { vmNames: vmNames.join(', ') }),
|
||||
timeout: 10000,
|
||||
}, { root: true });
|
||||
this.restartNotificationDisplayed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
lockIconTooltipMessage(row) {
|
||||
const message = '';
|
||||
const key = ENCRYPTED_VOLUME_TOOLTIP_KEYS[row.encryptedVolumeType];
|
||||
|
||||
if (row.encryptedVolumeType === 'all') {
|
||||
return this.t('harvester.virtualMachine.volume.lockTooltip.all');
|
||||
} else if (row.encryptedVolumeType === 'partial') {
|
||||
return this.t('harvester.virtualMachine.volume.lockTooltip.partial');
|
||||
}
|
||||
|
||||
return message;
|
||||
return key ? this.t(key) : '';
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -237,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"
|
||||
@ -253,8 +247,6 @@ export default {
|
||||
<HarvesterVmState
|
||||
class="vmstate"
|
||||
:row="scope.row"
|
||||
:all-node-network="allNodeNetworks"
|
||||
:all-cluster-network="allClusterNetworks"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -265,16 +257,16 @@ export default {
|
||||
v-if="scope.row.type !== HCI.VMI"
|
||||
:to="scope.row.detailLocation"
|
||||
>
|
||||
{{ scope.row.metadata.name }}
|
||||
{{ scope.row.nameDisplay }}
|
||||
<i
|
||||
v-if="lockIconTooltipMessage(scope.row)"
|
||||
v-if="scope.row.encryptedVolumeType !== 'none'"
|
||||
v-tooltip="lockIconTooltipMessage(scope.row)"
|
||||
class="icon icon-lock"
|
||||
:class="{'green-icon': scope.row.encryptedVolumeType === 'all', 'yellow-icon': scope.row.encryptedVolumeType === 'partial'}"
|
||||
/>
|
||||
</router-link>
|
||||
<span v-else>
|
||||
{{ scope.row.metadata.name }}
|
||||
{{ scope.row.nameDisplay }}
|
||||
</span>
|
||||
<ConsoleBar
|
||||
:resource-type="scope.row"
|
||||
|
||||
@ -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' && /^\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;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user