mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 20:59:16 +00:00
Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63be73b11 | ||
|
|
fabc1cec21 | ||
|
|
ed2b6f2551 | ||
|
|
72282ae50e | ||
|
|
8fba4e7050 | ||
|
|
fbd4a1108f | ||
|
|
823a88c85b | ||
|
|
3a2c6e3a3d | ||
|
|
be6e9b5f06 | ||
|
|
a88bed9eb4 | ||
|
|
f97f14c811 | ||
|
|
f8a29a1c54 | ||
|
|
9a935434c2 | ||
|
|
afe632b723 | ||
|
|
d050f21f9a | ||
|
|
b87f367583 | ||
|
|
92aa8ec603 | ||
|
|
e013534373 | ||
|
|
d23c3d3b72 | ||
|
|
719c33fa12 | ||
|
|
a6bd7ec00e | ||
|
|
ac26fe2f0e | ||
|
|
790cade04d | ||
|
|
2903e86c02 | ||
|
|
2d747e435a | ||
|
|
eadda2a18b | ||
|
|
a178302f73 | ||
|
|
d376bfb3a5 | ||
|
|
7a84e08f99 | ||
|
|
3d39626b93 | ||
|
|
1c3b40b631 | ||
|
|
87baf75301 | ||
|
|
a10d651827 | ||
|
|
11ff7c8a7c | ||
|
|
f6dffe9816 | ||
|
|
b813bc578e | ||
|
|
7c3b2730ab | ||
|
|
429d9e18ad | ||
|
|
dc166d8fc9 | ||
|
|
3ff4534294 | ||
|
|
d4223ad2ae | ||
|
|
44297ada16 | ||
|
|
aea53fdd65 | ||
|
|
9640c87947 | ||
|
|
2cefd7cb78 | ||
|
|
fb6ffa3c0f | ||
|
|
e0436bff28 | ||
|
|
219f39ae52 | ||
|
|
41cf44d231 | ||
|
|
7d09cb7142 | ||
|
|
560643d299 | ||
|
|
c3bb0ea4be | ||
|
|
3c37f04ef6 | ||
|
|
d98db6ba49 | ||
|
|
531ed03796 | ||
|
|
59f260eab8 | ||
|
|
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 |
3
.github/actions/setup/action.yaml
vendored
3
.github/actions/setup/action.yaml
vendored
@ -4,7 +4,8 @@ description: Setup node and install dependencies
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Setup Nodejs with yarn caching
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
|
||||
6
.github/auto-assign-config.yaml
vendored
6
.github/auto-assign-config.yaml
vendored
@ -1,6 +0,0 @@
|
||||
addAssignees: author
|
||||
addReviewers: true
|
||||
numberOfReviewers: 0
|
||||
reviewers:
|
||||
- a110605
|
||||
- houhoucoop
|
||||
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 -->
|
||||
|
||||
|
||||
|
||||
18
.github/renovate.json
vendored
18
.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
|
||||
@ -34,14 +41,17 @@
|
||||
{
|
||||
"matchUpdateTypes": ["minor"],
|
||||
"groupName": "minor dependencies",
|
||||
"minimumReleaseAge": "7 days",
|
||||
"labels": ["minor-update"],
|
||||
"reviewers": ["a110605", "houhoucoop"]
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["patch", "digest"],
|
||||
"automerge": true,
|
||||
"groupName": "patch digest dependencies",
|
||||
"labels": ["patch-update", "automerge"]
|
||||
"matchUpdateTypes": ["patch"],
|
||||
"automerge": false,
|
||||
"minimumReleaseAge": "7 days",
|
||||
"groupName": "patch dependencies",
|
||||
"labels": ["patch-update"],
|
||||
"reviewers": ["a110605", "houhoucoop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
39
.github/workflows/add-pr-label-check.yaml
vendored
Normal file
39
.github/workflows/add-pr-label-check.yaml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: "PR Management Add PR Label Collect Data"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, edited]
|
||||
branches:
|
||||
- main
|
||||
- 'release-harvester-v*'
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
|
||||
- name: Setup Nodejs and yarn install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Extract PR label
|
||||
run: |
|
||||
PR_LABEL=$(node ./scripts/extract-release-label.mjs "${{ github.event.pull_request.title }}")
|
||||
echo "PR_LABEL=$PR_LABEL"
|
||||
{
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||
echo "PR_LABEL=$PR_LABEL"
|
||||
} > pr-add-label-data.env
|
||||
|
||||
- name: Upload PR data artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pr-add-label-data
|
||||
path: pr-add-label-data.env
|
||||
33
.github/workflows/add-pr-label.yaml
vendored
Normal file
33
.github/workflows/add-pr-label.yaml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: "PR Management Add PR Label"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "PR Management Add PR Label Collect Data"
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
auto-assign-pr-label:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download PR data artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: pr-add-label-data
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Load PR data
|
||||
run: |
|
||||
cat pr-add-label-data.env >> $GITHUB_ENV
|
||||
|
||||
- name: Set PR label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "PR_LABEL = $PR_LABEL"
|
||||
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "$PR_LABEL"
|
||||
28
.github/workflows/auto-assign-check.yaml
vendored
Normal file
28
.github/workflows/auto-assign-check.yaml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: "PR Management Auto Assign Collect Data"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review]
|
||||
branches:
|
||||
- main
|
||||
- 'release-harvester-v*'
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Save PR data to artifact
|
||||
run: |
|
||||
{
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||
echo "PR_AUTHOR=${{ github.event.pull_request.user.login }}"
|
||||
} > pr-auto-assign-data.env
|
||||
|
||||
- name: Upload PR data artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pr-auto-assign-data
|
||||
path: pr-auto-assign-data.env
|
||||
17
.github/workflows/auto-assign-reviewer.yaml
vendored
17
.github/workflows/auto-assign-reviewer.yaml
vendored
@ -1,17 +0,0 @@
|
||||
name: "[PR Management] Auto Assign Reviewer & Assignee"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: rancher/gh-issue-mgr/auto-assign-action@main
|
||||
with:
|
||||
configuration-path: .github/auto-assign-config.yaml
|
||||
35
.github/workflows/auto-assign.yaml
vendored
Normal file
35
.github/workflows/auto-assign.yaml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: "PR Management Auto Assign"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "PR Management Auto Assign Collect Data"
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
env:
|
||||
REVIEWERS: "a110605,houhoucoop"
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download PR data artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: pr-auto-assign-data
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Load PR data
|
||||
run: |
|
||||
cat pr-auto-assign-data.env >> $GITHUB_ENV
|
||||
|
||||
- name: Auto assign PR author
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Assigning PR author: $PR_AUTHOR"
|
||||
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-assignee "$PR_AUTHOR" --add-reviewer "$REVIEWERS"
|
||||
30
.github/workflows/backport-label-check.yaml
vendored
Normal file
30
.github/workflows/backport-label-check.yaml
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
name: "PR Management Add Labels Collect Data"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
- 'release-harvester-v*'
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Save PR data to artifact
|
||||
run: |
|
||||
{
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||
echo "PR_BASE_REF=${{ github.event.pull_request.base.ref }}"
|
||||
echo "PR_USER_LOGIN=${{ github.event.pull_request.user.login }}"
|
||||
} > pr-backport-label-data.env
|
||||
|
||||
- name: Upload PR data artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pr-backport-label-data
|
||||
path: pr-backport-label-data.env
|
||||
99
.github/workflows/backport-label.yaml
vendored
99
.github/workflows/backport-label.yaml
vendored
@ -1,40 +1,44 @@
|
||||
name: "[PR Management] Add Labels"
|
||||
name: "PR Management Add Backport Labels"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
- 'release-harvester-v*'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "PR Management Add Labels Collect Data"
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
add-require-backport-label:
|
||||
if: github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.base.ref == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Download PR data artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
name: pr-backport-label-data
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Fetch release branches and PR labels
|
||||
id: fetch_info
|
||||
- name: Load PR data
|
||||
run: |
|
||||
cat pr-backport-label-data.env >> $GITHUB_ENV
|
||||
|
||||
- name: Add require-backport label (main branch PRs)
|
||||
if: env.PR_BASE_REF == 'main'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
repo="${{ github.repository }}"
|
||||
pr_number=${{ github.event.pull_request.number }}
|
||||
pr_number="$PR_NUMBER"
|
||||
|
||||
release_branches=$(gh api "repos/${repo}/branches" --paginate --jq '.[].name' | grep -E '^release-harvester-v[0-9]+\.[0-9]+$' || true)
|
||||
|
||||
if [[ -z "$release_branches" ]]; then
|
||||
echo "should_label=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No release branches found, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@ -44,51 +48,36 @@ jobs:
|
||||
|
||||
tags=$(gh api "repos/${repo}/releases" --paginate --jq '.[].tag_name')
|
||||
if echo "$tags" | grep -Fxq "$release_tag"; then
|
||||
echo "should_label=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Release $release_tag already published, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
label="require backport/v${version}"
|
||||
echo "should_label=true" >> "$GITHUB_OUTPUT"
|
||||
echo "backport_label=$label" >> "$GITHUB_OUTPUT"
|
||||
|
||||
pr_labels=$(gh pr view "$pr_number" --repo "$repo" --json labels --jq '.labels[].name' || echo "")
|
||||
pr_labels_csv=$(echo "$pr_labels" | tr '\n' ',' | sed 's/,$//')
|
||||
echo "pr_labels=$pr_labels_csv" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add label if needed
|
||||
if: steps.fetch_info.outputs.should_label == 'true' && !contains(steps.fetch_info.outputs.pr_labels, steps.fetch_info.outputs.backport_label)
|
||||
if echo "$pr_labels" | grep -Fxq "$label"; then
|
||||
echo "Label '$label' already present, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Adding label: $label"
|
||||
gh pr edit "$pr_number" --repo "$repo" --add-label "$label"
|
||||
|
||||
- name: Add backport label (release branch PRs opened by Mergify)
|
||||
if: startsWith(env.PR_BASE_REF, 'release-harvester-v')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Adding label: ${{ steps.fetch_info.outputs.backport_label }}"
|
||||
gh pr edit ${{ github.event.pull_request.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--add-label "${{ steps.fetch_info.outputs.backport_label }}"
|
||||
set -euo pipefail
|
||||
|
||||
add-backport-label:
|
||||
if: github.event.pull_request.draft == false &&
|
||||
startsWith(github.event.pull_request.base.ref, 'release-harvester-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check conditions for backport label
|
||||
id: check
|
||||
run: |
|
||||
IS_MERGIFY=$(echo '${{ github.event.pull_request.user.login }}' | grep -iq 'mergify' && echo true || echo false)
|
||||
TARGET_BRANCH=${{ github.event.pull_request.base.ref }}
|
||||
IS_MERGIFY=$(echo "$PR_USER_LOGIN" | grep -iq 'mergify' && echo true || echo false)
|
||||
|
||||
echo "IS_MERGIFY=$IS_MERGIFY" >> $GITHUB_OUTPUT
|
||||
echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_OUTPUT
|
||||
if [[ "$IS_MERGIFY" != "true" ]]; then
|
||||
echo "PR author is not Mergify, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Add label if needed
|
||||
if: steps.check.outputs.IS_MERGIFY == 'true' && startsWith(steps.check.outputs.TARGET_BRANCH, 'release-harvester-v')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TARGET_BRANCH="${{ steps.check.outputs.TARGET_BRANCH }}"
|
||||
version="${TARGET_BRANCH#release-harvester-v}"
|
||||
version="${PR_BASE_REF#release-harvester-v}"
|
||||
label="backport/v${version}"
|
||||
echo "Adding label $label"
|
||||
gh pr edit ${{ github.event.pull_request.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--add-label "$label"
|
||||
echo "Adding label: $label"
|
||||
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "$label"
|
||||
|
||||
28
.github/workflows/backport-request-check.yaml
vendored
Normal file
28
.github/workflows/backport-request-check.yaml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: "PR Management Request Backport via Mergify Collect Data"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Save PR data to artifact
|
||||
run: |
|
||||
labels_json='${{ toJson(github.event.pull_request.labels.*.name) }}'
|
||||
{
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||
echo "PR_LABELS=$(echo "$labels_json" | jq -r '[.[]] | join(",")')"
|
||||
} > pr-backport-request-data.env
|
||||
|
||||
- name: Upload PR data artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pr-backport-request-data
|
||||
path: pr-backport-request-data.env
|
||||
38
.github/workflows/backport-request.yaml
vendored
38
.github/workflows/backport-request.yaml
vendored
@ -1,41 +1,47 @@
|
||||
name: "[PR Management] Request Backport via Mergify"
|
||||
name: "PR Management Request Backport via Mergify"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "PR Management Request Backport via Mergify Collect Data"
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
comment-backport:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Download PR data artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
name: pr-backport-request-data
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Load PR data
|
||||
run: |
|
||||
cat pr-backport-request-data.env >> $GITHUB_ENV
|
||||
|
||||
- name: Post Mergify backport command
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pr_number=${{ github.event.pull_request.number }}
|
||||
pr_number="$PR_NUMBER"
|
||||
repo="${{ github.repository }}"
|
||||
|
||||
labels_json='${{ toJson(github.event.pull_request.labels.*.name) }}'
|
||||
labels=$(echo "$labels_json" | jq -r '.[] // empty')
|
||||
labels=$(echo "$PR_LABELS" | tr ',' '\n')
|
||||
|
||||
echo "Labels on PR: $labels"
|
||||
|
||||
matches=$(echo "$labels" | grep -oE '^require backport/v[0-9]+\.[0-9]+$' || true)
|
||||
|
||||
if [[ -z "$matches" ]]; then
|
||||
echo "No back‑port labels found — skipping."
|
||||
echo "No back-port labels found — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Check package version
|
||||
env:
|
||||
|
||||
@ -25,12 +25,12 @@ jobs:
|
||||
name: Build & Upload Hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# Note - Cannot use the setup action here as it uses a different yarn install arg
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
@ -45,19 +45,20 @@ jobs:
|
||||
run: ./scripts/build-upload-gate
|
||||
|
||||
- name: Get gcs auth
|
||||
uses: rancher-eio/read-vault-secrets@main
|
||||
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
|
||||
with:
|
||||
secrets: |
|
||||
secret/data/github/repo/${{ github.repository }}/google-auth/harvester/credentials token | GOOGLE_AUTH ;
|
||||
|
||||
- name: Apply gcs auth
|
||||
# https://github.com/google-github-actions/auth
|
||||
uses: 'google-github-actions/auth@v2'
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
|
||||
|
||||
with:
|
||||
credentials_json: "${{ env.GOOGLE_AUTH }}"
|
||||
|
||||
- name: Upload build
|
||||
uses: 'google-github-actions/upload-cloud-storage@v2'
|
||||
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
|
||||
# https://github.com/google-github-actions/upload-cloud-storage
|
||||
with:
|
||||
path: ${{steps.build-hosted.outputs.BUILD_HOSTED_DIR}}
|
||||
@ -71,12 +72,12 @@ jobs:
|
||||
name: Build & Upload Embedded
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# Note - Cannot use the setup action here as it uses a different yarn install arg
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
@ -89,19 +90,19 @@ jobs:
|
||||
DISABLED_EMBED_PKG: https://releases.rancher.com/harvester-ui/plugin/harvester-1.0.3.tar.gz
|
||||
|
||||
- name: Get gcs auth
|
||||
uses: rancher-eio/read-vault-secrets@main
|
||||
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
|
||||
with:
|
||||
secrets: |
|
||||
secret/data/github/repo/${{ github.repository }}/google-auth/harvester/credentials token | GOOGLE_AUTH ;
|
||||
|
||||
- name: Apply gcs auth
|
||||
# https://github.com/google-github-actions/auth
|
||||
uses: 'google-github-actions/auth@v2'
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
|
||||
with:
|
||||
credentials_json: "${{ env.GOOGLE_AUTH }}"
|
||||
|
||||
- name: Upload tar
|
||||
uses: 'google-github-actions/upload-cloud-storage@v2'
|
||||
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
|
||||
with:
|
||||
path: ${{steps.build-embedded.outputs.BUILD_EMBEDED_TGZ}}
|
||||
destination: releases.rancher.com/harvester-ui/dashboard
|
||||
@ -114,12 +115,12 @@ jobs:
|
||||
name: Build & Upload Plugin
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# Note - Cannot use the setup action here as it uses a different yarn install arg
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
@ -133,19 +134,19 @@ jobs:
|
||||
run: ./scripts/build-upload-gate
|
||||
|
||||
- name: Get gcs auth
|
||||
uses: rancher-eio/read-vault-secrets@main
|
||||
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
|
||||
with:
|
||||
secrets: |
|
||||
secret/data/github/repo/${{ github.repository }}/google-auth/harvester/credentials token | GOOGLE_AUTH ;
|
||||
|
||||
- name: Apply gcs auth
|
||||
# https://github.com/google-github-actions/auth
|
||||
uses: 'google-github-actions/auth@v2'
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
|
||||
with:
|
||||
credentials_json: "${{ env.GOOGLE_AUTH }}"
|
||||
|
||||
- name: Upload plugin tar
|
||||
uses: 'google-github-actions/upload-cloud-storage@v2'
|
||||
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
|
||||
with:
|
||||
path: dist-pkg/${{steps.ci-build-pkg.outputs.PKG_TARBALL}}
|
||||
destination: releases.rancher.com/harvester-ui/plugin
|
||||
@ -155,7 +156,7 @@ jobs:
|
||||
process_gcloudignore: false
|
||||
|
||||
- name: Upload plugin directory
|
||||
uses: 'google-github-actions/upload-cloud-storage@v2'
|
||||
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
|
||||
with:
|
||||
path: dist-pkg/${{steps.ci-build-pkg.outputs.PKG_NAME}}
|
||||
destination: releases.rancher.com/harvester-ui/plugin/${{steps.ci-build-pkg.outputs.PKG_NAME}}
|
||||
|
||||
20
.github/workflows/build-extension-catalog.yml
vendored
20
.github/workflows/build-extension-catalog.yml
vendored
@ -27,14 +27,14 @@ jobs:
|
||||
build-status: ${{ job.status }}
|
||||
steps:
|
||||
- name: Read Secrets
|
||||
uses: rancher-eio/read-vault-secrets@main
|
||||
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
|
||||
with:
|
||||
secrets: |
|
||||
secret/data/github/repo/${{ github.repository }}/dockerhub/rancher/credentials username | DOCKER_USERNAME ;
|
||||
secret/data/github/repo/${{ github.repository }}/dockerhub/rancher/credentials password | DOCKER_PASSWORD ;
|
||||
|
||||
- name: Checkout repository (normal flow)
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@ -45,26 +45,20 @@ jobs:
|
||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v3
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3
|
||||
with:
|
||||
version: v3.8.0
|
||||
|
||||
- name: Setup Nodejs with yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- name: Setup Nodejs with yarn install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build and push UI image
|
||||
run: |
|
||||
publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}"
|
||||
$publish
|
||||
$publish
|
||||
|
||||
24
.github/workflows/build-extension-on-merge.yml
vendored
24
.github/workflows/build-extension-on-merge.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
target_branch: ${{ steps.get-version.outputs.target_branch }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Determine target branch
|
||||
id: get-version
|
||||
@ -44,7 +44,7 @@ jobs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: get_version
|
||||
@ -62,7 +62,13 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Node from .nvmrc
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Setup environment
|
||||
run: |
|
||||
@ -70,7 +76,7 @@ jobs:
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v3
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3
|
||||
with:
|
||||
version: v3.8.0
|
||||
|
||||
@ -79,7 +85,7 @@ jobs:
|
||||
yarn publish-pkgs -s ${{ github.repository }} -b ${{ needs.setup-target-branch.outputs.target_branch }} -t harvester-${{ needs.extract-version.outputs.version }}
|
||||
|
||||
- name: Upload charts artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: charts
|
||||
path: tmp
|
||||
@ -94,7 +100,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: '${{ github.ref_name }}'
|
||||
|
||||
@ -105,7 +111,7 @@ jobs:
|
||||
echo "LAST_COMMIT=${LAST_COMMIT}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout target branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: '${{ needs.setup-target-branch.outputs.target_branch }}'
|
||||
|
||||
@ -121,7 +127,7 @@ jobs:
|
||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: charts
|
||||
|
||||
@ -132,7 +138,7 @@ jobs:
|
||||
git push origin ${{ needs.setup-target-branch.outputs.target_branch }}
|
||||
|
||||
- name: Run Helm chart releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||
with:
|
||||
charts_dir: ./charts
|
||||
env:
|
||||
|
||||
10
.github/workflows/build-extension-on-release.yml
vendored
10
.github/workflows/build-extension-on-release.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
release_tag: ${{ steps.determine_tag.outputs.release_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Determine release tag
|
||||
id: determine_tag
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Check package version
|
||||
env:
|
||||
TAG_VERSION: ${{ github.event.release.tag_name }}
|
||||
@ -43,12 +43,10 @@ jobs:
|
||||
needs:
|
||||
- setup-release-tag
|
||||
- check-version
|
||||
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@master
|
||||
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
deployments: write
|
||||
pages: write
|
||||
pull-requests: write
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
|
||||
|
||||
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"
|
||||
|
||||
@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Check package version
|
||||
env:
|
||||
TAG_VERSION: ${{github.ref_name}}
|
||||
|
||||
4
.github/workflows/fossa.yml
vendored
4
.github/workflows/fossa.yml
vendored
@ -20,13 +20,13 @@ jobs:
|
||||
# The FOSSA token is shared between all repos in Harvester's GH org. It can
|
||||
# be used directly and there is no need to request specific access to EIO.
|
||||
- name: Read FOSSA token
|
||||
uses: rancher-eio/read-vault-secrets@main
|
||||
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
|
||||
with:
|
||||
secrets: |
|
||||
secret/data/github/org/harvester/fossa/credentials token | FOSSA_API_KEY_PUSH_ONLY
|
||||
|
||||
- name: FOSSA scan
|
||||
uses: fossas/fossa-action@main
|
||||
uses: fossas/fossa-action@ff70fe9fe17cbd2040648f1c45e8ec4e4884dcf3 # v1.9.0
|
||||
with:
|
||||
api-key: ${{ env.FOSSA_API_KEY_PUSH_ONLY }}
|
||||
# Only runs the scan and do not provide/returns any results back to the
|
||||
|
||||
30
.github/workflows/release-label.yaml
vendored
30
.github/workflows/release-label.yaml
vendored
@ -1,30 +0,0 @@
|
||||
name: "[PR Management] Add PR Label"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited]
|
||||
branches:
|
||||
- main
|
||||
- 'release-harvester-v*'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-assign-pr-label:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
- name: Setup Nodejs and yarn install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Set PR label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_LABEL=$(node ./scripts/extract-release-label.mjs "${{ github.event.pull_request.title }}")
|
||||
echo "PR_LABEL = $PR_LABEL"
|
||||
gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --add-label "$PR_LABEL"
|
||||
2
.github/workflows/run-lint.yaml
vendored
2
.github/workflows/run-lint.yaml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0 # Need full history for commit-lint
|
||||
|
||||
|
||||
261
AGENTS.md
Normal file
261
AGENTS.md
Normal file
@ -0,0 +1,261 @@
|
||||
> 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
|
||||
|
||||
The Harvester UI Extension is a Rancher extension that provides the user interface for Harvester within the Rancher Dashboard.
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
|
||||
# Personas
|
||||
|
||||
## Software Developer
|
||||
|
||||
You are an expert Senior Software Engineer specializing in Vue.js and TypeScript. You have deep knowledge of Kubernetes and the Rancher ecosystem.
|
||||
- **Focus**: Writing clean, maintainable, and performant code.
|
||||
- **Priorities**: Adhering to the project's code style, ensuring type safety, and following best practices for component design.
|
||||
|
||||
|
||||
# Agents
|
||||
|
||||
## Boundaries
|
||||
|
||||
- ✅ **Always:**
|
||||
- Write to `.github`, `docs/`, `pkg/harvester`, `scripts/`.
|
||||
- Make commits in a new branch (for a PR).
|
||||
- Run `yarn lint:fix` before commits.
|
||||
- Use conventional commit format:
|
||||
```
|
||||
<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.
|
||||
- After updating files in `docs/agents.md/`, run `yarn agents:generate` to update root `AGENTS.md`.
|
||||
- ⚠️ **Ask first:**
|
||||
- Adding dependencies
|
||||
- Upgrading dependencies
|
||||
- 🚫 **Never:**
|
||||
- Commit or log secrets, `.env`, `kubeconfig` or any API keys.
|
||||
- Edit `node_modules/`.
|
||||
- Commit directly to `main` (use PRs).
|
||||
- Skip git hooks with `--no-verify` flag.
|
||||
|
||||
## Tools
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: `>= 24.0.0` (see `.nvmrc` for the pinned version: `24`)
|
||||
- **Package manager**: `yarn` (v1 classic)
|
||||
|
||||
### Common Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `yarn install --frozen-lockfile` | Install dependencies (CI-safe, no lockfile changes) |
|
||||
| `RANCHER_ENV=harvester API=https://<harvester-ip> yarn dev` | Start development server at `https://127.0.0.1:8005` |
|
||||
| `yarn build-pkg harvester` | Build the Harvester extension package |
|
||||
| `yarn serve-pkgs` | Serve the locally built extension for testing |
|
||||
| `yarn lint` | Run ESLint (zero warnings allowed) |
|
||||
| `yarn lint:fix` | Run ESLint with auto-fix |
|
||||
| `yarn clean` | Clean build artifacts |
|
||||
| `yarn agents:generate` | Regenerate `AGENTS.md` from `docs/agents.md/` sources |
|
||||
|
||||
### Development
|
||||
|
||||
- **Start dev server**:
|
||||
```bash
|
||||
RANCHER_ENV=harvester API=https://your-harvester-ip yarn dev
|
||||
```
|
||||
- `API` should point to a running Harvester cluster (e.g., `https://x.x.x.x`).
|
||||
- The dashboard will be available at `https://127.0.0.1:8005`.
|
||||
|
||||
### Building
|
||||
|
||||
- **Build extension package**:
|
||||
```bash
|
||||
yarn build-pkg harvester
|
||||
```
|
||||
- **Serve locally built extension** (for integration testing with a Rancher instance):
|
||||
```bash
|
||||
yarn serve-pkgs
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
- **Check** (must pass with zero warnings):
|
||||
```bash
|
||||
yarn lint
|
||||
```
|
||||
- **Auto-fix**:
|
||||
```bash
|
||||
yarn lint:fix
|
||||
```
|
||||
- ESLint covers `.js`, `.ts`, and `.vue` files.
|
||||
- Always run `yarn lint:fix` before committing.
|
||||
|
||||
### Commit Conventions
|
||||
|
||||
- Commits are validated by [commitlint](https://commitlint.js.org/) via a Husky `commit-msg` hook.
|
||||
- Follow [Conventional Commits](https://www.conventionalcommits.org/) format (configured in `commitlint.config.js`).
|
||||
|
||||
### Agent Documentation
|
||||
|
||||
- Source files live in `docs/agents.md/` (agents, contributors, personas subdirectories).
|
||||
- After editing any source file, regenerate the root `AGENTS.md`:
|
||||
```bash
|
||||
yarn agents:generate
|
||||
```
|
||||
|
||||
# Contributors Guide
|
||||
|
||||
## Getting Started
|
||||
|
||||
Please see the [Harvester UI Extension README](https://github.com/harvester/harvester-ui-extension).
|
||||
|
||||
To get started, follow the `Development Setup` section.
|
||||
|
||||
## Project Information
|
||||
|
||||
- **Tech Stack:**
|
||||
- `Vue.js`: Framework
|
||||
- `Linting`: ESLint
|
||||
- `CSS`: SCSS should be used
|
||||
- `TypeScript`: Primary language for logic.
|
||||
- **Code Style and Standards:**
|
||||
- `Language`: TypeScript is preferred for new code.
|
||||
- `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.
|
||||
- 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:**
|
||||
- `.github/`: CI/CD workflows and Renovate config.
|
||||
- `docs/`: Documentation source for `AGENTS.md` generation.
|
||||
- `scripts/`: Bash scripts for build, CI and doc generation.
|
||||
- `pkg/harvester/`: Main extension source. Files are named after K8s resource types (e.g., `kubevirt.io.virtualmachine`, `harvesterhci.io.volume`).
|
||||
- `index.ts`: Plugin entry point — registers the product and auto-imports models/detail/edit views.
|
||||
- `types.ts`: `HCI` constant mapping 60+ K8s resource types to string identifiers.
|
||||
- `components/`: Reusable Vue components (VNC/serial console, settings panels, upgrade banners, filters).
|
||||
- `config/`: Constants — settings keys, table column definitions, feature flags, type mappings, doc links.
|
||||
- `detail/`: Read-only detail views per resource type. Complex resources use subdirectories with tabs.
|
||||
- `dialog/`: Modal dialogs for operations (VM clone/migrate/restart, backup/restore, device passthrough, etc.).
|
||||
- `edit/`: Create/edit forms. Complex resources (e.g., VM) split into subcomponents (CpuMemory, Network, Volume, CloudConfig, etc.).
|
||||
- `formatters/`: Table cell formatters — state badges, usage bars, resource references.
|
||||
- `l10n/`: Localization (`en-us.yaml`).
|
||||
- `list/`: List (table) views per resource type, mirroring `detail/` and `edit/` naming.
|
||||
- `mixins/`: Shared Vue mixins — VM helpers (`harvester-vm/`) and disk helpers (`harvester-disk.js`).
|
||||
- `models/`: Model classes extending `SteveModel` with computed properties and actions. Base class: `harvester.js`.
|
||||
- `pages/`: Route-level pages — dashboard, support, console, members, brand, alertmanager.
|
||||
- `promptRemove/`: Custom delete-confirmation dialogs (VM, backup).
|
||||
- `routing/`: Vue Router config — all product routes (CRUD, console, support, upgrade, etc.).
|
||||
- `store/`: Vuex modules — `harvester-common.js` for shared state; `harvester-store/` for VM/resource creation actions with Steve integration.
|
||||
- `styles/`: Global SCSS files.
|
||||
- `utils/`: Helpers — VM volume templates, CPU/memory calc, cron parsing, regex validators, feature flags.
|
||||
- `validators/`: Form validation functions per resource type, pushing i18n error messages.
|
||||
|
||||
|
||||
|
||||
## Harvester UI Extension Development Guide
|
||||
|
||||
### 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
|
||||
|
||||
To add a feature in a new release, follow these steps:
|
||||
|
||||
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
|
||||
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
|
||||
|
||||
```js
|
||||
computed: {
|
||||
newFeatureEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
3. **Render**: Use the result of the check to conditionally render the UI components.
|
||||
|
||||
|
||||
## E2E Tests (Cypress)
|
||||
|
||||
See https://github.com/harvester/harvester-ui-tests
|
||||
|
||||
## Node Dependencies
|
||||
|
||||
Dependencies are managed via `package.json` and `yarn`
|
||||
|
||||
- To install dependencies use `yarn install`. This will fail if dependencies and versions listed in `package.json` are out of step with the `yarn.lock` file
|
||||
- To add a dependency use `yarn run add:no-lock ...` instead of `yarn add`
|
||||
- To upgrade a dependency use `yarn run upgrade:no-lock ...` instead of `yarn upgrade`
|
||||
|
||||
|
||||
Renovate automatically updates dependencies and opens upgrade PRs after 10:00 AM on Sundays (`Asia/Taipei`).
|
||||
For the Renovate config, see `.github/renovate.json`.
|
||||
|
||||
|
||||
## Milestone guidance
|
||||
|
||||
- All issues must first be resolved in the `main` branch
|
||||
- If backports are needed they can be made via the backport bot
|
||||
- pull requests
|
||||
- comment `@Mergifyio backport <target branch>` e.g. `@Mergifyio backport release-harvester-v1.8`
|
||||
- All backported pull requests must link to a backported issue
|
||||
|
||||
|
||||
## Creating a branch
|
||||
|
||||
### To resolve an issue
|
||||
- Checkout the branch matching the milestone of the issue `git checkout ${targetMilestoneBranch}`. Replace `${targetMilestoneBranch}` with the target milestone of the issue. For example
|
||||
- `main` for the latest unreleased minor version
|
||||
- `release-harvester-v.X` for release minor versions
|
||||
- `release-harvester-v1.6`
|
||||
- `release-harvester-v1.7`
|
||||
- `release-harvester-v1.8`
|
||||
- Ensure you have the latest of that branch `git pull --rebase`
|
||||
- Checkout the branch to commit the changes to `git checkout issue-${issueNumber}`. Replace `${issueNumber}` with the issue number.
|
||||
|
||||
## Creating a commit
|
||||
|
||||
- This project uses commit-lint with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to ensure consistent and meaningful commit messages.
|
||||
|
||||
### Commit Message Format
|
||||
All commit messages must follow the conventional commit format:
|
||||
```
|
||||
<type>: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### Supported Types
|
||||
- feat: New features
|
||||
- fix: Bug fixes
|
||||
- docs: Documentation changes
|
||||
- style: Code style changes (formatting, missing semicolons, etc.)
|
||||
- refactor: Code refactoring
|
||||
- perf: Performance improvements
|
||||
- test: Adding or updating tests
|
||||
- build: Build system or external dependencies
|
||||
- ci: CI/CD changes
|
||||
- chore: Other changes that don't modify src or test files
|
||||
- revert: Reverts a previous commit
|
||||
- wip: Work in progress
|
||||
- deps: Dependency updates
|
||||
- security: Security fixes
|
||||
|
||||
## Creating a Pull Request
|
||||
|
||||
- Pull requests must come from forks
|
||||
- Description should always have commit supported type prefix. E.g `fix: XXX`, `feat: OOO`
|
||||
- A Pull Request will only be merged once
|
||||
- ALL CI gates have passed
|
||||
- At least one harvester/harvester-ui-extension code owners reviews and approves the PR
|
||||
|
||||
2
CODEOWNERS
Normal file
2
CODEOWNERS
Normal file
@ -0,0 +1,2 @@
|
||||
@a110605
|
||||
@houhoucoop
|
||||
24
README.md
24
README.md
@ -5,14 +5,27 @@ 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 detailed installation instructions, please refer to the [official Harvester documentation](https://docs.harvesterhci.io/v1.5/rancher/harvester-ui-extension#installation-on-rancher-210).
|
||||
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
|
||||
|
||||
|
||||
## 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: [
|
||||
|
||||
63
docs/agents.md/README
Normal file
63
docs/agents.md/README
Normal file
@ -0,0 +1,63 @@
|
||||
# Agents.md
|
||||
|
||||
This folder contains files used to construct a single root `AGENTS.md`
|
||||
|
||||
We do this because
|
||||
- The single commonality between almost all AI tools is that they look for a root `AGENTS.md`
|
||||
- Having the information in a single place for all AI tools is better than duplicating and maintaining the information in multiple files for each AI tool
|
||||
|
||||
## Process
|
||||
1. Add files to or update files in `./docs/agents.md/agents`, `./docs/agents.md/personas`, `./docs/agents.md/contributors`.
|
||||
2. Each file must
|
||||
- Start markdown headers at the `##` level
|
||||
- End in a new line
|
||||
- Order contents in AGENTS.md by giving an optional number prefix
|
||||
3. Run `yarn agents:generate`.
|
||||
- Take the contents of `./docs/agents.md/template_agents.md`
|
||||
- Insert the contents of the agents, personas and contributors files
|
||||
- Output to `./AGENTS.md`
|
||||
4. Commit all changes, including root `./AGENTS.md` and any files that link to it
|
||||
|
||||
## Guidance
|
||||
|
||||
### Examples
|
||||
|
||||
When referencing code or configuration it's good to include an example
|
||||
|
||||
## Future Tasks
|
||||
- Add real world examples when referencing code
|
||||
- Create a GH workflow to monitor `agents.md` folder files in a PR, if changed run generate script and add new AGENTS.md to PR
|
||||
- Discuss
|
||||
- Three multi AI tool related questions
|
||||
- Questions
|
||||
- how AI specific patterns, like co-pilot instructions, can be expressed in a way to be adopted by or reduce duplication with the root agents.md
|
||||
- how AI tool specific metadata code can be included
|
||||
- multiple agents vs personas approach works
|
||||
- Proposal
|
||||
- Generic information applicable to all AI tools is added to the root `agents.md`
|
||||
- Agent, instruction or other AI specific files or patterns can be used in files specific to that AI tool. They must not contain duplicate information to the root `agents.md`
|
||||
- The root `agents.md` continues to be AI tool agnostic
|
||||
- How can folder specific agents be used
|
||||
- Proposal
|
||||
- `agents.md` are added to folders as long as most LLMs can make use of them and they do not duplicate information in the root `agents.md`
|
||||
|
||||
## Test Prompts
|
||||
|
||||
To validate LLMs are picking up information consider running the following prompts.
|
||||
|
||||
### Information
|
||||
|
||||
Can they provide correct information?
|
||||
|
||||
- `what's this project about?`
|
||||
- `can you tell me the process for fixing a github issue in this repo?`
|
||||
- `what makes a good software developer?`
|
||||
- `what should never be done in this project?`
|
||||
- `what are the folders in this project for?`
|
||||
- `what are the env vars i could use when running e2e tests?`
|
||||
|
||||
### Actions
|
||||
|
||||
Can they use the information in their actions?
|
||||
|
||||
Pending
|
||||
21
docs/agents.md/agents/boundaries.md
Normal file
21
docs/agents.md/agents/boundaries.md
Normal file
@ -0,0 +1,21 @@
|
||||
## Boundaries
|
||||
|
||||
- ✅ **Always:**
|
||||
- Write to `.github`, `docs/`, `pkg/harvester`, `scripts/`.
|
||||
- Make commits in a new branch (for a PR).
|
||||
- Run `yarn lint:fix` before commits.
|
||||
- Use conventional commit format:
|
||||
```
|
||||
<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.
|
||||
- After updating files in `docs/agents.md/`, run `yarn agents:generate` to update root `AGENTS.md`.
|
||||
- ⚠️ **Ask first:**
|
||||
- Adding dependencies
|
||||
- Upgrading dependencies
|
||||
- 🚫 **Never:**
|
||||
- Commit or log secrets, `.env`, `kubeconfig` or any API keys.
|
||||
- Edit `node_modules/`.
|
||||
- Commit directly to `main` (use PRs).
|
||||
- Skip git hooks with `--no-verify` flag.
|
||||
65
docs/agents.md/agents/tools.md
Normal file
65
docs/agents.md/agents/tools.md
Normal file
@ -0,0 +1,65 @@
|
||||
## Tools
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: `>= 24.0.0` (see `.nvmrc` for the pinned version: `24`)
|
||||
- **Package manager**: `yarn` (v1 classic)
|
||||
|
||||
### Common Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `yarn install --frozen-lockfile` | Install dependencies (CI-safe, no lockfile changes) |
|
||||
| `RANCHER_ENV=harvester API=https://<harvester-ip> yarn dev` | Start development server at `https://127.0.0.1:8005` |
|
||||
| `yarn build-pkg harvester` | Build the Harvester extension package |
|
||||
| `yarn serve-pkgs` | Serve the locally built extension for testing |
|
||||
| `yarn lint` | Run ESLint (zero warnings allowed) |
|
||||
| `yarn lint:fix` | Run ESLint with auto-fix |
|
||||
| `yarn clean` | Clean build artifacts |
|
||||
| `yarn agents:generate` | Regenerate `AGENTS.md` from `docs/agents.md/` sources |
|
||||
|
||||
### Development
|
||||
|
||||
- **Start dev server**:
|
||||
```bash
|
||||
RANCHER_ENV=harvester API=https://your-harvester-ip yarn dev
|
||||
```
|
||||
- `API` should point to a running Harvester cluster (e.g., `https://x.x.x.x`).
|
||||
- The dashboard will be available at `https://127.0.0.1:8005`.
|
||||
|
||||
### Building
|
||||
|
||||
- **Build extension package**:
|
||||
```bash
|
||||
yarn build-pkg harvester
|
||||
```
|
||||
- **Serve locally built extension** (for integration testing with a Rancher instance):
|
||||
```bash
|
||||
yarn serve-pkgs
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
- **Check** (must pass with zero warnings):
|
||||
```bash
|
||||
yarn lint
|
||||
```
|
||||
- **Auto-fix**:
|
||||
```bash
|
||||
yarn lint:fix
|
||||
```
|
||||
- ESLint covers `.js`, `.ts`, and `.vue` files.
|
||||
- Always run `yarn lint:fix` before committing.
|
||||
|
||||
### Commit Conventions
|
||||
|
||||
- Commits are validated by [commitlint](https://commitlint.js.org/) via a Husky `commit-msg` hook.
|
||||
- Follow [Conventional Commits](https://www.conventionalcommits.org/) format (configured in `commitlint.config.js`).
|
||||
|
||||
### Agent Documentation
|
||||
|
||||
- Source files live in `docs/agents.md/` (agents, contributors, personas subdirectories).
|
||||
- After editing any source file, regenerate the root `AGENTS.md`:
|
||||
```bash
|
||||
yarn agents:generate
|
||||
```
|
||||
72
docs/agents.md/contributors/1_contributors.md
Normal file
72
docs/agents.md/contributors/1_contributors.md
Normal file
@ -0,0 +1,72 @@
|
||||
## Getting Started
|
||||
|
||||
Please see the [Harvester UI Extension README](https://github.com/harvester/harvester-ui-extension).
|
||||
|
||||
To get started, follow the `Development Setup` section.
|
||||
|
||||
## Project Information
|
||||
|
||||
- **Tech Stack:**
|
||||
- `Vue.js`: Framework
|
||||
- `Linting`: ESLint
|
||||
- `CSS`: SCSS should be used
|
||||
- `TypeScript`: Primary language for logic.
|
||||
- **Code Style and Standards:**
|
||||
- `Language`: TypeScript is preferred for new code.
|
||||
- `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.
|
||||
- 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:**
|
||||
- `.github/`: CI/CD workflows and Renovate config.
|
||||
- `docs/`: Documentation source for `AGENTS.md` generation.
|
||||
- `scripts/`: Bash scripts for build, CI and doc generation.
|
||||
- `pkg/harvester/`: Main extension source. Files are named after K8s resource types (e.g., `kubevirt.io.virtualmachine`, `harvesterhci.io.volume`).
|
||||
- `index.ts`: Plugin entry point — registers the product and auto-imports models/detail/edit views.
|
||||
- `types.ts`: `HCI` constant mapping 60+ K8s resource types to string identifiers.
|
||||
- `components/`: Reusable Vue components (VNC/serial console, settings panels, upgrade banners, filters).
|
||||
- `config/`: Constants — settings keys, table column definitions, feature flags, type mappings, doc links.
|
||||
- `detail/`: Read-only detail views per resource type. Complex resources use subdirectories with tabs.
|
||||
- `dialog/`: Modal dialogs for operations (VM clone/migrate/restart, backup/restore, device passthrough, etc.).
|
||||
- `edit/`: Create/edit forms. Complex resources (e.g., VM) split into subcomponents (CpuMemory, Network, Volume, CloudConfig, etc.).
|
||||
- `formatters/`: Table cell formatters — state badges, usage bars, resource references.
|
||||
- `l10n/`: Localization (`en-us.yaml`).
|
||||
- `list/`: List (table) views per resource type, mirroring `detail/` and `edit/` naming.
|
||||
- `mixins/`: Shared Vue mixins — VM helpers (`harvester-vm/`) and disk helpers (`harvester-disk.js`).
|
||||
- `models/`: Model classes extending `SteveModel` with computed properties and actions. Base class: `harvester.js`.
|
||||
- `pages/`: Route-level pages — dashboard, support, console, members, brand, alertmanager.
|
||||
- `promptRemove/`: Custom delete-confirmation dialogs (VM, backup).
|
||||
- `routing/`: Vue Router config — all product routes (CRUD, console, support, upgrade, etc.).
|
||||
- `store/`: Vuex modules — `harvester-common.js` for shared state; `harvester-store/` for VM/resource creation actions with Steve integration.
|
||||
- `styles/`: Global SCSS files.
|
||||
- `utils/`: Helpers — VM volume templates, CPU/memory calc, cron parsing, regex validators, feature flags.
|
||||
- `validators/`: Form validation functions per resource type, pushing i18n error messages.
|
||||
|
||||
|
||||
|
||||
## Harvester UI Extension Development Guide
|
||||
|
||||
### 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
|
||||
|
||||
To add a feature in a new release, follow these steps:
|
||||
|
||||
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
|
||||
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
|
||||
|
||||
```js
|
||||
computed: {
|
||||
newFeatureEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
3. **Render**: Use the result of the check to conditionally render the UI components.
|
||||
|
||||
3
docs/agents.md/contributors/2_e2e-tests.md
Normal file
3
docs/agents.md/contributors/2_e2e-tests.md
Normal file
@ -0,0 +1,3 @@
|
||||
## E2E Tests (Cypress)
|
||||
|
||||
See https://github.com/harvester/harvester-ui-tests
|
||||
12
docs/agents.md/contributors/node-dependencies.md
Normal file
12
docs/agents.md/contributors/node-dependencies.md
Normal file
@ -0,0 +1,12 @@
|
||||
## Node Dependencies
|
||||
|
||||
Dependencies are managed via `package.json` and `yarn`
|
||||
|
||||
- To install dependencies use `yarn install`. This will fail if dependencies and versions listed in `package.json` are out of step with the `yarn.lock` file
|
||||
- To add a dependency use `yarn run add:no-lock ...` instead of `yarn add`
|
||||
- To upgrade a dependency use `yarn run upgrade:no-lock ...` instead of `yarn upgrade`
|
||||
|
||||
|
||||
Renovate automatically updates dependencies and opens upgrade PRs after 10:00 AM on Sundays (`Asia/Taipei`).
|
||||
For the Renovate config, see `.github/renovate.json`.
|
||||
|
||||
58
docs/agents.md/contributors/resolving_gh_issues.md
Normal file
58
docs/agents.md/contributors/resolving_gh_issues.md
Normal file
@ -0,0 +1,58 @@
|
||||
## Milestone guidance
|
||||
|
||||
- All issues must first be resolved in the `main` branch
|
||||
- If backports are needed they can be made via the backport bot
|
||||
- pull requests
|
||||
- comment `@Mergifyio backport <target branch>` e.g. `@Mergifyio backport release-harvester-v1.8`
|
||||
- All backported pull requests must link to a backported issue
|
||||
|
||||
|
||||
## Creating a branch
|
||||
|
||||
### To resolve an issue
|
||||
- Checkout the branch matching the milestone of the issue `git checkout ${targetMilestoneBranch}`. Replace `${targetMilestoneBranch}` with the target milestone of the issue. For example
|
||||
- `main` for the latest unreleased minor version
|
||||
- `release-harvester-v.X` for release minor versions
|
||||
- `release-harvester-v1.6`
|
||||
- `release-harvester-v1.7`
|
||||
- `release-harvester-v1.8`
|
||||
- Ensure you have the latest of that branch `git pull --rebase`
|
||||
- Checkout the branch to commit the changes to `git checkout issue-${issueNumber}`. Replace `${issueNumber}` with the issue number.
|
||||
|
||||
## Creating a commit
|
||||
|
||||
- This project uses commit-lint with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to ensure consistent and meaningful commit messages.
|
||||
|
||||
### Commit Message Format
|
||||
All commit messages must follow the conventional commit format:
|
||||
```
|
||||
<type>: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### Supported Types
|
||||
- feat: New features
|
||||
- fix: Bug fixes
|
||||
- docs: Documentation changes
|
||||
- style: Code style changes (formatting, missing semicolons, etc.)
|
||||
- refactor: Code refactoring
|
||||
- perf: Performance improvements
|
||||
- test: Adding or updating tests
|
||||
- build: Build system or external dependencies
|
||||
- ci: CI/CD changes
|
||||
- chore: Other changes that don't modify src or test files
|
||||
- revert: Reverts a previous commit
|
||||
- wip: Work in progress
|
||||
- deps: Dependency updates
|
||||
- security: Security fixes
|
||||
|
||||
## Creating a Pull Request
|
||||
|
||||
- Pull requests must come from forks
|
||||
- Description should always have commit supported type prefix. E.g `fix: XXX`, `feat: OOO`
|
||||
- A Pull Request will only be merged once
|
||||
- ALL CI gates have passed
|
||||
- At least one harvester/harvester-ui-extension code owners reviews and approves the PR
|
||||
5
docs/agents.md/personas/software_developer.md
Normal file
5
docs/agents.md/personas/software_developer.md
Normal file
@ -0,0 +1,5 @@
|
||||
## Software Developer
|
||||
|
||||
You are an expert Senior Software Engineer specializing in Vue.js and TypeScript. You have deep knowledge of Kubernetes and the Rancher ecosystem.
|
||||
- **Focus**: Writing clean, maintainable, and performant code.
|
||||
- **Priorities**: Adhering to the project's code style, ensuring type safety, and following best practices for component design.
|
||||
22
docs/agents.md/template_agents.md
Normal file
22
docs/agents.md/template_agents.md
Normal file
@ -0,0 +1,22 @@
|
||||
> 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
|
||||
|
||||
The Harvester UI Extension is a Rancher extension that provides the user interface for Harvester within the Rancher Dashboard.
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
|
||||
# Personas
|
||||
|
||||
`<personas>`
|
||||
|
||||
# Agents
|
||||
|
||||
`<agents>`
|
||||
|
||||
# Contributors Guide
|
||||
|
||||
`<contributors>`
|
||||
31
package.json
31
package.json
@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "harvester-ui-extension",
|
||||
"version": "1.8.0-dev",
|
||||
"version": "1.10.0-dev",
|
||||
"private": false,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-class-static-block": "7.28.6",
|
||||
"@rancher/shell": "3.0.9-rc.6",
|
||||
"@babel/plugin-transform-class-static-block": "7.29.7",
|
||||
"@rancher/shell": "3.0.12-rc.7",
|
||||
"@vue-flow/background": "^1.3.0",
|
||||
"@vue-flow/controls": "^1.1.1",
|
||||
"@vue-flow/core": "^1.33.5",
|
||||
@ -15,27 +15,33 @@
|
||||
"cache-loader": "^4.1.0",
|
||||
"color": "4.2.3",
|
||||
"ip": "2.0.1",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-web-links": "0.12.0",
|
||||
"@xterm/addon-webgl": "0.19.0",
|
||||
"@xterm/addon-canvas": "0.7.0",
|
||||
"node-polyfill-webpack-plugin": "^3.0.0",
|
||||
"elkjs": "^0.11.0",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "~20.19.0",
|
||||
"@types/node": "25.9.5",
|
||||
"cronstrue": "2.59.0",
|
||||
"d3-color": "3.1.0",
|
||||
"ejs": "3.1.10",
|
||||
"follow-redirects": "1.15.11",
|
||||
"follow-redirects": "1.16.0",
|
||||
"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.3.3",
|
||||
"node-forge": "1.4.0",
|
||||
"nth-check": "2.1.1",
|
||||
"qs": "6.15.0",
|
||||
"roarr": "7.21.4",
|
||||
"semver": "7.7.4",
|
||||
"qs": "6.15.3",
|
||||
"roarr": "7.21.7",
|
||||
"semver": "7.8.5",
|
||||
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
@ -49,7 +55,8 @@
|
||||
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
|
||||
"parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name",
|
||||
"commitlint": "commitlint --edit",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/load": "^19.8.1",
|
||||
|
||||
@ -7,8 +7,7 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
|
||||
|
||||
## Installation
|
||||
|
||||
For detailed installation instructions, please refer to the [official Harvester documentation](https://docs.harvesterhci.io/v1.5/rancher/harvester-ui-extension#installation-on-rancher-210).
|
||||
|
||||
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
@ -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>
|
||||
104
pkg/harvester/components/settings/instance-manager-resources.vue
Normal file
104
pkg/harvester/components/settings/instance-manager-resources.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<script>
|
||||
import UnitInput from '@shell/components/form/UnitInput';
|
||||
import { Banner } from '@components/Banner';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterInstanceManagerResources',
|
||||
|
||||
components: {
|
||||
UnitInput,
|
||||
Banner,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
value: '',
|
||||
default: '{}'
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
const resources = this.parseJSON(this.value?.value) || this.parseJSON(this.value?.default) || {};
|
||||
|
||||
return {
|
||||
resources,
|
||||
parseError: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
parseJSON(string) {
|
||||
try {
|
||||
return JSON.parse(string);
|
||||
} catch (e) {
|
||||
this.parseError = this.t('harvester.setting.instanceManagerResources.parseError', { error: e.message });
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
update() {
|
||||
if (!this.value) return;
|
||||
|
||||
const cpu = { ...this.resources?.cpu };
|
||||
|
||||
if (cpu.v1 !== null && cpu.v1 !== undefined) cpu.v1 = String(cpu.v1);
|
||||
if (cpu.v2 !== null && cpu.v2 !== undefined) cpu.v2 = String(cpu.v2);
|
||||
|
||||
this.value.value = JSON.stringify({ ...this.resources, cpu });
|
||||
},
|
||||
|
||||
useDefault() {
|
||||
if (this.value?.default) {
|
||||
this.resources = this.parseJSON(this.value.default) || {};
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Banner
|
||||
v-if="parseError"
|
||||
color="error"
|
||||
>
|
||||
{{ parseError }}
|
||||
</Banner>
|
||||
|
||||
<div class="row">
|
||||
<div class="col span-12">
|
||||
<UnitInput
|
||||
v-model:value="resources.cpu.v1"
|
||||
:label="t('harvester.setting.instanceManagerResources.v1')"
|
||||
suffix="%"
|
||||
:delay="0"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
required
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@update:value="update"
|
||||
/>
|
||||
<UnitInput
|
||||
v-model:value="resources.cpu.v2"
|
||||
:label="t('harvester.setting.instanceManagerResources.v2')"
|
||||
suffix="%"
|
||||
:delay="0"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
required
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@update:value="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
371
pkg/harvester/components/settings/rwx-network.vue
Normal file
371
pkg/harvester/components/settings/rwx-network.vue
Normal file
@ -0,0 +1,371 @@
|
||||
<script>
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import { isValidCIDR } from '@shell/utils/validators/cidr';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { HCI } from '../../types';
|
||||
import { NETWORK_TYPE } from '../../config/types';
|
||||
|
||||
const { L2VLAN, UNTAGGED } = NETWORK_TYPE;
|
||||
const SHARE_STORAGE_NETWORK = 'share-storage-network';
|
||||
const NETWORK = 'network';
|
||||
|
||||
const DEFAULT_DEDICATED_NETWORK = {
|
||||
vlan: '',
|
||||
clusterNetwork: '',
|
||||
range: '',
|
||||
exclude: [],
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'RwxNetworkSetting',
|
||||
|
||||
components: {
|
||||
RadioGroup,
|
||||
Banner,
|
||||
ArrayList,
|
||||
LabeledInput,
|
||||
LabeledSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
registerBeforeHook: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: _EDIT,
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
|
||||
await allHash({
|
||||
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
|
||||
vlanStatus: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN_STATUS }),
|
||||
});
|
||||
},
|
||||
|
||||
data() {
|
||||
let enabled = false; // enabled / disabled options
|
||||
let shareStorageNetwork = false; // shareStorageNetwork / dedicatedRwxNetwork options
|
||||
let dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
|
||||
let networkType = L2VLAN;
|
||||
let exclude = [];
|
||||
|
||||
try {
|
||||
const parsedValue = JSON.parse(this.value.value || this.value.default || '{}');
|
||||
const parsedNetwork = parsedValue?.[NETWORK] || parsedValue || {};
|
||||
|
||||
if (parsedValue && typeof parsedValue === 'object') {
|
||||
shareStorageNetwork = !!parsedValue[SHARE_STORAGE_NETWORK];
|
||||
networkType = 'vlan' in parsedNetwork ? L2VLAN : UNTAGGED;
|
||||
dedicatedNetwork = {
|
||||
vlan: parsedNetwork.vlan || '',
|
||||
clusterNetwork: parsedNetwork.clusterNetwork || '',
|
||||
range: parsedNetwork.range || '',
|
||||
};
|
||||
exclude = parsedNetwork?.exclude?.toString().split(',') || [];
|
||||
enabled = shareStorageNetwork || !!(parsedNetwork.vlan || parsedNetwork.clusterNetwork || parsedNetwork.range);
|
||||
}
|
||||
} catch (error) {
|
||||
enabled = false;
|
||||
shareStorageNetwork = false;
|
||||
dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
shareStorageNetwork,
|
||||
dedicatedNetwork,
|
||||
networkType,
|
||||
exclude,
|
||||
defaultAddValue: '',
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.registerBeforeHook) {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
showDedicatedNetworkConfig() {
|
||||
return this.enabled && !this.shareStorageNetwork;
|
||||
},
|
||||
|
||||
showVlan() {
|
||||
return this.networkType === L2VLAN;
|
||||
},
|
||||
|
||||
networkTypes() {
|
||||
return [L2VLAN, UNTAGGED];
|
||||
},
|
||||
|
||||
clusterNetworkOptions() {
|
||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
|
||||
const clusterNetworksOptions = this.networkType === UNTAGGED ? clusterNetworks.filter((network) => network.id !== 'mgmt') : clusterNetworks;
|
||||
|
||||
return clusterNetworksOptions.map((network) => {
|
||||
const disabled = !network.isReadyForStorageNetwork;
|
||||
|
||||
return {
|
||||
label: disabled ? `${ network.id } (${ this.t('generic.notReady') })` : network.id,
|
||||
value: network.id,
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onUpdateEnabled() {
|
||||
if (!this.enabled) {
|
||||
this.shareStorageNetwork = false;
|
||||
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
|
||||
}
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
onUpdateNetworkType() {
|
||||
if (this.shareStorageNetwork) {
|
||||
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
|
||||
}
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
onUpdateDedicatedType(neu) {
|
||||
this.dedicatedNetwork.clusterNetwork = '';
|
||||
|
||||
if (neu === L2VLAN) {
|
||||
this.dedicatedNetwork.vlan = '';
|
||||
} else {
|
||||
delete this.dedicatedNetwork.vlan;
|
||||
}
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
inputVlan(neu) {
|
||||
if (neu === '') {
|
||||
this.dedicatedNetwork.vlan = '';
|
||||
this.update();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = Number(neu);
|
||||
|
||||
if (newValue > 4094) {
|
||||
this.dedicatedNetwork.vlan = 4094;
|
||||
} else if (newValue < 1) {
|
||||
this.dedicatedNetwork.vlan = 1;
|
||||
} else {
|
||||
this.dedicatedNetwork.vlan = newValue;
|
||||
}
|
||||
|
||||
this.update();
|
||||
},
|
||||
|
||||
useDefault() {
|
||||
this.enabled = false;
|
||||
this.shareStorageNetwork = false;
|
||||
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
|
||||
this.update();
|
||||
},
|
||||
|
||||
update() {
|
||||
const value = { [SHARE_STORAGE_NETWORK]: false };
|
||||
|
||||
if (this.enabled && this.shareStorageNetwork) {
|
||||
value[SHARE_STORAGE_NETWORK] = true;
|
||||
}
|
||||
|
||||
if (this.showDedicatedNetworkConfig) {
|
||||
value[NETWORK] = {};
|
||||
|
||||
if (this.showVlan) {
|
||||
value[NETWORK].vlan = this.dedicatedNetwork.vlan;
|
||||
}
|
||||
|
||||
value[NETWORK].clusterNetwork = this.dedicatedNetwork.clusterNetwork;
|
||||
value[NETWORK].range = this.dedicatedNetwork.range;
|
||||
|
||||
const excludeList = this.exclude.filter((ip) => ip);
|
||||
|
||||
if (Array.isArray(excludeList) && excludeList.length > 0) {
|
||||
value[NETWORK].exclude = excludeList;
|
||||
}
|
||||
}
|
||||
|
||||
this.value.value = JSON.stringify(value);
|
||||
},
|
||||
|
||||
willSave() {
|
||||
this.update();
|
||||
|
||||
if (!this.showDedicatedNetworkConfig) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (this.showVlan && !this.dedicatedNetwork.vlan) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.vlan') }, true));
|
||||
}
|
||||
|
||||
if (!this.dedicatedNetwork.clusterNetwork) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.clusterNetwork') }, true));
|
||||
}
|
||||
|
||||
if (!this.dedicatedNetwork.range) {
|
||||
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.range.label') }, true));
|
||||
} else if (!isValidCIDR(this.dedicatedNetwork.range)) {
|
||||
errors.push(this.t('harvester.setting.storageNetwork.range.invalid', null, true));
|
||||
}
|
||||
|
||||
if (this.exclude) {
|
||||
const hasInvalidCIDR = this.exclude.find((cidr) => {
|
||||
return cidr && !isValidCIDR(cidr);
|
||||
});
|
||||
|
||||
if (hasInvalidCIDR) {
|
||||
errors.push(this.t('harvester.setting.storageNetwork.exclude.invalid', null, true));
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Promise.reject(errors);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="mode">
|
||||
<Banner color="warning">
|
||||
<t
|
||||
k="harvester.setting.rwxNetwork.warning"
|
||||
:raw="true"
|
||||
/>
|
||||
</Banner>
|
||||
<RadioGroup
|
||||
v-model:value="enabled"
|
||||
class="mb-20"
|
||||
name="rwx-network-enable"
|
||||
:options="[true,false]"
|
||||
:labels="[t('generic.enabled'), t('generic.disabled')]"
|
||||
@update:value="onUpdateEnabled"
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
v-if="enabled"
|
||||
v-model:value="shareStorageNetwork"
|
||||
class="mb-20"
|
||||
name="rwx-network-type"
|
||||
:options="[true,false]"
|
||||
:labels="[t('harvester.setting.rwxNetwork.shareStorageNetwork'), t('harvester.setting.rwxNetwork.dedicatedRwxNetwork')]"
|
||||
@update:value="onUpdateNetworkType"
|
||||
/>
|
||||
<Banner
|
||||
v-if="shareStorageNetwork"
|
||||
class="mb-20"
|
||||
color="warning"
|
||||
>
|
||||
<t
|
||||
k="harvester.setting.rwxNetwork.shareStorageNetworkWarning"
|
||||
:raw="true"
|
||||
/>
|
||||
</Banner>
|
||||
<template v-if="showDedicatedNetworkConfig">
|
||||
<LabeledSelect
|
||||
v-model:value="networkType"
|
||||
class="mb-20"
|
||||
:options="networkTypes"
|
||||
:mode="mode"
|
||||
:label="t('harvester.fields.type')"
|
||||
required
|
||||
@update:value="onUpdateDedicatedType"
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-if="showVlan"
|
||||
v-model:value.number="dedicatedNetwork.vlan"
|
||||
type="number"
|
||||
class="mb-20"
|
||||
:mode="mode"
|
||||
required
|
||||
placeholder="e.g. 1 - 4094"
|
||||
label-key="harvester.setting.storageNetwork.vlan"
|
||||
@update:value="inputVlan"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="dedicatedNetwork.clusterNetwork"
|
||||
label-key="harvester.setting.storageNetwork.clusterNetwork"
|
||||
class="mb-20"
|
||||
required
|
||||
:options="clusterNetworkOptions"
|
||||
@update:value="update"
|
||||
/>
|
||||
|
||||
<LabeledInput
|
||||
v-model:value="dedicatedNetwork.range"
|
||||
class="mb-5"
|
||||
:mode="mode"
|
||||
required
|
||||
:placeholder="t('harvester.setting.storageNetwork.range.placeholder')"
|
||||
label-key="harvester.setting.storageNetwork.range.label"
|
||||
@update:value="update"
|
||||
/>
|
||||
|
||||
<ArrayList
|
||||
v-model:value="exclude"
|
||||
:show-header="true"
|
||||
:default-add-value="defaultAddValue"
|
||||
:mode="mode"
|
||||
:add-label="t('harvester.setting.storageNetwork.exclude.addIp')"
|
||||
class="mt-20"
|
||||
@update:value="update"
|
||||
>
|
||||
<template #column-headers>
|
||||
<div class="box mb-10">
|
||||
<div class="key">
|
||||
{{ t('harvester.setting.storageNetwork.exclude.label') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #columns="scope">
|
||||
<div class="key">
|
||||
<input
|
||||
v-model="scope.row.value"
|
||||
:placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')"
|
||||
@update:value="update"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ArrayList>
|
||||
</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>
|
||||
@ -61,7 +61,21 @@ const FEATURE_FLAGS = {
|
||||
'v1.8.0': [
|
||||
'hotplugCdRom',
|
||||
'supportBundleFileNameSetting',
|
||||
'clusterRegistrationTLSVerify'
|
||||
'clusterRegistrationTLSVerify',
|
||||
'vGPUAsPCIDevice',
|
||||
'instanceManagerResourcesSetting',
|
||||
'rwxNetworkSetting',
|
||||
'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';
|
||||
|
||||
@ -81,7 +96,6 @@ export function init($plugin, store) {
|
||||
configureType,
|
||||
virtualType,
|
||||
weightGroup,
|
||||
weightType,
|
||||
} = $plugin.DSL(store, PRODUCT_NAME);
|
||||
|
||||
const isSingleVirtualCluster = process.env.rancherEnv === PRODUCT_NAME;
|
||||
@ -168,7 +182,7 @@ export function init($plugin, store) {
|
||||
group: 'Root',
|
||||
name: HCI.HOST,
|
||||
namespaced: true,
|
||||
weight: 399,
|
||||
weight: 499,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.HOST }
|
||||
@ -200,7 +214,7 @@ export function init($plugin, store) {
|
||||
group: 'root',
|
||||
name: HCI.VM,
|
||||
namespaced: true,
|
||||
weight: 299,
|
||||
weight: 498,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VM }
|
||||
@ -238,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 }
|
||||
@ -267,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 }
|
||||
@ -296,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 }
|
||||
@ -324,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 }
|
||||
@ -361,7 +379,7 @@ export function init($plugin, store) {
|
||||
ifHaveType: PVC,
|
||||
name: HCI.VOLUME,
|
||||
namespaced: true,
|
||||
weight: 199,
|
||||
weight: 497,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.VOLUME }
|
||||
@ -387,7 +405,7 @@ export function init($plugin, store) {
|
||||
group: 'root',
|
||||
name: HCI.IMAGE,
|
||||
namespaced: true,
|
||||
weight: 198,
|
||||
weight: 496,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: HCI.IMAGE }
|
||||
@ -402,7 +420,7 @@ export function init($plugin, store) {
|
||||
group: 'root',
|
||||
namespaced: true,
|
||||
name: 'projects-namespaces',
|
||||
weight: 98,
|
||||
weight: 495,
|
||||
route: { name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces` },
|
||||
exact: true,
|
||||
});
|
||||
@ -414,7 +432,7 @@ export function init($plugin, store) {
|
||||
labelKey: 'harvester.namespace.label',
|
||||
name: NAMESPACE,
|
||||
namespaced: true,
|
||||
weight: 89,
|
||||
weight: 495,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
params: { resource: NAMESPACE }
|
||||
@ -485,6 +503,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.CLUSTER_FLOW,
|
||||
labelKey: 'harvester.logging.clusterFlow.label',
|
||||
name: HCI.CLUSTER_FLOW,
|
||||
namespaced: true,
|
||||
@ -508,6 +527,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.CLUSTER_OUTPUT,
|
||||
labelKey: 'harvester.logging.clusterOutput.label',
|
||||
name: HCI.CLUSTER_OUTPUT,
|
||||
namespaced: true,
|
||||
@ -531,6 +551,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.FLOW,
|
||||
labelKey: 'harvester.logging.flow.label',
|
||||
name: HCI.FLOW,
|
||||
namespaced: true,
|
||||
@ -554,6 +575,7 @@ export function init($plugin, store) {
|
||||
});
|
||||
|
||||
virtualType({
|
||||
ifHaveType: LOGGING.OUTPUT,
|
||||
labelKey: 'harvester.logging.output.label',
|
||||
name: HCI.OUTPUT,
|
||||
namespaced: true,
|
||||
@ -574,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,9 +653,12 @@ export function init($plugin, store) {
|
||||
'backupAndSnapshot'
|
||||
);
|
||||
|
||||
weightGroup('networks', 300, true);
|
||||
weightType(NAMESPACE, 299, true);
|
||||
weightGroup('backupAndSnapshot', 289, true);
|
||||
weightGroup('networks', 494, 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(
|
||||
[
|
||||
@ -673,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 }
|
||||
@ -688,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 }
|
||||
@ -709,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 }
|
||||
@ -718,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 }
|
||||
@ -733,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: {
|
||||
@ -1087,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 }
|
||||
@ -1126,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 }
|
||||
@ -1135,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',
|
||||
@ -28,6 +30,7 @@ export const HCI = {
|
||||
NODE_ROLE_CONTROL_PLANE: 'node-role.kubernetes.io/control-plane',
|
||||
NODE_ROLE_ETCD: 'node-role.harvesterhci.io/witness',
|
||||
PROMOTE_STATUS: 'harvesterhci.io/promote-status',
|
||||
CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status',
|
||||
MIGRATION_STATE: 'harvesterhci.io/migrationState',
|
||||
VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates',
|
||||
IMAGE_NAME: 'harvesterhci.io/image-name',
|
||||
@ -78,5 +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,45 +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',
|
||||
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'
|
||||
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 = {
|
||||
@ -80,6 +85,9 @@ export const HCI_ALLOWED_SETTINGS = {
|
||||
[HCI_SETTING.STORAGE_NETWORK]: {
|
||||
kind: 'custom', from: 'import', canReset: true
|
||||
},
|
||||
[HCI_SETTING.RWX_NETWORK]: {
|
||||
kind: 'json', from: 'import', canReset: true, featureFlag: 'rwxNetworkSetting'
|
||||
},
|
||||
[HCI_SETTING.VM_FORCE_RESET_POLICY]: { kind: 'json', from: 'import' },
|
||||
[HCI_SETTING.SSL_CERTIFICATES]: { kind: 'json', from: 'import' },
|
||||
[HCI_SETTING.SSL_PARAMETERS]: {
|
||||
@ -106,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',
|
||||
@ -122,6 +138,12 @@ export const HCI_ALLOWED_SETTINGS = {
|
||||
},
|
||||
[HCI_SETTING.KUBEVIRT_MIGRATION]: {
|
||||
kind: 'json', from: 'import', canReset: true, featureFlag: 'kubevirtMigration',
|
||||
},
|
||||
[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',
|
||||
@ -41,3 +48,14 @@ export const VMIMPORT_SOURCE_KINDS = {
|
||||
OPENSTACK: 'OpenstackSource',
|
||||
OVA: 'OvaSource',
|
||||
};
|
||||
|
||||
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({
|
||||
|
||||
@ -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,9 +1,12 @@
|
||||
<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';
|
||||
import { getHarvesterUserName } from '../utils/auth';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEnablePciPassthrough',
|
||||
@ -12,7 +15,9 @@ export default {
|
||||
|
||||
components: {
|
||||
AsyncButton,
|
||||
Banner,
|
||||
Card,
|
||||
Checkbox,
|
||||
},
|
||||
|
||||
props: {
|
||||
@ -23,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() {
|
||||
@ -34,16 +45,7 @@ export default {
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
// isSingleProduct == this is a standalone Harvester cluster
|
||||
const isSingleProduct = this.$store.getters['isSingleProduct'];
|
||||
let userName = 'admin';
|
||||
|
||||
// if this is imported Harvester, there may be users other than 'admin
|
||||
if (!isSingleProduct) {
|
||||
const user = this.$store.getters['auth/v3User'];
|
||||
|
||||
userName = user?.username || user?.id;
|
||||
}
|
||||
const userName = getHarvesterUserName(this.$store.getters);
|
||||
|
||||
for (let i = 0; i < this.resources.length; i++) {
|
||||
const actionResource = this.resources[i];
|
||||
@ -60,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,
|
||||
}
|
||||
} );
|
||||
|
||||
@ -93,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>
|
||||
@ -4,6 +4,7 @@ import { Card } from '@components/Card';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { escapeHtml } from '@shell/utils/string';
|
||||
import { HCI } from '../types';
|
||||
import { getHarvesterUserName } from '../utils/auth';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterEnableUSBPassthrough',
|
||||
@ -34,16 +35,7 @@ export default {
|
||||
},
|
||||
|
||||
async save(buttonCb) {
|
||||
// isSingleProduct == this is a standalone Harvester cluster
|
||||
const isSingleProduct = this.$store.getters['isSingleProduct'];
|
||||
let userName = 'admin';
|
||||
|
||||
// if this is imported Harvester, there may be users other than 'admin
|
||||
if (!isSingleProduct) {
|
||||
const user = this.$store.getters['auth/v3User'];
|
||||
|
||||
userName = user?.username || user?.id;
|
||||
}
|
||||
const userName = getHarvesterUserName(this.$store.getters);
|
||||
|
||||
for (let i = 0; i < this.resources.length; i++) {
|
||||
const actionResource = this.resources[i];
|
||||
|
||||
@ -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"
|
||||
|
||||
183
pkg/harvester/dialog/HarvesterDataMigrationDialog.vue
Normal file
183
pkg/harvester/dialog/HarvesterDataMigrationDialog.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import { STORAGE_CLASS } from '@shell/config/types';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { isInternalStorageClass } from '../utils/storage-class';
|
||||
|
||||
import { Card } from '@components/Card';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterDataMigrationDialog',
|
||||
|
||||
emits: ['close'],
|
||||
|
||||
components: {
|
||||
AsyncButton, Banner, Card, LabeledInput, LabeledSelect
|
||||
},
|
||||
|
||||
props: {
|
||||
resources: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
this.storageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
targetVolumeName: '',
|
||||
targetStorageClassName: '',
|
||||
errors: [],
|
||||
storageClasses: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
actionResource() {
|
||||
return this.resources[0];
|
||||
},
|
||||
|
||||
storageClassOptions() {
|
||||
return sortBy(
|
||||
this.storageClasses
|
||||
.filter((sc) => !isInternalStorageClass(sc.metadata?.name))
|
||||
.map((sc) => ({
|
||||
label: sc.metadata?.name,
|
||||
value: sc.metadata?.name
|
||||
})),
|
||||
'label'
|
||||
);
|
||||
},
|
||||
|
||||
disableSave() {
|
||||
return !this.targetVolumeName || !this.targetStorageClassName;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.targetVolumeName = '';
|
||||
this.targetStorageClassName = '';
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async apply(buttonDone) {
|
||||
if (!this.actionResource) {
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.targetVolumeName) {
|
||||
const name = this.t('harvester.modal.dataMigration.fields.targetVolumeName.label');
|
||||
|
||||
this['errors'] = [this.t('validation.required', { key: name })];
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.targetStorageClassName) {
|
||||
const name = this.t('harvester.modal.dataMigration.fields.targetStorageClassName.label');
|
||||
|
||||
this['errors'] = [this.t('validation.required', { key: name })];
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.actionResource.doAction('dataMigration', {
|
||||
targetVolumeName: this.targetVolumeName,
|
||||
targetStorageClassName: this.targetStorageClassName
|
||||
}, {}, false);
|
||||
|
||||
buttonDone(true);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
const error = err?.data || err;
|
||||
|
||||
this['errors'] = exceptionToErrorsArray(error);
|
||||
buttonDone(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :show-highlight-border="false">
|
||||
<template #title>
|
||||
{{ t('harvester.modal.dataMigration.title') }}
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LabeledInput
|
||||
v-model:value="targetVolumeName"
|
||||
:label="t('harvester.modal.dataMigration.fields.targetVolumeName.label')"
|
||||
:placeholder="t('harvester.modal.dataMigration.fields.targetVolumeName.placeholder')"
|
||||
class="mb-20"
|
||||
required
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="targetStorageClassName"
|
||||
:label="t('harvester.modal.dataMigration.fields.targetStorageClassName.label')"
|
||||
:placeholder="t('harvester.modal.dataMigration.fields.targetStorageClassName.placeholder')"
|
||||
:options="storageClassOptions"
|
||||
required
|
||||
/>
|
||||
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template
|
||||
#actions
|
||||
class="actions"
|
||||
>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="btn role-secondary mr-10"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
|
||||
<AsyncButton
|
||||
mode="apply"
|
||||
:disabled="disableSave"
|
||||
@click="apply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
|
||||
239
pkg/harvester/dialog/HarvesterStorageMigrationDialog.vue
Normal file
239
pkg/harvester/dialog/HarvesterStorageMigrationDialog.vue
Normal file
@ -0,0 +1,239 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import { PVC } from '@shell/config/types';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { HCI } from '../types';
|
||||
import { parseVolumeClaimTemplates } from '@pkg/harvester/utils/vm';
|
||||
|
||||
import { Card } from '@components/Card';
|
||||
import { Banner } from '@components/Banner';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
|
||||
export default {
|
||||
name: 'HarvesterStorageMigrationDialog',
|
||||
|
||||
emits: ['close'],
|
||||
|
||||
components: {
|
||||
AsyncButton, Banner, Card, LabeledSelect
|
||||
},
|
||||
|
||||
props: {
|
||||
resources: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
sourceVolume: '',
|
||||
targetVolume: '',
|
||||
errors: [],
|
||||
allPVCs: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({ t: 'i18n/t' }),
|
||||
|
||||
actionResource() {
|
||||
return this.resources[0];
|
||||
},
|
||||
|
||||
sourceVolumeOptions() {
|
||||
const volumes = this.actionResource.spec?.template?.spec?.volumes || [];
|
||||
|
||||
return sortBy(
|
||||
volumes
|
||||
.map((v) => v.persistentVolumeClaim?.claimName)
|
||||
.filter((name) => !!name)
|
||||
.map((name) => ({
|
||||
label: name,
|
||||
value: name
|
||||
})),
|
||||
'label'
|
||||
);
|
||||
},
|
||||
|
||||
namespacePVCs() {
|
||||
return this.allPVCs.filter((pvc) => pvc.metadata.namespace === this.actionResource.metadata.namespace);
|
||||
},
|
||||
|
||||
vmUsedVolumeNames() {
|
||||
const allVMs = this.$store.getters['harvester/all'](HCI.VM) || [];
|
||||
const names = new Set();
|
||||
|
||||
allVMs.forEach((vm) => {
|
||||
// Collect volume names from spec.template.spec.volumes (both PVC and DataVolume references)
|
||||
const volumes = vm.spec?.template?.spec?.volumes || [];
|
||||
|
||||
volumes.forEach((v) => {
|
||||
const name = v.persistentVolumeClaim?.claimName || v.dataVolume?.name;
|
||||
|
||||
if (name) {
|
||||
names.add(`${ vm.metadata.namespace }/${ name }`);
|
||||
}
|
||||
});
|
||||
|
||||
// Collect volume names from volumeClaimTemplates annotation
|
||||
const templates = parseVolumeClaimTemplates(vm);
|
||||
|
||||
templates.forEach((t) => {
|
||||
if (t.metadata?.name) {
|
||||
names.add(`${ vm.metadata.namespace }/${ t.metadata.name }`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return names;
|
||||
},
|
||||
|
||||
targetVolumeOptions() {
|
||||
return sortBy(
|
||||
this.namespacePVCs
|
||||
.filter((pvc) => {
|
||||
// Exclude volumes used by any VM (via spec.volumes or volumeClaimTemplates)
|
||||
if (this.vmUsedVolumeNames.has(`${ pvc.metadata.namespace }/${ pvc.metadata.name }`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((pvc) => ({
|
||||
label: pvc.metadata.name,
|
||||
value: pvc.metadata.name
|
||||
})),
|
||||
'label'
|
||||
);
|
||||
},
|
||||
|
||||
disableSave() {
|
||||
return !this.sourceVolume || !this.targetVolume;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.sourceVolume = '';
|
||||
this.targetVolume = '';
|
||||
this.errors = [];
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async apply(buttonDone) {
|
||||
if (!this.actionResource) {
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.sourceVolume) {
|
||||
const name = this.t('harvester.modal.storageMigration.fields.sourceVolume.label');
|
||||
|
||||
this['errors'] = [this.t('validation.required', { key: name })];
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.targetVolume) {
|
||||
const name = this.t('harvester.modal.storageMigration.fields.targetVolume.label');
|
||||
|
||||
this['errors'] = [this.t('validation.required', { key: name })];
|
||||
buttonDone(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.actionResource.doAction('storageMigration', {
|
||||
sourceVolume: this.sourceVolume,
|
||||
targetVolume: this.targetVolume
|
||||
}, {}, false);
|
||||
|
||||
buttonDone(true);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
const error = err?.data || err;
|
||||
|
||||
this['errors'] = exceptionToErrorsArray(error);
|
||||
buttonDone(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :show-highlight-border="false">
|
||||
<template #title>
|
||||
{{ t('harvester.modal.storageMigration.title') }}
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LabeledSelect
|
||||
v-model:value="sourceVolume"
|
||||
:label="t('harvester.modal.storageMigration.fields.sourceVolume.label')"
|
||||
:placeholder="t('harvester.modal.storageMigration.fields.sourceVolume.placeholder')"
|
||||
:options="sourceVolumeOptions"
|
||||
class="mb-20"
|
||||
required
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="targetVolume"
|
||||
:label="t('harvester.modal.storageMigration.fields.targetVolume.label')"
|
||||
:placeholder="t('harvester.modal.storageMigration.fields.targetVolume.placeholder')"
|
||||
:options="targetVolumeOptions"
|
||||
required
|
||||
/>
|
||||
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template
|
||||
#actions
|
||||
class="actions"
|
||||
>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="btn role-secondary mr-10"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
|
||||
<AsyncButton
|
||||
mode="apply"
|
||||
:disabled="disableSave"
|
||||
@click="apply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
</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"
|
||||
|
||||
@ -71,21 +71,38 @@ export default {
|
||||
},
|
||||
|
||||
data() {
|
||||
let provisioner = `${ this.value.provisioner || LONGHORN_DRIVER }`;
|
||||
|
||||
if (provisioner === LONGHORN_DRIVER) {
|
||||
provisioner = `${ provisioner }_${ this.value.provisionerVersion || LONGHORN_VERSION_V1 }`;
|
||||
}
|
||||
|
||||
return {
|
||||
provisioner,
|
||||
volumeGroupDialog: null,
|
||||
randomStr: randomStr(10).toLowerCase(),
|
||||
isOpen: false
|
||||
isOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
provisioner: {
|
||||
get() {
|
||||
let provisioner = `${ this.value?.provisioner || LONGHORN_DRIVER }`;
|
||||
|
||||
if (provisioner === LONGHORN_DRIVER) {
|
||||
provisioner = `${ provisioner }_${ this.value?.provisionerVersion || LONGHORN_VERSION_V1 }`;
|
||||
}
|
||||
|
||||
return provisioner;
|
||||
},
|
||||
set(value) {
|
||||
this.randomStr = randomStr(10).toLowerCase();
|
||||
const [provisioner, provisionerVersion] = (value || '').split('_');
|
||||
|
||||
this.value.provisioner = provisioner;
|
||||
|
||||
if (provisioner === LONGHORN_DRIVER) {
|
||||
this.value.provisionerVersion = provisionerVersion || LONGHORN_VERSION_V1;
|
||||
} else {
|
||||
this.value.provisionerVersion = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
provisioners() {
|
||||
const out = [];
|
||||
|
||||
@ -283,20 +300,6 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
provisioner(value) {
|
||||
this.randomStr = randomStr(10).toLowerCase();
|
||||
|
||||
const [provisioner, provisionerVersion] = value?.split('_');
|
||||
|
||||
this.value.provisioner = provisioner;
|
||||
|
||||
if (provisioner === LONGHORN_DRIVER) {
|
||||
this.value.provisionerVersion = provisionerVersion || LONGHORN_VERSION_V1;
|
||||
} else {
|
||||
this.value.provisionerVersion = undefined;
|
||||
}
|
||||
},
|
||||
|
||||
'value.lvmVolumeGroup'(neu) {
|
||||
if (neu === _NEW) {
|
||||
this.value.lvmVolumeGroup = null;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -9,8 +9,11 @@ import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import NameNsDescription from '@shell/components/form/NameNsDescription';
|
||||
import Conditions from '@shell/components/form/Conditions';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { get } from '@shell/utils/object';
|
||||
import { clone, get } from '@shell/utils/object';
|
||||
import { STORAGE_CLASS, LONGHORN, PV } from '@shell/config/types';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { saferDump } from '@shell/utils/create-yaml';
|
||||
@ -23,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',
|
||||
|
||||
@ -33,6 +38,7 @@ export default {
|
||||
|
||||
components: {
|
||||
Banner,
|
||||
Checkbox,
|
||||
Tab,
|
||||
UnitInput,
|
||||
CruResource,
|
||||
@ -79,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;
|
||||
@ -90,14 +96,34 @@ export default {
|
||||
source,
|
||||
storage,
|
||||
imageId,
|
||||
snapshots: [],
|
||||
images: [],
|
||||
showAdvanced: false,
|
||||
createWithDataVolume: false,
|
||||
snapshots: [],
|
||||
images: [],
|
||||
GIBIBYTE
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.registerBeforeHook(this.willSave, 'willSave');
|
||||
|
||||
if (this.mode === _CREATE) {
|
||||
const originalSaveYaml = this.value.saveYaml?.bind(this.value);
|
||||
|
||||
this.value.saveYaml = async(yaml) => {
|
||||
if (this.createWithDataVolume && this.isBlank) {
|
||||
const parsed = jsyaml.load(yaml);
|
||||
const dvObj = { ...parsed, type: 'cdi.kubevirt.io.datavolume' };
|
||||
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
|
||||
|
||||
await dataVolume.save();
|
||||
|
||||
return dataVolume;
|
||||
}
|
||||
|
||||
return originalSaveYaml(yaml);
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
@ -135,6 +161,10 @@ export default {
|
||||
return Object.values(VOLUME_MODE);
|
||||
},
|
||||
|
||||
accessModeOptions() {
|
||||
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
|
||||
},
|
||||
|
||||
imageOption() {
|
||||
return sortBy(
|
||||
this.images
|
||||
@ -275,6 +305,10 @@ export default {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
|
||||
},
|
||||
|
||||
isCreatePVCWithDataVolumeFeatureEnabled() {
|
||||
return this.$store.getters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
|
||||
},
|
||||
|
||||
isResizeDisabled() {
|
||||
return (
|
||||
!this.isLHV2VolExpansionFeatureEnabled &&
|
||||
@ -323,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) {
|
||||
@ -339,8 +373,60 @@ 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 = {
|
||||
storageClassName: this.value.spec.storageClassName,
|
||||
resources: { requests: { storage: this.storage } },
|
||||
};
|
||||
|
||||
if (this.showAdvanced && this.value.spec.accessModes?.length > 0) {
|
||||
storage.accessModes = this.value.spec.accessModes;
|
||||
}
|
||||
|
||||
if (this.showAdvanced && this.value.spec.volumeMode) {
|
||||
storage.volumeMode = this.value.spec.volumeMode;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'cdi.kubevirt.io.datavolume',
|
||||
apiVersion: 'cdi.kubevirt.io/v1beta1',
|
||||
kind: 'DataVolume',
|
||||
metadata: {
|
||||
name: this.value.metadata.name,
|
||||
namespace: this.value.metadata.namespace,
|
||||
annotations: this.value.metadata.annotations || {},
|
||||
labels: this.value.metadata.labels || {},
|
||||
},
|
||||
spec: {
|
||||
source: { blank: {} },
|
||||
storage,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async save(buttonDone) {
|
||||
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
|
||||
try {
|
||||
this.update();
|
||||
const dvObj = this.buildDataVolumeObj();
|
||||
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
|
||||
|
||||
await dataVolume.save();
|
||||
buttonDone(true);
|
||||
this.done();
|
||||
} catch (err) {
|
||||
const error = err?.data || err;
|
||||
|
||||
this['errors'] = exceptionToErrorsArray(error);
|
||||
buttonDone(false);
|
||||
}
|
||||
} else {
|
||||
await CreateEditView.methods.save.call(this, buttonDone);
|
||||
}
|
||||
},
|
||||
|
||||
willSave() {
|
||||
this.update();
|
||||
},
|
||||
@ -383,9 +469,17 @@ export default {
|
||||
this.update();
|
||||
},
|
||||
generateYaml() {
|
||||
const out = saferDump(this.value);
|
||||
this.update();
|
||||
|
||||
return out;
|
||||
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
|
||||
return saferDump(this.buildDataVolumeObj());
|
||||
}
|
||||
|
||||
const plain = clone(this.value);
|
||||
|
||||
delete plain.saveYaml;
|
||||
|
||||
return saferDump(plain);
|
||||
},
|
||||
}
|
||||
};
|
||||
@ -458,18 +552,6 @@ export default {
|
||||
@update:value="update"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="showVolumeMode"
|
||||
v-model:value="value.spec.volumeMode"
|
||||
:label="t('harvester.volume.volumeMode')"
|
||||
:options="volumeModeOptions"
|
||||
required
|
||||
:disabled="!isCreate"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@update:value="update"
|
||||
/>
|
||||
|
||||
<UnitInput
|
||||
v-model:value="storage"
|
||||
:label="t('harvester.volume.size')"
|
||||
@ -490,6 +572,44 @@ export default {
|
||||
>
|
||||
<span>{{ t('harvester.volume.longhorn.disableResize') }}</span>
|
||||
</Banner>
|
||||
|
||||
<div class="row mb-20">
|
||||
<Checkbox
|
||||
v-if="isCreate && isBlank && isCreatePVCWithDataVolumeFeatureEnabled"
|
||||
v-model:value="createWithDataVolume"
|
||||
:label="t('harvester.volume.createWithDataVolume')"
|
||||
tooltip-key="harvester.volume.createWithDataVolumeTooltip"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
v-if="isCreate && isCreatePVCWithDataVolumeFeatureEnabled"
|
||||
role="button"
|
||||
class="hand"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
{{ showAdvanced ? t('harvester.volume.hideAdvanced') : t('harvester.volume.showAdvanced') }}
|
||||
</a>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="showAdvanced"
|
||||
v-model:value="value.spec.accessModes"
|
||||
:label="t('harvester.volume.accessModes')"
|
||||
:options="accessModeOptions"
|
||||
:multiple="true"
|
||||
:mode="mode"
|
||||
class="mb-20 mt-20"
|
||||
@update:value="update"
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
v-if="showAdvanced"
|
||||
v-model:value="value.spec.volumeMode"
|
||||
:label="t('harvester.volume.volumeMode')"
|
||||
:options="volumeModeOptions"
|
||||
:mode="mode"
|
||||
class="mb-20"
|
||||
@update:value="update"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
v-if="!isCreate"
|
||||
|
||||
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>
|
||||
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