Compare commits

..
Author SHA1 Message Date
mergify[bot]andAndy Lee 8fbe1943d8 chore: bump version to v1.7.0 (#658) (#659)
(cherry picked from commit a86302c9d5)

Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-12-22 13:41:18 +08:00
mergify[bot]andfreeze ec6bc4d639 chore: bump version to v1.7.0-rc7 (#656) (#657)
(cherry picked from commit 5fe7e13fcd)

Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2025-12-16 20:27:51 +08:00
renovate[bot] 3824a14730 deps: update dependency @types/node to v20.19.27 (#655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-14 08:59:31 +00:00
mergify[bot]andAndy Lee 0fc8bece02 chore: bump version to v1.7.0-rc6 (#649) (#650)
(cherry picked from commit 5769588633)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-12-11 16:50:51 +08:00
mergify[bot]andAndy Lee 39764af627 fix: failed to create multiple VMs (#647) (#648)
(cherry picked from commit b29950f99c)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-12-11 16:17:02 +08:00
mergify[bot]andAndy Lee bdc87bda0e fix: do not inherit template secret when creating new VM (#643) (#646)
(cherry picked from commit 6c27a46274)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-12-10 17:18:33 +08:00
mergify[bot]andAndy Lee e0dc77624b feat: read addon displayname from label and add descheduler description (#644) (#645)
* refactor: display addon name from addon.harvesterhci.io/displayName label



* refactor: add descheduler description



---------


(cherry picked from commit b03fffbc30)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-12-10 15:16:08 +08:00
renovate[bot] c3ba10bd22 deps: update dependency node-forge to v1.3.3 (#641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-07 09:15:44 +00:00
mergify[bot]andfreeze 5b7d54d0a3 chore: bump to v1.7.0-rc5 (#636) (#637)
(cherry picked from commit 416098ffd8)

Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2025-12-04 22:45:15 +08:00
renovate[bot] 99a216dfa0 deps: update dependency yaml to v2.8.2 (#634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 16:55:46 +00:00
mergify[bot]andAndy Lee e4c85f510e chore: bump to v1.7.0-rc4 (#621) (#629)
(cherry picked from commit 3d7b96d86d)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-28 13:23:55 +08:00
mergify[bot]andCaio Torres f391f018de fix: create new secret on vm creation (#614) (#628)
* fix: create new secret on vm creation



* fix: ensure parseVM result is immutable



---------


(cherry picked from commit 0b37467f76)

Signed-off-by: Caio Torres <caio.torres@suse.com>
Co-authored-by: Caio Torres <caio.torres@suse.com>
2025-11-27 17:42:46 +08:00
renovate[bot] 4b2e92ea15 deps: update dependency node-forge to v1.3.2 [security] (#627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 14:31:50 +08:00
mergify[bot]andAndy Lee 2f956d5946 feat: allow user to attach volume to muliple VMs (#620) (#622)
(cherry picked from commit d94003f8c2)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-27 14:30:44 +08:00
renovate[bot] 5f8d556ea2 deps: update patch digest dependencies (#619)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-23 09:32:21 +00:00
mergify[bot] 396ab48f1c chore: bump version to v1.7.0-rc3 (#612) (#613) 2025-11-20 10:25:03 +08:00
mergify[bot]andYiya Chen 6b8c079018 fix: condition render namespaceOptions (#607) (#611)
(cherry picked from commit 1b183febdc)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-11-19 17:40:03 +08:00
mergify[bot]andAndy Lee 7f638e86c8 fix: change migConfiguration model to inherit from harvester resource (#608) (#609)
(cherry picked from commit 70d3b656f7)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-19 17:35:52 +08:00
mergify[bot]andAndy Lee 0c4955a766 feat: create related image storageclass before OS upgrade (#595) (#606)
* feat: create related image SC before upgrade



* refactor: update spec.targetStorageClassName



* refactor: based on comment



---------


(cherry picked from commit 10d19cd329)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-17 17:34:51 +08:00
mergify[bot]andYiya Chen 3f4ff30275 feat: modified placeholder (#599) (#605)
(cherry picked from commit 1715ae754c)

Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-11-17 17:08:20 +08:00
renovate[bot] 8e0332a364 deps: update dependency @types/node to v20.19.25 (#604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 13:03:17 +00:00
6700b2055e feat: add support for configuring transparent hugepages (#414) (#598)
* feat: add support for configuring transparent hugepages

Related-to: https://github.com/harvester/harvester/issues/5006



* fix: return empty object if hugepages can't be found for node

Related-to: https://github.com/harvester/harvester/issues/5006




---------




(cherry picked from commit 30de2b1a18)

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Tim Serong <tserong@suse.com>
Co-authored-by: Moritz Röhrich <moritz.rohrich@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-12 14:15:27 +08:00
mergify[bot]andAndy Lee e486852f7a chore: bump version to v1.7.0-rc2 (#596) (#597)
(cherry picked from commit 6fedcc353c)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-12 09:21:01 +08:00
mergify[bot]andYiya Chen c19341bec9 feat: support for HotPlugNICs from Kubevirt (#582) (#594)
* refactor: rename hotplug volume
* feat: add hotplug NIC
* feat: add hot unplug
* refactor: rename NIC
* feat: get latest status
* feat: disable not ready options
* feat: filter out system networks
* refactor: update wordings

---------


(cherry picked from commit f9bff21e84)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Yiya Chen <yiya.chen@suse.com>
2025-11-11 11:46:43 +08:00
mergify[bot]andAndy Lee 8cbb9d6b18 chore: update yarn.lock for @rancher/shell v3.0.8-rc.8 (#591) (#592)
(cherry picked from commit 6735826e15)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-10 16:19:48 +08:00
mergify[bot]andAndy Lee 00f0953592 chore: bump shell version to v3.0.8-rc.8 (#588) (#590)
(cherry picked from commit 9e17e239cf)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-10 15:05:04 +08:00
mergify[bot]andAndy Lee 58507f0b2e ci: lint last commit if is empty string or all zero (#584) (#586)
(cherry picked from commit db58024351)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-06 15:53:50 +08:00
mergify[bot]andTim Serong 7785d7f469 feat: enable snapshot and clone for LHv2 (#379) (#587)
Now that Longhorn supports volume clone with the V2 data engine, we
can enable volume snapshot and clone.

Related issue: https://github.com/harvester/harvester/issues/6710


(cherry picked from commit a1cf41bda9)

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Tim Serong <tserong@suse.com>
2025-11-06 15:53:20 +08:00
mergify[bot]andAndy Lee 2c043e0a8e chore: bump version to v1.7.0-rc1 (#583) (#585)
(cherry picked from commit 81bf19419c)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-04 16:12:14 +08:00
Andy Lee 6f90cae482 feat: add vGPU MIG Configuration page (#576)
* feat: add vGPU MIGConfiguration page

Signed-off-by: Andy Lee <andy.lee@suse.com>

* feat: add detail page

Signed-off-by: Andy Lee <andy.lee@suse.com>

* feat: add banner

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: allow editConfig when status is empty

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: remove unneeded code

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: only show disable action if MIGConfig is enabled

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: some UI flow changes

Signed-off-by: Andy Lee <andy.lee@suse.com>

* feat: show configured profile in table

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: show configured profiles with requested count

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: based on review

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-04 13:32:30 +08:00
renovate[bot] b4980a51e7 deps: update dependency @types/node to v20.19.24 (#578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 15:32:05 +08:00
Andy Lee 756ed383ac fix: namespace can't be selected the same in IP pool page (#560)
* fix: the namespacess option can't be selected twice in standalone UI

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: disabled slected ns

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-31 16:52:05 +08:00
Yiya Chen 7e0a9dcd80 feat: add kubevirt migration setting (#577)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-31 16:49:31 +08:00
devsymo 5fae6c3087 fix(image): Correctly handle query parameters in image URL validation (#569)
* fix(image): Correctly handle query parameters in image URL validation

The imageUrl validator currently fails to correctly extract the file extension
when the image URL contains query parameters or fragments (e.g., 'image.qcow2?token=abc').

This change introduces a dedicated function, `getFilenameFromUrl`,
which uses the native URL object for robust parsing.
This ensures the file suffix validation is always performed on the actual filename,
ignoring any trailing parameters.

Signed-off-by: devsymo <devsymo@hotmail.com>

* fix(lint): Resolve formatting and spacing warnings

Signed-off-by: devsymo <devsymo@hotmail.com>

* fix: adjusted filename extraction logic and remove duplicate code

Signed-off-by: DevSymo <DevSymo@hotmail.com>

---------

Signed-off-by: devsymo <devsymo@hotmail.com>
Signed-off-by: DevSymo <DevSymo@hotmail.com>
2025-10-30 16:02:35 +08:00
renovate[bot] a994d9861e deps: update patch digest dependencies (#572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 11:17:36 +08:00
Andy Lee 5d996c42dc ci: allow any string as commit scope (#571)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-23 14:18:02 +08:00
Andy Lee 88cd302ce4 fix: trunk input field is missing when creating L2VlanTrunkNetwork (#570)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-23 09:47:47 +08:00
Yiya Chen e6dd8d6771 feat: online Volume Resizing from the VM Page (#568)
* feat: add conditions tab
* feat: ignore restart for resizing
* refactor: remove unused code
---------
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-22 17:33:28 +08:00
Andy Lee 1a3822881e fix: view and edit mode for l2vlan trunk (#563)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-20 14:52:32 +08:00
renovate[bot] ee8bb21a10 deps: update dependency @types/node to v20.19.22 (#564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 10:44:54 +08:00
Yiya Chen e3d30a0eec feat: make size editable (#562)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-17 15:36:36 +08:00
Andy Lee 532b6c4d50 feat: add access / trunk mode in create VM network page (#510)
* feat: add l2VlanTrunkMode feature

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: remove unneeded code

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: fix edit l2vlan trunk mode edit page

Signed-off-by: Andy Lee <andy.lee@suse.com>

* fix: hide Route tab when trunk mode

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-17 15:28:21 +08:00
Yiya Chen f22644e058 fix: reset filesystem (#559)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-17 12:15:58 +08:00
Andy Lee cd128d0444 chore: limit v1.7.0 to install on Rancher 2.13.0+ (#558)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-15 17:38:09 +08:00
Yiya Chen 0278f51260 fix: WebVNC console shortcut keys not working correctly (#557)
* style: disable overflow

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* style: fix class name

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

---------

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-13 16:03:16 +08:00
Andy Lee 98efd63110 fix: abort upload when switch to URL option (#552)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-13 14:48:37 +08:00
renovate[bot] 78e78ac8dd deps: update patch digest dependencies (#553)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-12 22:27:42 +08:00
Yiya Chen 2aaa0a55a2 feat: add default value (#548)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-09 13:37:06 +08:00
Andy Lee 5c2a23924d fix: allow to edit as yaml if has empty CPU or memory (#547)
* fix: allow edit as yaml in create VM page if empty CPU or memory

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: remove getCPUMemoryValidation

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-08 16:11:27 +08:00
Yiya Chen 7af8a82838 feat: remove machine type dropdown (#546)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-10-08 13:41:35 +08:00
Caio Torres 3b343bcaca fix: create new secret on vm creation (#537)
Signed-off-by: Caio Torres <caio.torres@suse.com>
2025-10-07 17:23:18 +08:00
renovate[bot] 6ddd35d661 deps: update dependency @types/node to v20.19.19 (#542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 12:01:21 +08:00
Andy Lee bd28ba6f71 feat: add network policy page (#536)
* feat: add Network Policiies page

Signed-off-by: Andy Lee <andy.lee@suse.com>

* ci: add build

Signed-off-by: Andy Lee <andy.lee@suse.com>

* ci: ensure FROM and TO exist

Signed-off-by: Andy Lee <andy.lee@suse.com>

* ci: skip commitlint if FROM and TO emtpy

Signed-off-by: Andy Lee <andy.lee@suse.com>

* revert: unnecessary change

* feat: add banner

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-10-03 09:38:31 +08:00
Andy Lee 4bb67153ce fix: set maxSockets : 1 if not enabled cpuMemoryhotplug (#539) 2025-10-02 17:50:16 +08:00
Andy Lee 7dbd442519 fix: force fetch VM data (#535) 2025-09-25 18:55:47 +08:00
Andy Lee 5a0d7f283d fix: unable to create VM when choosing L2VlanNetwork (#534)
* fix: unable to create VM via l2vlan network

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: based on discussion

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: add comment

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-09-23 16:32:39 +08:00
Andy Lee 9fdbe9c58f feat: add ACL tab in create subnet page (#527)
* feat: add ACL tab in create subnet page

Signed-off-by: Andy Lee <andy.lee@suse.com>

* fix: typo

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-09-22 16:13:13 +08:00
renovate[bot] f652ed9d4b deps: update dependency @types/node to v20.19.17 (#530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 10:11:21 +08:00
Andy Lee 21a1cd4e89 feat: skip ksmtuned updated if there no related change (#526)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-09-22 10:03:34 +08:00
renovate[bot] facc74ca51 deps: update dependency @types/node to v20.19.14 (#522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 10:14:17 +08:00
53 changed files with 3414 additions and 871 deletions
+17 -3
View File
@@ -41,8 +41,14 @@ jobs:
FROM="$GITHUB_BASE_SHA" FROM="$GITHUB_BASE_SHA"
TO="$GITHUB_HEAD_SHA" TO="$GITHUB_HEAD_SHA"
elif [ -n "$GITHUB_BEFORE" ] && [ -n "$GITHUB_AFTER" ]; then elif [ -n "$GITHUB_BEFORE" ] && [ -n "$GITHUB_AFTER" ]; then
FROM="$GITHUB_BEFORE" if [ "$GITHUB_BEFORE" = "0000000000000000000000000000000000000000" ]; then
TO="$GITHUB_AFTER" # first push to HEAD
FROM=""
TO="$GITHUB_AFTER"
else
FROM="$GITHUB_BEFORE"
TO="$GITHUB_AFTER"
fi
else else
echo "No valid commit range found, skipping commitlint." echo "No valid commit range found, skipping commitlint."
exit 0 exit 0
@@ -50,7 +56,15 @@ jobs:
echo "FROM=$FROM" echo "FROM=$FROM"
echo "TO=$TO" echo "TO=$TO"
npx commitlint --from "$FROM" --to "$TO" --verbose
if [ -z "$FROM" ]; then
echo "Linting last commit $TO"
npx commitlint --last --verbose
else
echo "Linting commits from $FROM to $TO"
npx commitlint --from "$FROM" --to "$TO" --verbose
fi
env: env:
GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+4
View File
@@ -22,6 +22,10 @@ module.exports = {
'security', // Security fixes 'security', // Security fixes
] ]
], ],
// Allows the scope to be empty. [0] means the rule is disabled.
'scope-empty': [0, 'always'],
// Allows any string for the scope.
'scope-case': [2, 'always', 'kebab-case'],
'type-case': [2, 'always', 'lower-case'], 'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'], 'type-empty': [2, 'never'],
'subject-case': [0, 'never'], 'subject-case': [0, 'never'],
+6 -6
View File
@@ -1,13 +1,13 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.7.0-dev", "version": "1.7.0",
"private": false, "private": false,
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-class-static-block": "7.28.3", "@babel/plugin-transform-class-static-block": "7.28.3",
"@rancher/shell": "3.0.5-rc.8", "@rancher/shell": "3.0.8-rc.8",
"cache-loader": "^4.1.0", "cache-loader": "^4.1.0",
"color": "4.2.3", "color": "4.2.3",
"ip": "2.0.1", "ip": "2.0.1",
@@ -24,13 +24,13 @@
"glob": "7.2.3", "glob": "7.2.3",
"glob-parent": "6.0.2", "glob-parent": "6.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"@types/lodash": "4.17.20", "@types/lodash": "4.17.21",
"merge": "2.1.1", "merge": "2.1.1",
"node-forge": "1.3.1", "node-forge": "1.3.3",
"nth-check": "2.1.1", "nth-check": "2.1.1",
"qs": "6.14.0", "qs": "6.14.0",
"roarr": "7.21.1", "roarr": "7.21.2",
"semver": "7.7.2", "semver": "7.7.3",
"@vue/cli-service/html-webpack-plugin": "^5.0.0" "@vue/cli-service/html-webpack-plugin": "^5.0.0"
}, },
"scripts": { "scripts": {
@@ -391,4 +391,7 @@ export default {
.vm-console .v-popper__popper{ .vm-console .v-popper__popper{
margin-top: 8px; margin-top: 8px;
} }
.vm-console .v-popper__inner{
overflow-y: visible;
}
</style> </style>
@@ -0,0 +1,154 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
import UnitInput from '@shell/components/form/UnitInput';
import { MEBIBYTE } from '../../utils/unit';
import { Banner } from '@components/Banner';
export default {
name: 'KubevirtMigration',
components: {
LabeledInput,
UnitInput,
RadioGroup,
Banner
},
props: {
registerBeforeHook: {
type: Function,
required: true
},
value: {
type: Object,
default: () => ({
value: '',
default: '{}'
})
},
},
data() {
const migration = this.parseJSON(this.value?.value) || this.parseJSON(this.value?.default) || {};
return {
MEBIBYTE,
migration,
parseError: null,
};
},
created() {
this.registerBeforeHook?.(this.willSave, 'willSave');
},
methods: {
parseJSON(string) {
try {
return JSON.parse(string);
} catch (e) {
this.parseError = this.t('kubevirtMigration.parseError', { error: e.message });
// eslint-disable-next-line no-console
console.error('Failed to parse JSON:', e.message);
return null;
}
},
updateValue() {
if (this.value) {
this.value.value = JSON.stringify(this.migration);
}
},
useDefault() {
if (this.value?.default) {
const defaultMigration = this.parseJSON(this.value.default) || {};
this.migration = defaultMigration;
this.updateValue();
}
},
async willSave() {
this.updateValue();
return Promise.resolve();
},
},
};
</script>
<template>
<div>
<Banner
v-if="parseError"
color="error"
>
{{ parseError }}
</Banner>
<div class="migration-field">
<LabeledInput
v-model:value.number="migration.parallelMigrationsPerCluster"
:label="t('harvester.setting.kubevirtMigration.parallelMigrationsPerCluster')"
type="number"
min="1"
/>
<LabeledInput
v-model:value.number="migration.parallelOutboundMigrationsPerNode"
:label="t('harvester.setting.kubevirtMigration.parallelOutboundMigrationsPerNode')"
type="number"
min="1"
/>
<UnitInput
v-model:value="migration.bandwidthPerMigration"
min="0"
:label="t('harvester.setting.kubevirtMigration.bandwidthPerMigration')"
:mode="mode"
:suffix="MEBIBYTE"
:tooltip="t('harvester.setting.kubevirtMigration.bandwidthPerMigrationTooltip', _, true)"
/>
<UnitInput
v-model:value="migration.completionTimeoutPerGiB"
:label="t('harvester.setting.kubevirtMigration.completionTimeoutPerGiB')"
:mode="mode"
:suffix="migration.completionTimeoutPerGiB === 1 ? 'Second' : 'Seconds'"
min="10"
/>
<UnitInput
v-model:value="migration.progressTimeout"
:label="t('harvester.setting.kubevirtMigration.progressTimeout')"
:mode="mode"
:suffix="migration.progressTimeout === 1 ? 'Second' : 'Seconds'"
min="10"
/>
<div
v-for="field in ['allowAutoConverge','allowPostCopy','unsafeMigrationOverride','allowWorkloadDisruption','disableTLS','matchSELinuxLevelOnMigration']"
:key="field"
>
<label
class="mb-5"
:for="field"
>{{ t(`harvester.setting.kubevirtMigration.${field}`) }}</label>
<RadioGroup
:id="field"
v-model:value="migration[field]"
:options="[
{ label: t('advancedSettings.edit.trueOption'), value: true },
{ label: t('advancedSettings.edit.falseOption'), value: false },
]"
/>
</div>
</div>
</div>
</template>
<style scoped>
.migration-field {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
@@ -18,16 +18,20 @@ export default {
await this.$store.dispatch('harvester/findAll', { type: NAMESPACE }); await this.$store.dispatch('harvester/findAll', { type: NAMESPACE });
try { if (this.customSupportBundleFeatureEnabled) {
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/namespaces?link=supportbundle'); try {
const response = await this.$store.dispatch('harvester/request', { url }); const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/namespaces?link=supportbundle');
const response = await this.$store.dispatch('harvester/request', { url });
this.defaultNamespaces = response.data || []; this.defaultNamespaces = response.data || [];
} catch (error) { } catch (error) {
this.defaultNamespaces = [];
}
} else {
this.defaultNamespaces = []; this.defaultNamespaces = [];
} finally {
this.loading = false;
} }
this.loading = false;
}, },
data() { data() {
@@ -42,24 +46,38 @@ export default {
}, },
computed: { computed: {
customSupportBundleFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('customSupportBundle');
},
allNamespaces() { allNamespaces() {
return this.$store.getters['harvester/all'](NAMESPACE).map((ns) => ns.id); return this.$store.getters['harvester/all'](NAMESPACE).map((ns) => ns.id);
}, },
filteredNamespaces() { filteredNamespaces() {
if (!this.customSupportBundleFeatureEnabled) {
return this.allNamespaces;
}
const defaultIds = this.defaultNamespaces.map((ns) => ns.id); const defaultIds = this.defaultNamespaces.map((ns) => ns.id);
return this.allNamespaces.filter((ns) => !defaultIds.includes(ns)); return this.allNamespaces.filter((ns) => !defaultIds.includes(ns));
}, },
namespaceOptions() { namespaceOptions() {
const mappedNamespaces = this.filteredNamespaces.map((ns) => ({ label: ns, value: ns }));
if (!this.customSupportBundleFeatureEnabled) {
return mappedNamespaces;
}
const allSelected = const allSelected =
this.namespaces.length === this.filteredNamespaces.length && this.namespaces.length === this.filteredNamespaces.length &&
this.filteredNamespaces.every((ns) => this.namespaces.includes(ns)); this.filteredNamespaces.every((ns) => this.namespaces.includes(ns));
const controlOption = allSelected ? { label: this.t('harvester.modal.bundle.namespaces.unselectAll'), value: UNSELECT_ALL } : { label: this.t('harvester.modal.bundle.namespaces.selectAll'), value: SELECT_ALL }; const controlOption = allSelected ? { label: this.t('harvester.modal.bundle.namespaces.unselectAll'), value: UNSELECT_ALL } : { label: this.t('harvester.modal.bundle.namespaces.selectAll'), value: SELECT_ALL };
return [controlOption, ...this.filteredNamespaces]; return [controlOption, ...mappedNamespaces];
} }
}, },
+2
View File
@@ -8,4 +8,6 @@ export const DOC = {
STORAGE_NETWORK_EXAMPLE: `/advanced/storagenetwork#configuration-example`, STORAGE_NETWORK_EXAMPLE: `/advanced/storagenetwork#configuration-example`,
SUPPORT_BUNDLE_NAMESPACES: `/advanced/index/#support-bundle-namespaces`, SUPPORT_BUNDLE_NAMESPACES: `/advanced/index/#support-bundle-namespaces`,
VPC_CONFIGURATION_EXAMPLES: `/networking/kubeovn-vpc#vpc-peering-configuration-examples`, VPC_CONFIGURATION_EXAMPLES: `/networking/kubeovn-vpc#vpc-peering-configuration-examples`,
NETWORK_POLICY: `/networking/kubeovn-vm-isolation/#network-policies`,
TRANSPARENT_HUGEPAGES: `https://docs.kernel.org/admin-guide/mm/transhuge.html`,
}; };
+7 -2
View File
@@ -39,7 +39,6 @@ const FEATURE_FLAGS = {
], ],
'v1.5.1': [], 'v1.5.1': [],
'v1.6.0': [ 'v1.6.0': [
'vmMachineTypes',
'customSupportBundle', 'customSupportBundle',
'csiOnlineExpandValidation', 'csiOnlineExpandValidation',
'vmNetworkMigration', 'vmNetworkMigration',
@@ -50,7 +49,13 @@ const FEATURE_FLAGS = {
'vmCloneRunStrategy', 'vmCloneRunStrategy',
], ],
'v1.6.1': [], 'v1.6.1': [],
'v1.7.0': [] 'v1.7.0': [
'vmMachineTypeAuto',
'lhV2VolExpansion',
'l2VlanTrunkMode',
'kubevirtMigration',
'hotplugNic'
]
}; };
const generateFeatureFlags = () => { const generateFeatureFlags = () => {
+38
View File
@@ -10,6 +10,7 @@ import {
LOGGING, LOGGING,
STORAGE_CLASS, STORAGE_CLASS,
SECRET, SECRET,
NETWORK_POLICY
} from '@shell/config/types'; } from '@shell/config/types';
import { HCI, VOLUME_SNAPSHOT } from '../types'; import { HCI, VOLUME_SNAPSHOT } from '../types';
import { import {
@@ -425,6 +426,7 @@ export function init($plugin, store) {
HCI.CLUSTER_NETWORK, HCI.CLUSTER_NETWORK,
HCI.NETWORK_ATTACHMENT, HCI.NETWORK_ATTACHMENT,
HCI.VPC, HCI.VPC,
NETWORK_POLICY,
HCI.LB, HCI.LB,
HCI.IP_POOL, HCI.IP_POOL,
], ],
@@ -455,6 +457,7 @@ export function init($plugin, store) {
HCI.PCI_DEVICE, HCI.PCI_DEVICE,
HCI.SR_IOVGPU_DEVICE, HCI.SR_IOVGPU_DEVICE,
HCI.VGPU_DEVICE, HCI.VGPU_DEVICE,
HCI.MIG_CONFIGURATION,
HCI.USB_DEVICE, HCI.USB_DEVICE,
HCI.ADD_ONS, HCI.ADD_ONS,
HCI.SECRET, HCI.SECRET,
@@ -566,6 +569,21 @@ export function init($plugin, store) {
ifHaveType: HCI.VPC, ifHaveType: HCI.VPC,
}); });
configureType(NETWORK_POLICY, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.networkPolicy.label',
name: NETWORK_POLICY,
namespaced: true,
weight: 186,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: NETWORK_POLICY }
},
exact: false,
ifHaveType: NETWORK_POLICY,
});
configureType(HCI.SNAPSHOT, { configureType(HCI.SNAPSHOT, {
isCreatable: false, isCreatable: false,
location: { location: {
@@ -832,6 +850,26 @@ export function init($plugin, store) {
] ]
}); });
virtualType({
labelKey: 'harvester.migconfiguration.label',
group: 'advanced',
weight: 12,
name: HCI.MIG_CONFIGURATION,
namespaced: false,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.MIG_CONFIGURATION }
},
exact: false,
ifHaveType: HCI.MIG_CONFIGURATION,
});
configureType(HCI.MIG_CONFIGURATION, {
isCreatable: false,
hiddenNamespaceGroupButton: true,
canYaml: false,
});
virtualType({ virtualType({
labelKey: 'harvester.usb.label', labelKey: 'harvester.usb.label',
group: 'advanced', group: 'advanced',
@@ -50,6 +50,7 @@ export const HCI = {
STORAGE_CLASS: 'harvesterhci.io/storageClassName', STORAGE_CLASS: 'harvesterhci.io/storageClassName',
STORAGE_NETWORK: 'storage-network.settings.harvesterhci.io', STORAGE_NETWORK: 'storage-network.settings.harvesterhci.io',
ADDON_EXPERIMENTAL: 'addon.harvesterhci.io/experimental', ADDON_EXPERIMENTAL: 'addon.harvesterhci.io/experimental',
ADDON_DISPLAYNAME: 'addon.harvesterhci.io/displayName',
VOLUME_ERROR: 'longhorn.io/volume-scheduling-error', VOLUME_ERROR: 'longhorn.io/volume-scheduling-error',
VOLUME_FOR_VM: 'harvesterhci.io/volumeForVirtualMachine', VOLUME_FOR_VM: 'harvesterhci.io/volumeForVirtualMachine',
KVM_AMD_CPU: 'cpu-feature.node.kubevirt.io/svm', KVM_AMD_CPU: 'cpu-feature.node.kubevirt.io/svm',
+5 -1
View File
@@ -37,7 +37,8 @@ export const HCI_SETTING = {
UPGRADE_CONFIG: 'upgrade-config', UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network', VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster', RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio' MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration'
}; };
export const HCI_ALLOWED_SETTINGS = { export const HCI_ALLOWED_SETTINGS = {
@@ -115,6 +116,9 @@ export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.VM_MIGRATION_NETWORK]: { [HCI_SETTING.VM_MIGRATION_NETWORK]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'vmNetworkMigration', kind: 'json', from: 'import', canReset: true, featureFlag: 'vmNetworkMigration',
}, },
[HCI_SETTING.KUBEVIRT_MIGRATION]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'kubevirtMigration',
}
}; };
export const HCI_SINGLE_CLUSTER_ALLOWED_SETTING = { export const HCI_SINGLE_CLUSTER_ALLOWED_SETTING = {
+9 -3
View File
@@ -4,9 +4,10 @@ export const BACKUP_TYPE = {
}; };
export const NETWORK_TYPE = { export const NETWORK_TYPE = {
L2VLAN: 'L2VlanNetwork', L2VLAN: 'L2VlanNetwork',
UNTAGGED: 'UntaggedNetwork', UNTAGGED: 'UntaggedNetwork',
OVERLAY: 'OverlayNetwork', OVERLAY: 'OverlayNetwork',
L2TRUNK_VLAN: 'L2VlanTrunkNetwork',
}; };
export const VOLUME_MODE = { export const VOLUME_MODE = {
@@ -23,3 +24,8 @@ export const INTERNAL_STORAGE_CLASS = {
VMSTATE_PERSISTENCE: 'vmstate-persistence', VMSTATE_PERSISTENCE: 'vmstate-persistence',
LONGHORN_STATIC: 'longhorn-static', LONGHORN_STATIC: 'longhorn-static',
}; };
export const L2VLAN_MODE = {
ACCESS: 'access',
TRUNK: 'trunk',
};
@@ -0,0 +1,124 @@
<script>
import CreateEditView from '@shell/mixins/create-edit-view';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import SortableTable from '@shell/components/SortableTable';
export default {
components: {
ResourceTabs,
Tab,
SortableTable,
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
computed: {
headers() {
return [
{
name: 'profileName',
labelKey: 'harvester.migconfiguration.tableHeaders.profileName',
value: 'name',
width: 75,
sort: 'name',
dashIfEmpty: true,
},
{
name: 'vGPUID',
labelKey: 'harvester.migconfiguration.tableHeaders.vGPUID',
value: 'vGPUID',
width: 75,
sort: 'vGPUID',
dashIfEmpty: true,
},
{
name: 'available',
labelKey: 'harvester.migconfiguration.tableHeaders.available',
value: 'available',
width: 75,
sort: 'available',
align: 'center',
dashIfEmpty: true,
},
{
name: 'requested',
labelKey: 'harvester.migconfiguration.tableHeaders.requested',
value: 'requested',
width: 75,
sort: 'requested',
align: 'center',
dashIfEmpty: true,
},
{
name: 'total',
labelKey: 'harvester.migconfiguration.tableHeaders.total',
value: 'total',
width: 75,
sort: 'total',
align: 'center',
dashIfEmpty: true,
},
];
},
rows() {
let out = (this.value?.status?.profileStatus || []).map((profile) => {
const {
id, name, total, available
} = profile;
return {
id,
name,
total,
available,
vGPUID: profile.vGPUID?.join(', ') || '',
};
});
out = out.map((row) => {
const requested = this.value?.spec?.profileSpec.find((p) => p.id === row.id)?.requested || 0;
return { ...row, requested };
});
return out;
},
},
};
</script>
<template>
<ResourceTabs
:value="value"
:need-events="false"
:need-related="false"
:mode="mode"
>
<Tab
name="Profile Status"
:label="t('harvester.migconfiguration.profileStatus')"
>
<SortableTable
:headers="headers"
:rows="rows"
key-field="condition"
default-sort-by="condition"
:table-actions="false"
:row-actions="false"
:search="false"
/>
</Tab>
</ResourceTabs>
</template>
@@ -0,0 +1,119 @@
<script>
import LabelValue from '@shell/components/LabelValue';
import { HCI } from '../../types';
import { DOC } from '../../config/doc-links';
export default {
name: 'HarvesterHugepages',
components: { LabelValue },
props: {
node: {
type: Object,
required: true,
},
},
computed: {
docsTransparentHugepagesLink() {
return DOC.TRANSPARENT_HUGEPAGES;
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HUGEPAGES });
this.hugepages = hash.find((node) => {
return node.id === this.node.id;
}) || {};
},
data() {
return { hugepages: {} };
},
};
</script>
<template>
<div>
<template v-if="hugepages.status">
<h2>{{ t('harvester.host.hugepages.meminfo') }}</h2>
<div class="row mb-20">
<div class="col span-6">
<LabelValue
:name="t('harvester.host.hugepages.status.anon')"
:value="hugepages.status.meminfo.anonHugePages"
/>
</div>
<div class="col span-6">
<LabelValue
:name="t('harvester.host.hugepages.status.size')"
:value="hugepages.status.meminfo.hugepageSize"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.total')"
:value="hugepages.status.meminfo.hugePagesTotal"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.free')"
:value="hugepages.status.meminfo.hugePagesFree"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.rsvd')"
:value="hugepages.status.meminfo.hugePagesRsvd"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.host.hugepages.status.surp')"
:value="hugepages.status.meminfo.hugePagesSurp"
/>
</div>
</div>
<div>
<hr class="divider" />
<h3>
<t
k="harvester.host.hugepages.transparent.title"
:raw="true"
:url="docsTransparentHugepagesLink"
/>
</h3>
<div class="row mb-20">
<div class="col span-4">
<LabelValue
:name="t('harvester.host.hugepages.transparent.enabled')"
:value="hugepages.spec.transparent.enabled"
/>
</div>
<div class="col span-4">
<LabelValue
:name="t('harvester.host.hugepages.transparent.shmemEnabled')"
:value="hugepages.spec.transparent.shmemEnabled"
/>
</div>
<div class="col span-4">
<LabelValue
:name="t('harvester.host.hugepages.transparent.defrag')"
:value="hugepages.spec.transparent.defrag"
/>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -27,6 +27,7 @@ import Instance from './VirtualMachineInstance';
import Disk from './HarvesterHostDisk'; import Disk from './HarvesterHostDisk';
import VlanStatus from './VlanStatus'; import VlanStatus from './VlanStatus';
import HarvesterKsmtuned from './HarvesterKsmtuned.vue'; import HarvesterKsmtuned from './HarvesterKsmtuned.vue';
import HarvesterHugepages from './HarvesterHugepages.vue';
import HarvesterSeeder from './HarvesterSeeder'; import HarvesterSeeder from './HarvesterSeeder';
const LONGHORN_SYSTEM = 'longhorn-system'; const LONGHORN_SYSTEM = 'longhorn-system';
@@ -46,6 +47,7 @@ export default {
VlanStatus, VlanStatus,
LabelValue, LabelValue,
HarvesterKsmtuned, HarvesterKsmtuned,
HarvesterHugepages,
Loading, Loading,
SortableTable, SortableTable,
HarvesterSeeder, HarvesterSeeder,
@@ -209,6 +211,12 @@ export default {
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED); return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED);
}, },
hasHugepagesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.HUGEPAGES);
},
hasBlockDevicesSchema() { hasBlockDevicesSchema() {
return !!this.$store.getters['harvester/schemaFor'](HCI.BLOCK_DEVICE); return !!this.$store.getters['harvester/schemaFor'](HCI.BLOCK_DEVICE);
}, },
@@ -468,6 +476,16 @@ export default {
/> />
</Tab> </Tab>
<Tab
v-if="hasHugepagesSchema"
name="hugepages"
:weight="0"
:show-header="false"
:label="t('harvester.host.tabs.hugepages')"
>
<HarvesterHugepages :node="value" />
</Tab>
<Tab <Tab
v-if="seederEnabled" v-if="seederEnabled"
name="seeder" name="seeder"
@@ -213,6 +213,7 @@ export default {
const diskRows = this.getDiskRows(neu); const diskRows = this.getDiskRows(neu);
this['diskRows'] = diskRows; this['diskRows'] = diskRows;
this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false });
}, },
deep: true deep: true
} }
@@ -265,6 +266,7 @@ export default {
<Network <Network
v-model:value="networkRows" v-model:value="networkRows"
mode="view" mode="view"
:vm="value"
/> />
</Tab> </Tab>
@@ -32,7 +32,7 @@ export default {
resources: { resources: {
type: Array, type: Array,
required: true required: true
} },
}, },
data() { data() {
@@ -43,7 +43,7 @@ export default {
...mapState('action-menu', ['modalData']), ...mapState('action-menu', ['modalData']),
title() { title() {
return this.modalData.title || 'dialog.promptRemove.title'; return this.modalData?.title || 'dialog.promptRemove.title';
}, },
formattedType() { formattedType() {
@@ -51,7 +51,7 @@ export default {
}, },
warningMessage() { warningMessage() {
if (this.modalData.warningMessage) return this.modalData.warningMessage; if (this.modalData?.warningMessage) return this.modalData.warningMessage;
const isPlural = this.type.endsWith('s'); const isPlural = this.type.endsWith('s');
const thisOrThese = isPlural ? 'these' : 'this'; const thisOrThese = isPlural ? 'these' : 'this';
@@ -145,6 +145,7 @@ export default {
try { try {
for (const resource of this.resources) { for (const resource of this.resources) {
await resource.remove(); await resource.remove();
if (this.modalData?.extraActionAfterRemove) await this.modalData.extraActionAfterRemove();
} }
buttonDone(true); buttonDone(true);
this.close(); this.close();
@@ -0,0 +1,212 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapGetters } from 'vuex';
import { NETWORK_ATTACHMENT } from '@shell/config/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 { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { NETWORK_TYPE } from '../config/types';
export default {
name: 'AddHotplugNic',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
try {
this.allVMNetworks = await this.$store.dispatch('harvester/findAll', { type: NETWORK_ATTACHMENT });
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.allVMNetworks = [];
}
},
data() {
return {
interfaceName: '',
networkName: '',
macAddress: '',
allVMNetworks: [],
errors: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources?.[0];
},
isFormValid() {
return this.interfaceName !== '' && this.networkName !== '';
},
vmNetworksOption() {
return this.allVMNetworks
.filter((network) => {
const labels = network.metadata?.labels || {};
const type = labels[HCI_ANNOTATIONS.NETWORK_TYPE];
const isValidType = [
NETWORK_TYPE.L2VLAN,
NETWORK_TYPE.UNTAGGED,
NETWORK_TYPE.L2TRUNK_VLAN,
].includes(type);
return isValidType && !network.isSystem;
})
.map((network) => {
const label = network.isNotReady ? `${ network.id } (${ this.t('generic.notReady') })` : network.id;
return ({
label,
value: network.id || '',
disabled: network.isNotReady,
});
});
}
},
methods: {
close() {
this.interfaceName = '';
this.networkName = '';
this.macAddress = '';
this.errors = [];
this.$emit('close');
},
async save(buttonCb) {
if (!this.actionResource) {
buttonCb(false);
return;
}
const payload = {
interfaceName: this.interfaceName,
networkName: this.networkName
};
if (this.macAddress) {
payload.macAddress = this.macAddress;
}
try {
const res = await this.actionResource.doAction('addNic', payload);
if ([200, 204].includes(res?._status)) {
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.hotplugNic.success', {
interfaceName: this.interfaceName,
vm: this.actionResource.nameDisplay
})
}, { root: true });
this.close();
buttonCb(true);
} else {
this.errors = exceptionToErrorsArray(res);
buttonCb(false);
}
} catch (err) {
this.errors = exceptionToErrorsArray(err);
buttonCb(false);
}
}
}
};
</script>
<template>
<Card
ref="modal"
name="modal"
:show-highlight-border="false"
>
<template #title>
<h4
v-clean-html="t('harvester.modal.hotplugNic.title')"
class="text-default-text"
/>
</template>
<template #body>
<LabeledInput
v-model:value="interfaceName"
:label="t('generic.name')"
required
/>
<LabeledSelect
v-model:value="networkName"
class="mt-20"
:label="t('harvester.modal.hotplugNic.vmNetwork')"
:options="vmNetworksOption"
required
/>
<LabeledInput
v-model:value="macAddress"
class="mt-20"
label-key="harvester.modal.hotplugNic.macAddress"
:tooltip="t('harvester.modal.hotplugNic.macAddressTooltip', _, true)"
/>
<Banner
v-for="(err, i) in errors"
:key="i"
:label="err"
color="error"
/>
</template>
<template #actions>
<div class="actions">
<div class="buttons">
<button
type="button"
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="!isFormValid"
@click="save"
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -11,7 +11,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
export default { export default {
name: 'HotplugModal', name: 'HotplugVolumeModal',
emits: ['close'], emits: ['close'],
@@ -62,7 +62,7 @@ export default {
return false; return false;
} }
return !pvc.attachVM; return true;
}) })
.map((pvc) => { .map((pvc) => {
return { return {
@@ -90,7 +90,7 @@ export default {
if (res._status === 200 || res._status === 204) { if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', { this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'), title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.hotplug.success', { diskName: this.diskName, vm: this.actionResource.nameDisplay }) message: this.t('harvester.modal.hotplugVolume.success', { diskName: this.diskName, vm: this.actionResource.nameDisplay })
}, { root: true }); }, { root: true });
this.close(); this.close();
@@ -122,7 +122,7 @@ export default {
> >
<template #title> <template #title>
<h4 <h4
v-clean-html="t('harvester.modal.hotplug.title')" v-clean-html="t('harvester.modal.hotplugVolume.title')"
class="text-default-text" class="text-default-text"
/> />
</template> </template>
@@ -1,13 +1,12 @@
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import { exceptionToErrorsArray } from '@shell/utils/error'; import { exceptionToErrorsArray } from '@shell/utils/error';
import { Card } from '@components/Card'; import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
export default { export default {
name: 'HarvesterHotUnplugModal', name: 'HarvesterHotUnplug',
emits: ['close'], emits: ['close'],
@@ -35,8 +34,25 @@ export default {
actionResource() { actionResource() {
return this.resources[0]; return this.resources[0];
}, },
diskName() {
return this.modalData.diskName; name() {
return this.modalData.name;
},
isVolume() {
return this.modalData.type === 'volume';
},
titleKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.title' : 'harvester.virtualMachine.hotUnplug.detachNIC.title';
},
actionLabelKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel' : 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabel';
},
successMessageKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.success' : 'harvester.virtualMachine.hotUnplug.detachNIC.success';
} }
}, },
@@ -47,14 +63,20 @@ export default {
async save(buttonCb) { async save(buttonCb) {
try { try {
const res = await this.actionResource.doAction('removeVolume', { diskName: this.diskName }); let res;
if (this.isVolume) {
res = await this.actionResource.doAction('removeVolume', { diskName: this.name });
} else {
res = await this.actionResource.doAction('removeNic', { interfaceName: this.name });
}
if (res._status === 200 || res._status === 204) { if (res._status === 200 || res._status === 204) {
this.$store.dispatch( this.$store.dispatch(
'growl/success', 'growl/success',
{ {
title: this.t('generic.notification.title.succeed'), title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.hotunplug.success', { name: this.diskName }) message: this.t(this.successMessageKey, { name: this.name })
}, },
{ root: true } { root: true }
); );
@@ -64,14 +86,14 @@ export default {
} else { } else {
const error = [res?.data] || exceptionToErrorsArray(res); const error = [res?.data] || exceptionToErrorsArray(res);
this['errors'] = error; this.errors = error;
buttonCb(false); buttonCb(false);
} }
} catch (err) { } catch (err) {
const error = err?.data || err; const error = err?.data || err;
const message = exceptionToErrorsArray(error); const message = exceptionToErrorsArray(error);
this['errors'] = message; this.errors = message;
buttonCb(false); buttonCb(false);
} }
} }
@@ -87,7 +109,7 @@ export default {
> >
<template #title> <template #title>
<h4 <h4
v-clean-html="t('harvester.virtualMachine.unplug.title', { name: diskName })" v-clean-html="t(titleKey, { name })"
class="text-default-text" class="text-default-text"
/> />
<Banner <Banner
@@ -111,9 +133,9 @@ export default {
<AsyncButton <AsyncButton
mode="apply" mode="apply"
:action-label="t('harvester.virtualMachine.unplug.actionLabel')" :action-label="t(actionLabelKey)"
:waiting-label="t('harvester.virtualMachine.unplug.actionLabel')" :waiting-label="t(actionLabelKey)"
:success-label="t('harvester.virtualMachine.unplug.actionLabel')" :success-label="t(actionLabelKey)"
@click="save" @click="save"
/> />
</div> </div>
@@ -132,4 +154,8 @@ export default {
justify-content: flex-end; justify-content: flex-end;
width: 100%; width: 100%;
} }
::v-deep(.card-title) {
display: block;
}
</style> </style>
@@ -0,0 +1,133 @@
<script>
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import CruResource from '@shell/components/CruResource';
import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import LabelValue from '@shell/components/LabelValue';
import CreateEditView from '@shell/mixins/create-edit-view';
export default {
name: 'HarvesterEditMIGConfiguration',
components: {
Tab,
Tabbed,
CruResource,
LabeledInput,
NameNsDescription,
LabelValue
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
const { profileSpec } = this.value.spec;
return { profileSpec: profileSpec || [] };
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
isView() {
return this.mode === 'view';
},
},
methods: {
updateBeforeSave() {
// MIGConfiguration CRD don't have any namespace field,
// so we need to remove the namespace field before saving
delete this.value.metadata.namespace;
// enable the MIGConfiguration when saving
this.value.spec.enabled = true;
},
labelTitle(profile) {
return `${ profile.name } (available : ${ this.available(profile) })`;
},
available(profile) {
const count = this.value.status?.profileStatus?.find((p) => p.id === profile.id)?.available;
return count || 0;
},
updateRequested(neu, profile) {
if (neu === null || neu === '') return;
const newValue = Number(neu);
const availableCount = this.available(profile);
if (newValue < 0) {
profile.requested = 0;
} else if ( newValue > availableCount ) {
profile.requested = availableCount;
} else {
profile.requested = newValue;
}
}
},
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
finish-button-mode="enable"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="profileSpec"
:label="t('harvester.migconfiguration.profileSpec')"
:weight="1"
class="bordered-table"
>
<div
v-for="(profile, index) in profileSpec"
:key="index"
>
<LabelValue
:value="labelTitle(profile)"
class="mb-10"
/>
<LabeledInput
v-model:value="profile.requested"
:min="0"
:disabled="isView"
type="number"
class="mb-20"
:label="`${t('harvester.migconfiguration.requested')}`"
@update:value="updateRequested($event, profile)"
/>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -8,7 +8,7 @@ import LabelValue from '@shell/components/LabelValue';
import { BadgeState } from '@components/BadgeState'; import { BadgeState } from '@components/BadgeState';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RadioGroup, RadioButton } from '@components/Form/Radio'; import { RadioGroup } from '@components/Form/Radio';
import HarvesterDisk from '../../mixins/harvester-disk'; import HarvesterDisk from '../../mixins/harvester-disk';
import Tags from '../../components/DiskTags'; import Tags from '../../components/DiskTags';
import { HCI } from '../../types'; import { HCI } from '../../types';
@@ -30,7 +30,6 @@ export default {
BadgeState, BadgeState,
Banner, Banner,
RadioGroup, RadioGroup,
RadioButton,
ModalWithCard, ModalWithCard,
Tags, Tags,
}, },
@@ -184,12 +183,11 @@ export default {
}, },
forceFormattedDisabled() { forceFormattedDisabled() {
const lastFormattedAt = this.blockDevice?.status?.deviceStatus?.fileSystem?.LastFormattedAt;
const fileSystem = this.blockDevice?.status?.deviceStatus?.fileSystem.type; const fileSystem = this.blockDevice?.status?.deviceStatus?.fileSystem.type;
const systems = ['ext4', 'XFS']; const systems = ['ext4', 'XFS'];
if (lastFormattedAt || this.blockDevice?.childParts?.length > 0) { if (this.blockDevice?.childParts?.length > 0) {
return true; return true;
} else if (systems.includes(fileSystem)) { } else if (systems.includes(fileSystem)) {
return false; return false;
@@ -446,7 +444,7 @@ export default {
/> />
</div> </div>
<div <div
v-if="(value.isNew && isLonghornV1 && !isFormatted) || isCorrupted" v-if="(value.isNew && isLonghornV1) || isCorrupted"
class="col span-6" class="col span-6"
> >
<RadioGroup <RadioGroup
@@ -459,15 +457,6 @@ export default {
:disabled="forceFormattedDisabled" :disabled="forceFormattedDisabled"
tooltip-key="harvester.host.disk.forceFormatted.toolTip" tooltip-key="harvester.host.disk.forceFormatted.toolTip"
> >
<template #1="{option, listeners}">
<RadioButton
:label="option.label"
:val="option.value"
:value="value.forceFormatted"
:disabled="forceFormattedDisabled && !value.forceFormatted"
v-on="listeners"
/>
</template>
</RadioGroup> </RadioGroup>
</div> </div>
<div <div
@@ -0,0 +1,157 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { HCI } from '../../types';
import { DOC } from '../../config/doc-links';
export const hugepagesTHPEnabledMode = [{
label: 'Always',
value: 'always',
}, {
label: 'Madvise',
value: 'madvise',
}, {
label: 'Never',
value: 'never',
}];
export const hugepagesTHPShmemEnabledMode = [{
label: 'Always',
value: 'always',
}, {
label: 'Within Size',
value: 'within_size',
}, {
label: 'Advise',
value: 'advise',
}, {
label: 'Never',
value: 'never',
}, {
label: 'Deny',
value: 'deny',
}, {
label: 'Force',
value: 'force',
}];
export const hugepagesTHPDefragMode = [{
label: 'Always',
value: 'always',
}, {
label: 'Defer',
value: 'defer',
}, {
label: 'Defer+Madvise',
value: 'defer+madvise',
}, {
label: 'Madvise',
value: 'madvise',
}, {
label: 'Never',
value: 'never'
}];
export default {
name: 'HarvesterHugepages',
components: { LabeledSelect },
props: {
node: {
type: Object,
required: true,
},
registerBeforeHook: {
type: Function,
required: true,
},
},
computed: {
docsTransparentHugepagesLink() {
return DOC.TRANSPARENT_HUGEPAGES;
},
},
methods: {
async saveHugepages() {
this.hugepages['spec'] = this.spec;
await this.hugepages.save().catch((reason) => {
if (reason?.type === 'error') {
this.$store.dispatch('growl/error', {
title: this.t('harvester.notification.title.error'),
message: reason?.message,
}, { root: true });
return Promise.reject(new Error('saveHugepages error'));
}
});
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HUGEPAGES });
this.hugepages = hash.find((node) => {
return node.id === this.node.id;
});
this.spec = this.hugepages.spec;
},
data() {
return {
hugepages: {},
spec: { transparent: {} },
hugepagesTHPEnabledMode,
hugepagesTHPShmemEnabledMode,
hugepagesTHPDefragMode,
};
},
created() {
this.registerBeforeHook(this.saveHugepages, 'saveHugepages');
},
};
</script>
<template>
<div>
<div>
<hr class="divider" />
<h3>
<t
k="harvester.host.hugepages.transparent.title"
:raw="true"
:url="docsTransparentHugepagesLink"
/>
</h3>
<div class="row mb-20">
<div class="col span-4">
<LabeledSelect
v-model:value="spec.transparent.enabled"
:label="t('harvester.host.hugepages.transparent.enabled')"
:options="hugepagesTHPEnabledMode"
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="spec.transparent.shmemEnabled"
:label="t('harvester.host.hugepages.transparent.shmemEnabled')"
:options="hugepagesTHPShmemEnabledMode"
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="spec.transparent.defrag"
:label="t('harvester.host.hugepages.transparent.defrag')"
:options="hugepagesTHPDefragMode"
/>
</div>
</div>
</div>
</div>
</template>
@@ -7,6 +7,8 @@ import { Checkbox } from '@components/Form/Checkbox';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { DOC } from '../../config/doc-links'; import { DOC } from '../../config/doc-links';
import { docLink } from '../../utils/feature-flags'; import { docLink } from '../../utils/feature-flags';
import isEqual from 'lodash/isEqual';
import { clone } from '@shell/utils/object';
export const ksmtunedMode = [{ export const ksmtunedMode = [{
value: 'standard', value: 'standard',
@@ -64,11 +66,13 @@ export default {
this.enableMergeAcrossNodes = !!this.ksmtuned.spec?.mergeAcrossNodes; this.enableMergeAcrossNodes = !!this.ksmtuned.spec?.mergeAcrossNodes;
this.spec = this.ksmtuned.spec; this.spec = this.ksmtuned.spec;
this.originSpec = clone(this.ksmtuned.spec);
}, },
data() { data() {
return { return {
ksmtuned: {}, ksmtuned: {},
originSpec: {},
spec: {}, spec: {},
thresCoef: 30, thresCoef: 30,
ksmtunedMode, ksmtunedMode,
@@ -99,6 +103,11 @@ export default {
methods: { methods: {
async saveKsmtuned() { async saveKsmtuned() {
// no ksmtuned spec change, skip save
if (isEqual(this.spec, this.originSpec)) {
return;
}
this.spec.mergeAcrossNodes = this.enableMergeAcrossNodes ? 1 : 0; this.spec.mergeAcrossNodes = this.enableMergeAcrossNodes ? 1 : 0;
this.ksmtuned['spec'] = this.spec; this.ksmtuned['spec'] = this.spec;
@@ -28,6 +28,7 @@ import { HCI } from '../../types';
import HarvesterDisk from './HarvesterDisk'; import HarvesterDisk from './HarvesterDisk';
import HarvesterSeeder from './HarvesterSeeder'; import HarvesterSeeder from './HarvesterSeeder';
import HarvesterKsmtuned from './HarvesterKsmtuned'; import HarvesterKsmtuned from './HarvesterKsmtuned';
import HarvesterHugepages from './HarvesterHugepages';
import Tags from '../../components/DiskTags'; import Tags from '../../components/DiskTags';
import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
@@ -50,6 +51,7 @@ export default {
ArrayListGrouped, ArrayListGrouped,
HarvesterDisk, HarvesterDisk,
HarvesterKsmtuned, HarvesterKsmtuned,
HarvesterHugepages,
ButtonDropdown, ButtonDropdown,
KeyValue, KeyValue,
Banner, Banner,
@@ -225,6 +227,12 @@ export default {
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED); return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.KSTUNED);
}, },
hasHugepagesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return !!this.$store.getters[`${ inStore }/schemaFor`](HCI.HUGEPAGES);
},
hasBlockDevicesSchema() { hasBlockDevicesSchema() {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
@@ -527,7 +535,6 @@ export default {
id: this.longhornNode.id, id: this.longhornNode.id,
opt: { force: true }, opt: { force: true },
}); });
await new Promise((resolve) => setTimeout(resolve, '5000')); await new Promise((resolve) => setTimeout(resolve, '5000'));
await retrySave(); await retrySave();
} else { } else {
@@ -648,6 +655,17 @@ export default {
</template> </template>
</ArrayListGrouped> </ArrayListGrouped>
</Tab> </Tab>
<Tab
v-if="hasHugepagesSchema"
name="Hugepages"
:weight="70"
:label="t('harvester.host.tabs.hugepages')"
>
<HarvesterHugepages
:node="value"
:register-before-hook="registerBeforeHook"
/>
</Tab>
<Tab <Tab
v-if="hasKsmtunedSchema" v-if="hasKsmtunedSchema"
name="Ksmtuned" name="Ksmtuned"
@@ -10,9 +10,13 @@ import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-anno
import CreateEditView from '@shell/mixins/create-edit-view'; import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { HCI } from '../types'; import { HCI } from '../types';
import { NETWORK_TYPE } from '../config/types'; import { NETWORK_TYPE, L2VLAN_MODE } from '../config/types';
import { removeObject } from '@shell/utils/array';
const { L2VLAN, UNTAGGED, OVERLAY } = NETWORK_TYPE; const {
L2VLAN, UNTAGGED, OVERLAY, L2TRUNK_VLAN
} = NETWORK_TYPE;
const { ACCESS, TRUNK } = L2VLAN_MODE;
const AUTO = 'auto'; const AUTO = 'auto';
const MANUAL = 'manual'; const MANUAL = 'manual';
@@ -52,6 +56,8 @@ export default {
return { return {
config, config,
type, type,
l2VlanMode: this.value.vlanType === L2TRUNK_VLAN ? TRUNK : ACCESS,
vlanTrunk: this.parseVlanTrunk(config),
layer3Network: { layer3Network: {
mode: layer3Network.mode || AUTO, mode: layer3Network.mode || AUTO,
serverIPAddr: layer3Network.serverIPAddr || '', serverIPAddr: layer3Network.serverIPAddr || '',
@@ -108,6 +114,10 @@ export default {
}]; }];
}, },
l2VlanTrunkModeFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('l2VlanTrunkMode');
},
kubeovnVpcSubnetSupport() { kubeovnVpcSubnetSupport() {
return this.$store.getters['harvester-common/getFeatureEnabled']('kubeovnVpcSubnet'); return this.$store.getters['harvester-common/getFeatureEnabled']('kubeovnVpcSubnet');
}, },
@@ -131,6 +141,16 @@ export default {
}); });
}, },
l2VlanModeOptions() {
return [{
label: this.t('harvester.vlanStatus.vlanConfig.l2VlanMode.access'),
value: ACCESS,
}, {
label: this.t('harvester.vlanStatus.vlanConfig.l2VlanMode.trunk'),
value: TRUNK,
}];
},
networkTypes() { networkTypes() {
const types = [L2VLAN, UNTAGGED]; const types = [L2VLAN, UNTAGGED];
@@ -149,6 +169,22 @@ export default {
return this.type === L2VLAN; return this.type === L2VLAN;
}, },
isL2VlanTrunkMode() {
if (this.isView) {
return this.value.vlanType === L2TRUNK_VLAN && this.l2VlanMode === TRUNK;
}
return this.type === L2TRUNK_VLAN || (this.l2VlanMode === TRUNK && this.type === L2VLAN);
},
isL2VlanAccessMode() {
if (this.isView) {
return this.value.vlanType === L2VLAN && this.l2VlanMode === ACCESS;
}
return this.type === L2VLAN && this.l2VlanMode === ACCESS;
},
isOverlayNetwork() { isOverlayNetwork() {
if (this.isView) { if (this.isView) {
return this.value.vlanType === OVERLAY; return this.value.vlanType === OVERLAY;
@@ -168,11 +204,11 @@ export default {
watch: { watch: {
type(newType) { type(newType) {
if (newType === OVERLAY) { if (newType === OVERLAY) { // overlay network configuration
this.config.type = 'kube-ovn'; this.config.type = 'kube-ovn';
this.config.provider = `${ this.value.metadata.name }.${ this.value.metadata.namespace }.ovn`; this.config.provider = `${ this.value.metadata.name }.${ this.value.metadata.namespace }.ovn`;
this.config.server_socket = '/run/openvswitch/kube-ovn-daemon.sock'; this.config.server_socket = '/run/openvswitch/kube-ovn-daemon.sock';
} else { } else { // l2vlan or untagged network configuration
this.config.type = 'bridge'; this.config.type = 'bridge';
this.config.promiscMode = true; this.config.promiscMode = true;
this.config.ipam = {}; this.config.ipam = {};
@@ -180,7 +216,20 @@ export default {
delete this.config.provider; delete this.config.provider;
delete this.config.server_socket; delete this.config.server_socket;
} }
} },
l2VlanMode(newAccessMode) {
if (this.type !== L2VLAN) {
return;
}
if (newAccessMode === TRUNK) { // trunk mode
this.config.vlan = 0;
this.config.vlanTrunk = this.vlanTrunk;
} else { // access mode
delete this.config.vlanTrunk;
this.config.vlan = '';
}
},
}, },
methods: { methods: {
@@ -188,7 +237,15 @@ export default {
const errors = []; const errors = [];
if (this.isL2VlanNetwork || this.isUntaggedNetwork) { if (this.isL2VlanNetwork || this.isUntaggedNetwork) {
if (!this.config.vlan && !this.isUntaggedNetwork) { if (this.isL2VlanTrunkMode && this.vlanTrunk.some((trunk) => trunk.minID === '')) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.vlanStatus.vlanConfig.vlanTrunk.minId') }));
}
if (this.isL2VlanTrunkMode && this.vlanTrunk.some((trunk) => trunk.maxID === '')) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.vlanStatus.vlanConfig.vlanTrunk.maxId') }));
}
if (this.isL2VlanAccessMode && !this.config.vlan && !this.isUntaggedNetwork) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('tableHeaders.networkVlan') })); errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('tableHeaders.networkVlan') }));
} }
@@ -217,6 +274,30 @@ export default {
await this.save(buttonCb); await this.save(buttonCb);
}, },
parseVlanTrunk(config) {
if (config?.vlanTrunk && config?.vlanTrunk?.length > 0) {
return config.vlanTrunk;
}
return [{ minID: '', maxID: '' }];
},
removeVlanTrunk(trunk) {
removeObject(this.vlanTrunk, trunk);
},
addVlanTrunk() {
if (!this.config.vlanTrunk) {
this.config.vlanTrunk = [];
}
this.vlanTrunk.push({ minID: this.minId, maxID: this.maxId });
this.config.vlanTrunk = this.vlanTrunk;
},
vlanTrunkChange() {
this.config.vlanTrunk = this.vlanTrunk;
},
input(neu) { input(neu) {
if (neu === '') { if (neu === '') {
this.config.vlan = ''; this.config.vlan = '';
@@ -292,8 +373,76 @@ export default {
required required
/> />
<LabeledSelect
v-if="isL2VlanNetwork && l2VlanTrunkModeFeatureEnabled"
v-model:value="l2VlanMode"
class="mb-20"
:options="l2VlanModeOptions"
:mode="mode"
:disabled="isEdit"
:label="t('harvester.vlanStatus.vlanConfig.l2VlanMode.label')"
required
/>
<div v-if="isL2VlanTrunkMode && l2VlanTrunkModeFeatureEnabled">
<div
v-for="(trunk, i) in vlanTrunk"
:key="i"
>
<div class="row mt-10">
<div class="col trunk-span">
<LabeledInput
v-model:value.number="trunk.minID"
class="mb-20"
required
placeholder="e.g. 1-4094"
:min="1"
:max="4094"
type="number"
:label="t('harvester.vlanStatus.vlanConfig.vlanTrunk.minId')"
:mode="mode"
@update:value="vlanTrunkChange"
/>
</div>
<div class="col trunk-span">
<LabeledInput
v-model:value.number="trunk.maxID"
class="mb-20"
:max="4094"
:min="1"
placeholder="e.g. 1-4094"
required
type="number"
:label="t('harvester.vlanStatus.vlanConfig.vlanTrunk.maxId')"
:mode="mode"
@update:value="vlanTrunkChange"
/>
</div>
<div class="col remove-btn mb-20">
<button
type="button"
:disabled="isView || vlanTrunk.length <= 1"
:aria-label="t('generic.ariaLabel.remove', {index: i+1})"
role="button"
class="btn role-link"
@click="removeVlanTrunk(trunk)"
>
{{ t('harvester.fields.remove') }}
</button>
</div>
</div>
</div>
<button
v-if="isL2VlanTrunkMode"
type="button"
class="btn btn-sm bg-primary mb-20"
:disabled="isView"
@click="addVlanTrunk"
>
{{ t('harvester.vlanStatus.vlanConfig.vlanTrunk.add') }}
</button>
</div>
<LabeledInput <LabeledInput
v-if="isL2VlanNetwork" v-if="isL2VlanAccessMode"
v-model:value.number="config.vlan" v-model:value.number="config.vlan"
class="mb-20" class="mb-20"
required required
@@ -316,7 +465,7 @@ export default {
/> />
</Tab> </Tab>
<Tab <Tab
v-if="isL2VlanNetwork" v-if="isL2VlanAccessMode"
name="layer3Network" name="layer3Network"
:label="t('harvester.network.tabs.layer3Network')" :label="t('harvester.network.tabs.layer3Network')"
:weight="98" :weight="98"
@@ -375,3 +524,12 @@ export default {
</Tabbed> </Tabbed>
</CruResource> </CruResource>
</template> </template>
<style lang="scss" scoped>
.remove-btn {
align-self: center;
}
.trunk-span{
flex: 5;
}
</style>
@@ -296,4 +296,9 @@ export default {
:deep() .edit-help code { :deep() .edit-help code {
padding: 1px 5px; padding: 1px 5px;
} }
::v-deep(.banner__content.closable) {
white-space: normal;
word-break: break-word;
}
</style> </style>
+26 -3
View File
@@ -7,6 +7,7 @@ import ResourceTabs from '@shell/components/form/ResourceTabs';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription'; import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { get } from '@shell/utils/object'; import { get } from '@shell/utils/object';
@@ -40,6 +41,7 @@ export default {
LabeledSelect, LabeledSelect,
LabeledInput, LabeledInput,
NameNsDescription, NameNsDescription,
Conditions
}, },
mixins: [CreateEditView], mixins: [CreateEditView],
@@ -267,7 +269,19 @@ export default {
rebuildStatus() { rebuildStatus() {
return this.value.longhornEngine?.status?.rebuildStatus; return this.value.longhornEngine?.status?.rebuildStatus;
} },
isLHV2VolExpansionFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
},
isResizeDisabled() {
return (
!this.isLHV2VolExpansionFeatureEnabled &&
this.value?.isLonghornV2 &&
this.isEdit
);
},
}, },
watch: { watch: {
@@ -463,7 +477,7 @@ export default {
:output-modifier="true" :output-modifier="true"
:increment="1024" :increment="1024"
:mode="mode" :mode="mode"
:disabled="value?.isLonghornV2 && isEdit" :disabled="isResizeDisabled"
required required
class="mb-20" class="mb-20"
:suffix="GIBIBYTE" :suffix="GIBIBYTE"
@@ -471,7 +485,7 @@ export default {
/> />
<Banner <Banner
v-if="value?.isLonghornV2 && isEdit" v-if="isResizeDisabled"
color="warning" color="warning"
> >
<span>{{ t('harvester.volume.longhorn.disableResize') }}</span> <span>{{ t('harvester.volume.longhorn.disableResize') }}</span>
@@ -581,6 +595,15 @@ export default {
:label="t('nameNsDescription.name.label')" :label="t('nameNsDescription.name.label')"
/> />
</Tab> </Tab>
<Tab
v-if="!isCreate"
name="conditions"
:label="t('harvester.volume.tabs.conditions')"
class="bordered-table"
:mode="mode"
>
<Conditions :value="value" />
</Tab>
</ResourceTabs> </ResourceTabs>
</CruResource> </CruResource>
</template> </template>
@@ -0,0 +1,196 @@
<script>
import debounce from 'lodash/debounce';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { removeAt } from '@shell/utils/array';
import { Banner } from '@components/Banner';
import InfoBox from '@shell/components/InfoBox';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
export default {
name: 'AccessControlList',
emits: ['update:value'],
components: {
Banner, InfoBox, LabeledSelect, LabeledInput
},
props: {
value: {
type: Array,
default: null,
},
mode: {
type: String,
default: _EDIT,
},
},
data() {
const rows = (this.value || []).map((row) => {
return {
action: row.action || '',
direction: row.direction || '',
priority: row.priority || 0,
match: row.match || '',
};
});
return { rows };
},
computed: {
isView() {
return this.mode === _VIEW;
},
actionOptions() {
return [
{ label: 'Allow', value: 'allow' },
{ label: 'Drop', value: 'drop' },
{ label: 'Pass', value: 'pass' },
{ label: 'Reject', value: 'reject' },
{ label: 'Allow-related', value: 'allow-related' },
{ label: 'Allow-stateless', value: 'allow-stateless' },
];
},
directionOptions() {
return [
{ label: 'To-lport', value: 'to-lport' },
{ label: 'From-lport', value: 'from-lport' },
];
}
},
created() {
this.queueUpdate = debounce(this.update, 100);
},
methods: {
add() {
this.rows.push({
action: '',
direction: '',
priority: this.rows.length,
match: '',
});
this.queueUpdate();
},
removeRule(idx) {
removeAt(this.rows, idx);
this.queueUpdate();
},
update() {
if (this.isView) {
return;
}
this.$emit('update:value', this.rows);
}
},
};
</script>
<template>
<div>
<Banner
v-if="rows.length > 0"
color="info"
>
<t
k="harvester.subnet.acl.banner"
:raw="true"
/>
</Banner>
<div
v-for="(row, idx) in rows"
:key="idx"
>
<InfoBox class="box">
<button
type="button"
class="role-link btn btn-sm removeBtn"
:disabled="isView"
@click="removeRule(idx)"
>
<i class="icon icon-x" />
</button>
<div class="row">
<div class="col span-4">
<LabeledSelect
v-model:value="row.action"
:mode="mode"
class="mt-5 ml-5"
:options="actionOptions"
:required="true"
:label="t('harvester.subnet.acl.action.label')"
:placeholder="t('harvester.subnet.acl.action.placeholder')"
@update:value="update"
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="row.direction"
:mode="mode"
:options="directionOptions"
class="mt-5"
:required="true"
:label="t('harvester.subnet.acl.direction.label')"
:placeholder="t('harvester.subnet.acl.direction.placeholder')"
@update:value="update"
/>
</div>
<div class="col span-3">
<LabeledInput
v-model:value.number="row.priority"
type="number"
class="mb-20 mt-5"
:max="32767"
:min="0"
:mode="mode"
required
label-key="harvester.subnet.acl.priority.label"
@update:value="update"
/>
</div>
</div>
<div class="row">
<div class="col span-11">
<LabeledInput
v-model:value="row.match"
class="mb-5 ml-5"
:mode="mode"
required
:placeholder="t('harvester.subnet.acl.match.placeholder')"
label-key="harvester.subnet.acl.match.label"
@update:value="update"
/>
</div>
</div>
</InfoBox>
</div>
<button
type="button"
class="btn role-tertiary add"
:disabled="isView"
@click="add()"
>
<t k="harvester.subnet.acl.addRule" />
</button>
</div>
</template>
<style lang="scss" scoped>
.box {
position: relative;
}
.removeBtn {
position: absolute;
top: 10px;
right: 10px;
padding: 0px;
}
</style>
+19 -2
View File
@@ -15,6 +15,7 @@ import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types'; import { HCI } from '../../types';
import ResourceTabs from '@shell/components/form/ResourceTabs/index'; import ResourceTabs from '@shell/components/form/ResourceTabs/index';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AccessControlList from './AccessControlList';
export default { export default {
name: 'EditSubnet', name: 'EditSubnet',
@@ -32,6 +33,7 @@ export default {
ArrayList, ArrayList,
ResourceTabs, ResourceTabs,
Loading, Loading,
AccessControlList
}, },
mixins: [CreateEditView], mixins: [CreateEditView],
@@ -51,7 +53,8 @@ export default {
gatewayIP: '', gatewayIP: '',
excludeIps: [], excludeIps: [],
private: false, private: false,
enableDHCP enableDHCP,
acls: []
}); });
}, },
@@ -143,6 +146,7 @@ export default {
async saveSubnet(buttonCb) { async saveSubnet(buttonCb) {
const errors = []; const errors = [];
const name = this.value?.metadata?.name; const name = this.value?.metadata?.name;
const hasEmptyAcls = this.value?.spec?.acls?.some((acl) => !acl.match || !acl.action || acl.priority === undefined || acl.priority === null);
try { try {
if (!name) { if (!name) {
@@ -153,6 +157,8 @@ export default {
errors.push(this.t('validation.required', { key: this.t('harvester.subnet.provider.label') }, true)); errors.push(this.t('validation.required', { key: this.t('harvester.subnet.provider.label') }, true));
} else if (this.value.spec.excludeIps.includes('')) { } else if (this.value.spec.excludeIps.includes('')) {
errors.push(this.t('harvester.validation.subnet.excludeIps')); errors.push(this.t('harvester.validation.subnet.excludeIps'));
} else if (hasEmptyAcls) {
errors.push(this.t('harvester.validation.subnet.aclEmptyError'));
} }
if (errors.length > 0) { if (errors.length > 0) {
@@ -365,12 +371,23 @@ export default {
<div class="key"> <div class="key">
<input <input
v-model="scope.row.value" v-model="scope.row.value"
:placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')" :placeholder="t('harvester.subnet.excludeIPs.placeholder')"
/> />
</div> </div>
</template> </template>
</ArrayList> </ArrayList>
</Tab> </Tab>
<Tab
name="ACL"
:label="t('harvester.subnet.acl.label')"
:weight="-2"
class="bordered-table"
>
<AccessControlList
v-model:value="value.spec.acls"
:mode="mode"
/>
</Tab>
</ResourceTabs> </ResourceTabs>
</CruResource> </CruResource>
</template> </template>
@@ -1,6 +1,5 @@
<script> <script>
import InfoBox from '@shell/components/InfoBox'; import InfoBox from '@shell/components/InfoBox';
import { NETWORK_ATTACHMENT } from '@shell/config/types'; import { NETWORK_ATTACHMENT } from '@shell/config/types';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
@@ -14,6 +13,13 @@ export default {
components: { InfoBox, Base }, components: { InfoBox, Base },
props: { props: {
vm: {
type: Object,
default: () => {
return {};
}
},
mode: { mode: {
type: String, type: String,
default: 'create' default: 'create'
@@ -32,10 +38,15 @@ export default {
} }
}, },
async fetch() {
await this.fetchHotunplugData();
},
data() { data() {
return { return {
rows: this.addKeyId(clone(this.value)), nameIdx: 1,
nameIdx: 1 rows: this.addKeyId(clone(this.value)),
hotunpluggableNics: new Set(),
}; };
}, },
@@ -64,15 +75,57 @@ export default {
return out; return out;
}, },
canCheckHotunplug() {
return !!this.vm?.actions?.findHotunpluggableNics;
},
vmState() {
return this.vm?.stateDisplay;
}
}, },
watch: { watch: {
value(neu) { value(neu) {
this.rows = neu; this.rows = this.mergeHotplugData(clone(neu));
}, },
vmState(newState, oldState) {
if (newState !== oldState) {
this.fetchHotunplugData();
}
}
}, },
methods: { methods: {
async fetchHotunplugData() {
if (!this.canCheckHotunplug) {
this.rows = this.mergeHotplugData(clone(this.value));
return;
}
try {
const resp = await this.vm.doAction('findHotunpluggableNics');
this.hotunpluggableNics = new Set(resp?.interfaces || []);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to fetch hot-unpluggable NICs:', e);
this.hotunpluggableNics = new Set();
}
this.rows = this.mergeHotplugData(clone(this.value));
},
mergeHotplugData(networks) {
return (networks || []).map((network) => ({
...network,
isHotunpluggable: this.hotunpluggableNics.has(network.name),
rowKeyId: network.rowKeyId || randomStr(10)
}));
},
add(type) { add(type) {
const name = this.generateName(); const name = this.generateName();
@@ -118,7 +171,11 @@ export default {
update() { update() {
this.$emit('update:value', this.rows); this.$emit('update:value', this.rows);
} },
unplugNIC(network) {
this.vm.unplugNIC(network.name);
},
} }
}; };
</script> </script>
@@ -129,17 +186,28 @@ export default {
v-for="(row, i) in rows" v-for="(row, i) in rows"
:key="i" :key="i"
> >
<button <div class="box-title mb-10">
v-if="!isView" <h3>
type="button" {{ t('harvester.virtualMachine.network.title') }}
class="role-link remove-vol" </h3>
@click="remove(row)" <button
> v-if="!isView"
<i class="icon icon-x" /> type="button"
</button> class="role-link btn btn-sm remove"
@click="remove(row)"
<h3> {{ t('harvester.virtualMachine.network.title') }} </h3> >
<i class="icon icon-x" />
</button>
<button
v-if="vm.hotplugNicFeatureEnabled && row.isHotunpluggable && isView"
type="button"
class="role-link btn btn-sm remove"
:disabled="!canCheckHotunplug"
@click="unplugNIC(row)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachNIC.actionLabel') }}
</button>
</div>
<Base <Base
v-model:value="rows[i]" v-model:value="rows[i]"
:rows="rows" :rows="rows"
@@ -162,16 +230,13 @@ export default {
</template> </template>
<style lang='scss' scoped> <style lang='scss' scoped>
.infoBox{ .box-title{
position: relative; display: flex;
} justify-content: space-between;
align-items: center;
.remove-vol { h3 {
position: absolute; margin-bottom: 0;
top: 10px; }
right: 16px;
padding:0px;
max-height: 28px;
min-height: 28px;
} }
</style> </style>
@@ -136,6 +136,10 @@ export default {
pvcs() { pvcs() {
return this.$store.getters['harvester/all'](PVC) || []; return this.$store.getters['harvester/all'](PVC) || [];
}, },
isLHV2VolExpansionFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
},
}, },
watch: { watch: {
@@ -270,8 +274,8 @@ export default {
}, },
isResizeDisabled(volume) { isResizeDisabled(volume) {
if (this.isCreate) return false; if (this.isLHV2VolExpansionFeatureEnabled) return false;
if (volume.newCreateId) return false; if (this.isCreate || volume.newCreateId) return false;
const isStopped = this.vm.stateDisplay === OFF; const isStopped = this.vm.stateDisplay === OFF;
const isLonghornV2 = this.isLonghornV2(volume); const isLonghornV2 = this.isLonghornV2(volume);
@@ -299,55 +303,57 @@ export default {
v-for="(volume, i) in rows" v-for="(volume, i) in rows"
:key="volume.id" :key="volume.id"
> >
<InfoBox class="box"> <InfoBox>
<button <div class="box-title mb-10">
v-if="!isView" <h3>
type="button" <span
class="role-link btn btn-sm remove" v-if="volume.to && isVirtualType"
@click="removeVolume(volume)" class="title"
>
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
type="button"
class="role-link btn remove"
@click="unplugVolume(volume)"
>
{{ t('harvester.virtualMachine.unplug.detachVolume') }}
</button>
<h3>
<span
v-if="volume.to && isVirtualType"
class="title"
>
<router-link :to="volume.to">
{{ t('harvester.virtualMachine.volume.edit') }} {{ headerFor(volume.source) }}
</router-link>
<BadgeStateFormatter
v-if="volume.pvc"
class="ml-10 state"
:arbitrary="true"
:row="volume.pvc"
:value="volume.pvc.state"
/>
<a
v-if="dev && !!volume.pvc && !!volume.pvc.resourceExternalLink"
v-clean-tooltip="t(volume.pvc.resourceExternalLink.tipsKey || 'generic.resourceExternalLinkTips')"
class="ml-5 resource-external"
rel="nofollow noopener noreferrer"
target="_blank"
:href="volume.pvc.resourceExternalLink.url"
> >
<i class="icon icon-external-link" /> <router-link :to="volume.to">
</a> {{ t('harvester.virtualMachine.volume.edit') }} {{ headerFor(volume.source) }}
</span> </router-link>
<span v-else> <BadgeStateFormatter
{{ headerFor(volume.source, !!volume?.volumeBackups) }} v-if="volume.pvc"
</span> class="ml-10 state"
</h3> :arbitrary="true"
:row="volume.pvc"
:value="volume.pvc.state"
/>
<a
v-if="dev && !!volume.pvc && !!volume.pvc.resourceExternalLink"
v-clean-tooltip="t(volume.pvc.resourceExternalLink.tipsKey || 'generic.resourceExternalLinkTips')"
class="ml-5 resource-external"
rel="nofollow noopener noreferrer"
target="_blank"
:href="volume.pvc.resourceExternalLink.url"
>
<i class="icon icon-external-link" />
</a>
</span>
<span v-else>
{{ headerFor(volume.source, !!volume?.volumeBackups) }}
</span>
</h3>
<button
v-if="!isView"
type="button"
class="role-link btn btn-sm remove"
@click="removeVolume(volume)"
>
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
type="button"
class="role-link btn btn-sm remove"
@click="unplugVolume(volume)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachVolume.actionLabel') }}
</button>
</div>
<div> <div>
<component <component
:is="componentFor(volume.source)" :is="componentFor(volume.source)"
@@ -406,7 +412,7 @@ export default {
:label="ucFirst(value.volumeBackups.error.message)" :label="ucFirst(value.volumeBackups.error.message)"
/> />
<Banner <Banner
v-if="isLonghornV2(volume) && !isView" v-if="!isLHV2VolExpansionFeatureEnabled && isLonghornV2(volume) && !isView"
color="warning" color="warning"
:label="t('harvester.volume.longhorn.disableResize')" :label="t('harvester.volume.longhorn.disableResize')"
/> />
@@ -491,25 +497,24 @@ export default {
</template> </template>
<style lang='scss' scoped> <style lang='scss' scoped>
.box { .box-title {
position: relative; display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin-bottom: 0;
}
} }
.title { .title {
display: flex; display: flex;
align-items: center;
.state { .state {
font-size: 16px; font-size: 16px;
} }
} }
.remove {
position: absolute;
top: 10px;
right: 10px;
padding: 0px;
}
.bootOrder { .bootOrder {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -99,7 +99,6 @@ export default {
this.allPVCs this.allPVCs
.filter( (pvc) => { .filter( (pvc) => {
let isAvailable = true; let isAvailable = true;
let isBeingUsed = false;
this.rows.forEach( (O) => { this.rows.forEach( (O) => {
if (O.volumeName === pvc.metadata.name) { if (O.volumeName === pvc.metadata.name) {
@@ -111,17 +110,16 @@ export default {
return false; return false;
} }
// already used as image volume
if (this.idx > 0 && pvc.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]) {
return false;
}
if (pvc.isGoldenImageVolume) { if (pvc.isGoldenImageVolume) {
return false; return false;
} }
if (pvc.attachVM && isAvailable && pvc.attachVM?.id === this.vm?.id && this.isEdit) { return isAvailable && pvc.isAvailable;
isBeingUsed = false;
} else if (pvc.attachVM) {
isBeingUsed = true;
}
return isAvailable && !isBeingUsed && pvc.isAvailable;
}) })
.map((pvc) => { .map((pvc) => {
return { return {
@@ -2,7 +2,7 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed'; import Tabbed from '@shell/components/Tabbed';
import { clone, set } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
import Tab from '@shell/components/Tabbed/Tab'; import Tab from '@shell/components/Tabbed/Tab';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource'; import CruResource from '@shell/components/CruResource';
@@ -96,7 +96,6 @@ export default {
templateVersionId: '', templateVersionId: '',
namePrefix: '', namePrefix: '',
isSingle: true, isSingle: true,
useTemplate: false,
isOpen: false, isOpen: false,
hostname, hostname,
isRestartImmediately, isRestartImmediately,
@@ -338,6 +337,15 @@ export default {
saveVM(buttonCb) { saveVM(buttonCb) {
clear(this.errors); clear(this.errors);
this.validateCPUMemory();
// block create VM flow if has validation errors
if (this.errors.length) {
buttonCb(false);
return;
}
if (this.isSingle) { if (this.isSingle) {
this.saveSingle(buttonCb); this.saveSingle(buttonCb);
} else { } else {
@@ -345,6 +353,18 @@ export default {
} }
}, },
validateCPUMemory() {
const { cpu, memory } = this;
if ((!cpu)) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.cpu') }, true));
}
if ((!memory)) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.memory') }, true));
}
},
async saveSingle(buttonCb) { async saveSingle(buttonCb) {
this.parseVM(); this.parseVM();
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name; this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
@@ -413,38 +433,44 @@ export default {
} }
}, },
_compareDisksIgnoreStorage(disksA = [], disksB = []) {
if (disksA.length !== disksB.length) return false;
const sortedA = [...disksA].sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
const sortedB = [...disksB].sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
for (let i = 0; i < sortedA.length; i++) {
const cloneA = clone(sortedA[i]);
const cloneB = clone(sortedB[i]);
// remove the storage field before comparison
delete cloneA?.spec?.resources?.requests?.storage;
delete cloneB?.spec?.resources?.requests?.storage;
if (!isEqual(cloneA, cloneB)) return false;
}
return true;
},
_shouldPromptRestart(oldVM, newVM) {
const specChanged = !isEqual(oldVM?.spec, newVM?.spec);
// check if the disk defined in annotations has changed, ignoring storage size
const oldDisks = parseVolumeClaimTemplates(oldVM);
const newDisks = parseVolumeClaimTemplates(newVM);
const diskChanged = !this._compareDisksIgnoreStorage(oldDisks, newDisks);
return specChanged || diskChanged;
},
restartVM() { restartVM() {
if (this.mode !== 'edit') { if (this.mode !== 'edit' || !this.value.isRunning) return;
return;
}
if (!this.value.isRunning) {
return;
}
const cloneDeepNewVM = clone(this.value);
// new VM const needsRestart = this._shouldPromptRestart(this.cloneVM, this.value);
delete cloneDeepNewVM?.metadata;
delete cloneDeepNewVM?.__clone;
// old VM // if no restart is needed (either no changes or only size changed)
delete this.cloneVM?.metadata; if (!needsRestart) return;
delete this.cloneVM?.__clone;
// add empty hostDevices to old VM as CRD does not have it.
const devicesObj = this.cloneVM?.spec?.template?.spec?.domain?.devices;
if (devicesObj && devicesObj.hostDevices === undefined) {
set(devicesObj, 'hostDevices', []);
}
const oldVM = JSON.parse(JSON.stringify(this.cloneVM));
const newVM = JSON.parse(JSON.stringify(cloneDeepNewVM));
// we won't show restart dialog in yaml page as we don't have a way to detect change in yaml editor.
// only check VM is changed in form page.
if (isEqual(oldVM, newVM)) {
return;
}
return new Promise((resolve) => { return new Promise((resolve) => {
this.isOpen = true; this.isOpen = true;
@@ -463,12 +489,11 @@ export default {
if (this.isSingle) { if (this.isSingle) {
if (!this.value.spec.template.spec.hostname) { if (!this.value.spec.template.spec.hostname) {
this.value.spec.template.spec['hostname'] = this.value.metadata.name; this.value.spec.template.spec['hostname'] = this.value.metadata.name;
this.spec.template.spec['hostname'] = this.value.metadata.name;
} }
} }
const cpuMemoryErrors = this.getCPUMemoryValidation(); const errors = this.getAccessCredentialsValidation();
const accessCredentialsErrors = this.getAccessCredentialsValidation();
const errors = [...cpuMemoryErrors, ...accessCredentialsErrors];
if (errors.length > 0) { if (errors.length > 0) {
return Promise.reject(errors); return Promise.reject(errors);
@@ -873,7 +898,10 @@ export default {
/> />
</div> </div>
<div class="col span-6"> <div
v-if="!value.vmMachineTypeAutoFeatureEnabled"
class="col span-6"
>
<LabeledSelect <LabeledSelect
v-model:value="machineType" v-model:value="machineType"
label-key="harvester.virtualMachine.input.MachineType" label-key="harvester.virtualMachine.input.MachineType"
@@ -39,6 +39,10 @@ export default {
return this.mode === _VIEW; return this.mode === _VIEW;
}, },
disableAdd() {
return this.isStandaloneHarvester && this.rows.some((row) => row.namespace === '*');
},
showAdd() { showAdd() {
return !this.isView; return !this.isView;
}, },
@@ -141,6 +145,7 @@ export default {
> >
<button <button
type="button" type="button"
:disabled="disableAdd"
class="btn role-tertiary add" class="btn role-tertiary add"
@click="add()" @click="add()"
> >
@@ -66,17 +66,26 @@ export default {
}, },
namespaceOptions() { namespaceOptions() {
const out = (this.filteredNamespaces || []).map((namespace) => { const namespaces = this.filteredNamespaces;
return { const selected = this.rows.map((r) => r?.namespace);
label: namespace.metadata.name,
value: namespace.id, if (this.isStandaloneHarvester) {
}; return [
}); { label: this.t('generic.all'), value: '*' },
...namespaces.map((ns) => ({
label: ns.metadata.name,
value: ns.id,
disabled: selected.includes(ns.id) && ns.id !== this.row.namespace
}))
];
}
return [{ return [{
label: this.t('generic.all'), label: this.t('generic.all'),
value: '*', value: '*',
}, ...out]; },
...namespaces.map((ns) => ({ label: ns.metadata.name, value: ns.id }))
];
}, },
guestClusterOptions() { guestClusterOptions() {
@@ -0,0 +1,42 @@
<script>
import NetworkPolicy from '@shell/edit/networking.k8s.io.networkpolicy';
import { Banner } from '@components/Banner';
import { DOC } from '../config/doc-links';
import { docLink } from '../utils/feature-flags';
export default {
components: {
NetworkPolicy,
Banner
},
computed: {
networkPolicyDocLink() {
const version = this.$store.getters['harvester-common/getServerVersion']();
return docLink(DOC.NETWORK_POLICY, version);
}
}
};
</script>
<template>
<div class="network-policy-edit">
<Banner color="info">
<t
k="harvester.networkPolicy.banner"
:url="networkPolicyDocLink"
:raw="true"
/>
</Banner>
<NetworkPolicy v-bind="$attrs" />
</div>
</template>
<style lang="scss" scoped>
.network-policy-edit {
flex: auto;
display: flex;
flex-direction: column;
}
</style>
+116 -11
View File
@@ -154,15 +154,19 @@ harvester:
nodeTimeout: nodeTimeout:
label: Node Collection Timeout label: Node Collection Timeout
tooltip: Minutes allowed for collecting logs/configurations on nodes.<br/>See docs support-bundle-node-collection-timeout for detail. tooltip: Minutes allowed for collecting logs/configurations on nodes.<br/>See docs support-bundle-node-collection-timeout for detail.
hotplug: hotplugVolume:
success: 'Volume { diskName } is mounted to the virtual machine { vm }.' success: 'Volume { diskName } is mounted to the virtual machine { vm }.'
title: Add Volume title: Add Volume
hotplugNic:
success: 'The settings have been saved, but the network interface {interfaceName} will be attached only after the virtual machine is migrated.'
title: Add Network Interface
vmNetwork: Virtual Machine Network
macAddress: MAC Address
macAddressTooltip: If left blank, the MAC address will be automatically generated.
cpuMemoryHotplug: cpuMemoryHotplug:
success: 'CPU and Memory are updated to the virtual machine { vm }.' success: 'CPU and Memory are updated to the virtual machine { vm }.'
title: Edit CPU and Memory title: Edit CPU and Memory
maxResourcesMessage: 'You can increase the CPU to maximum { maxCpu }C and memory to maximum { maxMemory }.' maxResourcesMessage: 'You can increase the CPU to maximum { maxCpu }C and memory to maximum { maxMemory }.'
hotunplug:
success: 'Volume { name } is detached successfully.'
snapshot: snapshot:
title: Take Snapshot title: Take Snapshot
name: Name name: Name
@@ -226,7 +230,8 @@ harvester:
disableCPUManager: Disable CPU Manager disableCPUManager: Disable CPU Manager
cordon: Cordon cordon: Cordon
uncordon: Uncordon uncordon: Uncordon
addHotplug: Add Volume addHotplugVolume: Add Volume
addHotplugNic: Hotplug Network Interface
exportImage: Export Image exportImage: Export Image
viewlogs: View Logs viewlogs: View Logs
cancelExpand: Cancel Expand cancelExpand: Cancel Expand
@@ -272,6 +277,7 @@ harvester:
cronExpression: Cron Expression cronExpression: Cron Expression
retain: Retain retain: Retain
scheduleType: Type scheduleType: Type
configuredProfiles: Configured Profiles
maxFailure: Max Failure maxFailure: Max Failure
sourceVm: Source Virtual Machine sourceVm: Source Virtual Machine
vmSchedule: Virtual Machine Schedule vmSchedule: Virtual Machine Schedule
@@ -404,7 +410,7 @@ harvester:
sha512: 'Invalid SHA512 checksum.' sha512: 'Invalid SHA512 checksum.'
subnet: subnet:
excludeIps: 'Exclude IPs cannot be empty. Please remove or fill in the exclude IPs.' excludeIps: 'Exclude IPs cannot be empty. Please remove or fill in the exclude IPs.'
aclEmptyError: The fields in subnet access control list rule can not be empty.
dashboard: dashboard:
label: Dashboard label: Dashboard
header: "Harvester Cluster: {cluster}" header: "Harvester Cluster: {cluster}"
@@ -441,6 +447,7 @@ harvester:
storage: Storage storage: Storage
labels: Labels labels: Labels
ksmtuned: Ksmtuned ksmtuned: Ksmtuned
hugepages: Hugepages
seeder: Out-of-band Access seeder: Out-of-band Access
detail: detail:
kvm: kvm:
@@ -513,6 +520,20 @@ harvester:
fullScans: Full Scans fullScans: Full Scans
stableNodeChains: Stable Node Chains stableNodeChains: Stable Node Chains
stableNodeDups: Stable Node Dups stableNodeDups: Stable Node Dups
hugepages:
meminfo: Meminfo
transparent:
title: Transparent Hugepages <a href="{url}" target="_blank"><i class="icon icon-info" /></a>
enabled: Enabled
shmemEnabled: Shared Memory Enabled
defrag: Defragmentation
status:
anon: Anonymous Hugepages (bytes)
size: Default Hugepage Size (bytes)
total: Total Hugepages
free: Free Hugepages
rsvd: Reserved Hugepages
surp: Surplus Hugepages
disk: disk:
add: Add Disk add: Add Disk
path: path:
@@ -581,6 +602,16 @@ harvester:
title: Enable CPU and memory hotplug title: Enable CPU and memory hotplug
tooltip: The default maximum CPU and maximum memory are {hotPlugTimes} times based on CPU and memory. tooltip: The default maximum CPU and maximum memory are {hotPlugTimes} times based on CPU and memory.
restartVMMessage: Restart action is required for the virtual machine configuration change to take effect restartVMMessage: Restart action is required for the virtual machine configuration change to take effect
hotUnplug:
actionLabel: Detach
detachVolume:
title: 'Are you sure that you want to detach volume {name}?'
actionLabel: Detach Volume
success: 'Volume { name } is detached successfully.'
detachNIC:
title: 'Are you sure that you want to detach network interface {name}?'
actionLabel: Detach Network Interface
success: 'The settings have been saved, but the network interface {name} will be detached only after the virtual machine is migrated.'
instance: instance:
singleInstance: singleInstance:
multipleInstance: multipleInstance:
@@ -612,11 +643,6 @@ harvester:
title: 'Select the volume you want to delete:' title: 'Select the volume you want to delete:'
deleteAll: Delete All deleteAll: Delete All
tips: "Warn: The snapshots of the virtual machine will be deleted with virtual machine and the snapshots of volume will be deleted with volume." tips: "Warn: The snapshots of the virtual machine will be deleted with virtual machine and the snapshots of volume will be deleted with volume."
unplug:
title: 'Are you sure that you want to detach volume {name} ?'
actionLabel: Detach
detachVolume:
Detach Volume
restartTip: |- restartTip: |-
{restart, select, {restart, select,
true {Restart} true {Restart}
@@ -837,6 +863,7 @@ harvester:
snapshots: Snapshots snapshots: Snapshots
datasource: Data Source datasource: Data Source
details: Details details: Details
conditions: Conditions
size: Size size: Size
volumeMode: Volume Mode volumeMode: Volume Mode
source: Source source: Source
@@ -1062,6 +1089,25 @@ harvester:
placeholder: e.g. 172.16.0.0/16 placeholder: e.g. 172.16.0.0/16
excludeIPs: excludeIPs:
tooltip: The IP address list to reserve from automatic assignment. The gateway IP address is always excluded and will be automatically added to the list. tooltip: The IP address list to reserve from automatic assignment. The gateway IP address is always excluded and will be automatically added to the list.
placeholder: single IP or 192.168.0.100..192.168.0.200
acl:
label: Access Control List
tooltip: The ACL to apply to this Subnet. Must be one of the ACLs in the same namespace.
action:
label: Action
placeholder: Please select an action
direction:
label: Direction
placeholder: Please select a direction
addRule: Add Rule
priority:
label: Priority
placeholder: Please select a priority
match:
label: Match
placeholder: e.g. ip4.dst == 10.10.0.2
banner: The supported field in ACL match can refer to <a href="https://kubeovn.github.io/docs/v1.14.x/en/guide/subnet/#subnet-acl" target="_blank">KubeOvn Subnet ACL document</a>
vpc: vpc:
noAddonEnabled: noAddonEnabled:
@@ -1089,6 +1135,9 @@ harvester:
remoteVpc: remoteVpc:
label: Remote VPC label: Remote VPC
infoBanner: The static route destination CIDR must cover all subnets CIDR from remote VPC Peer. Read <a href="{url}" target="_blank">VPC Peering Configuration Examples</a> for more information. infoBanner: The static route destination CIDR must cover all subnets CIDR from remote VPC Peer. Read <a href="{url}" target="_blank">VPC Peering Configuration Examples</a> for more information.
networkPolicy:
label: Network Policies
banner: The network policies must be used for VMs attached to overlay networks. Please read the <a href="{url}" target="_blank">harvester document</a> how the network policy works.
network: network:
label: Virtual Machine Networks label: Virtual Machine Networks
tabs: tabs:
@@ -1157,7 +1206,7 @@ harvester:
vlan: VLAN ID vlan: VLAN ID
exclude: exclude:
label: Exclude IPs label: Exclude IPs
placeholder: e.g. 172.16.0.1 placeholder: CIDR format, e.g. 172.16.0.10/32
invalid: '"Exclude list" is invalid.' invalid: '"Exclude list" is invalid.'
addIp: Add Exclude IP addIp: Add Exclude IP
warning: 'WARNING: <br/> Any change to storage-network requires shutting down all virtual machines before applying this setting. <br/> Users have to ensure the cluster network is configured and VLAN Configuration will cover all nodes and ensure the network connectivity is working and expected in all nodes.' warning: 'WARNING: <br/> Any change to storage-network requires shutting down all virtual machines before applying this setting. <br/> Users have to ensure the cluster network is configured and VLAN Configuration will cover all nodes and ensure the network connectivity is working and expected in all nodes.'
@@ -1247,6 +1296,21 @@ harvester:
ntpServers: ntpServers:
isNotIPV4: The address you entered is not IPv4 or host. Please enter a valid IPv4 address or a host address. isNotIPV4: The address you entered is not IPv4 or host. Please enter a valid IPv4 address or a host address.
isDuplicate: There are duplicate NTP server configurations. isDuplicate: There are duplicate NTP server configurations.
kubevirtMigration:
parseError: "Failed to parse configuration: {error}"
parallelMigrationsPerCluster: "Parallel Migrations Per Cluster"
parallelOutboundMigrationsPerNode: "Parallel Outbound Migrations Per Node"
bandwidthPerMigration: "Bandwidth Per Migration"
bandwidthPerMigrationTooltip: '0 is KubeVirt default, representing unlimited bandwidth'
completionTimeoutPerGiB: "Completion Timeout Per GiB"
progressTimeout: "Progress Timeout"
allowAutoConverge: "Allow Auto Converge"
allowPostCopy: "Allow Post Copy"
unsafeMigrationOverride: "Unsafe Migration Override"
allowWorkloadDisruption: "Allow Workload Disruption"
disableTLS: "Disable TLS"
matchSELinuxLevelOnMigration: "Match SELinux Level On Migration"
cloudTemplate: cloudTemplate:
label: Cloud Configuration Templates label: Cloud Configuration Templates
templateType: Template Type templateType: Template Type
@@ -1462,6 +1526,14 @@ harvester:
vlanStatus: vlanStatus:
vlanConfig: vlanConfig:
label: Network Configuration label: Network Configuration
l2VlanMode:
access: Access
trunk: Trunk
label: Mode
vlanTrunk:
minId: Minimum VLAN ID
maxId: Maximum VLAN ID
add: Add VLAN Trunk
clusterNetwork: clusterNetwork:
title: Cluster Network Configuration title: Cluster Network Configuration
@@ -1489,6 +1561,7 @@ harvester:
'harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations. 'harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations.
'harvester-system/harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations. 'harvester-system/harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations.
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices. 'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
vmImport: vmImport:
titles: titles:
basic: Basic basic: Basic
@@ -1635,6 +1708,26 @@ harvester:
middle: here middle: here
suffix: to enable it to manage your SR-IOV GPU devices. suffix: to enable it to manage your SR-IOV GPU devices.
migconfiguration:
label: vGPU MIG Configurations
infoBanner: To configure the MIG configuration, please disable it first and re-enable after editing the configuration.
profileSpec: Profile Specs
profileStatus: Profile Status
tableHeaders:
profileName: Profile Name
total: Total
vGPUID: vGPU ID
available: Available
requested: Requested
requested: Requested
available: Available
total: Total
vGPUID: vGPU ID
goSriovGPU:
prefix: Please enable the supported GPU devices in
middle: SR-IOV GPU Devices
suffix: page to manage the vGPU MIG configurations.
vgpu: vgpu:
label: vGPU Devices label: vGPU Devices
noPermission: Please contact system administrator to add Harvester add-ons first. noPermission: Please contact system administrator to add Harvester add-ons first.
@@ -1753,6 +1846,7 @@ advancedSettings:
'harv-vm-migration-network': 'Segregated network for VM migration traffic.' 'harv-vm-migration-network': 'Segregated network for VM migration traffic.'
'harv-rancher-cluster': 'Configure Rancher cluster integration settings for guest cluster management.' 'harv-rancher-cluster': 'Configure Rancher cluster integration settings for guest cluster management.'
'harv-max-hotplug-ratio': 'The ratio for kubevirt to limit the maximum CPU and memory that can be hotplugged to a VM. The value could be an integer between 1 and 20, default to 4.' 'harv-max-hotplug-ratio': 'The ratio for kubevirt to limit the maximum CPU and memory that can be hotplugged to a VM. The value could be an integer between 1 and 20, default to 4.'
'harv-kubevirt-migration': 'Configure cluster-wide KubeVirt live migration parameters.'
typeLabel: typeLabel:
kubevirt.io.virtualmachine: |- kubevirt.io.virtualmachine: |-
@@ -1815,6 +1909,11 @@ typeLabel:
one { Virtual Private Cloud } one { Virtual Private Cloud }
other { Virtual Private Clouds } other { Virtual Private Clouds }
} }
networking.k8s.io.networkpolicy: |-
{count, plural,
one { Network Policy }
other { Network Policies }
}
harvesterhci.io.cloudtemplate: |- harvesterhci.io.cloudtemplate: |-
{count, plural, {count, plural,
one { Cloud Configuration Template } one { Cloud Configuration Template }
@@ -1903,6 +2002,12 @@ typeLabel:
other { vGPU Devices } other { vGPU Devices }
} }
devices.harvesterhci.io.migconfiguration: |-
{count, plural,
one { vGPU MIG Configuration }
other { vGPU MIG Configurations }
}
harvesterhci.io.secret: |- harvesterhci.io.secret: |-
{count, plural, {count, plural,
one { Secret } one { Secret }
@@ -0,0 +1,177 @@
<script>
import { STATE, NAME } from '@shell/config/table-headers';
import { allHash } from '@shell/utils/promise';
import Banner from '@components/Banner/Banner.vue';
import Loading from '@shell/components/Loading';
import ResourceTable from '@shell/components/ResourceTable';
import { HCI } from '../types';
import { ADD_ONS } from '../config/harvester-map';
import MessageLink from '@shell/components/MessageLink';
export default {
name: 'ListMIGConfigurations',
inheritAttrs: false,
components: {
Banner,
Loading,
ResourceTable,
MessageLink,
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.schema = this.$store.getters[`${ inStore }/schemaFor`](HCI.MIG_CONFIGURATION);
this.hasAddonSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.ADD_ONS);
if (this.hasSchema) {
try {
const hash = await allHash({
migconfigs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.MIG_CONFIGURATION }),
vGpuDevices: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VGPU_DEVICE }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS })
});
this.hasPCIAddon = hash.addons.find((addon) => addon.name === ADD_ONS.PCI_DEVICE_CONTROLLER)?.spec?.enabled === true;
this.hasSriovgpuAddon = hash.addons.find((addon) => addon.name === ADD_ONS.NVIDIA_DRIVER_TOOLKIT_CONTROLLER)?.spec?.enabled === true;
this.hasSRIOVGPUSchema = !!this.$store.getters[`${ inStore }/schemaFor`](HCI.SR_IOVGPU_DEVICE);
if (this.hasSRIOVGPUSchema) {
await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOVGPU_DEVICE });
}
this.rows = hash.migconfigs;
} catch (e) {}
}
},
data() {
return {
rows: [],
schema: null,
hasAddonSchema: false,
hasPCIAddon: false,
hasSriovgpuAddon: false,
hasSRIOVGPUSchema: false,
toVGpuAddon: `${ HCI.ADD_ONS }/harvester-system/${ ADD_ONS.NVIDIA_DRIVER_TOOLKIT_CONTROLLER }?mode=edit`,
toPciAddon: `${ HCI.ADD_ONS }/harvester-system/${ ADD_ONS.PCI_DEVICE_CONTROLLER }?mode=edit`,
SRIOVGPUPage: `${ HCI.ADD_ONS }/harvester-system/${ ADD_ONS.NVIDIA_DRIVER_TOOLKIT_CONTROLLER }?mode=edit`,
};
},
computed: {
hasSchema() {
return !!this.schema;
},
rowsData() {
const inStore = this.$store.getters['currentProduct'].inStore;
const rows = this.$store.getters[`${ inStore }/all`](HCI.MIG_CONFIGURATION) || [];
return rows;
},
sriovGPUPage() {
return {
name: 'harvester-c-cluster-resource',
params: { cluster: this.$store.getters['clusterId'], resource: HCI.SR_IOVGPU_DEVICE },
};
},
showEnableSRIOVGPUMessage() {
return this.rowsData.length === 0;
},
headers() {
const cols = [
STATE,
NAME,
{
name: 'address',
label: 'Address',
value: 'spec.gpuAddress',
sort: ['spec.gpuAddress']
},
{
name: 'Configured Profile',
label: 'Configured Count',
labelKey: 'harvester.tableHeaders.configuredProfiles',
value: 'configuredProfiles',
sort: ['configuredProfiles'],
align: 'center'
},
{
name: 'status',
label: 'Status',
labelKey: 'tableHeaders.status',
sort: ['status.status'],
value: 'status.status',
},
];
return cols;
},
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else-if="!hasAddonSchema">
<Banner color="warning">
{{ t('harvester.vgpu.noPermission') }}
</Banner>
</div>
<div v-else-if="!hasSriovgpuAddon || !hasPCIAddon">
<Banner
v-if="!hasSriovgpuAddon"
color="warning"
>
<MessageLink
:to="toVGpuAddon"
prefix-label="harvester.vgpu.goSetting.prefix"
middle-label="harvester.vgpu.goSetting.middle"
suffix-label="harvester.vgpu.goSetting.suffix"
/>
</Banner>
<Banner
v-if="!hasPCIAddon"
color="warning"
>
<MessageLink
:to="toPciAddon"
prefix-label="harvester.pci.goSetting.prefix"
middle-label="harvester.pci.goSetting.middle"
suffix-label="harvester.pci.goSetting.suffix"
/>
</Banner>
</div>
<div v-else-if="hasSchema">
<Banner
v-if="showEnableSRIOVGPUMessage"
color="warning"
>
<MessageLink
:to="sriovGPUPage"
prefix-label="harvester.migconfiguration.goSriovGPU.prefix"
middle-label="harvester.migconfiguration.goSriovGPU.middle"
suffix-label="harvester.migconfiguration.goSriovGPU.suffix"
/>
</Banner>
<Banner
v-if="!showEnableSRIOVGPUMessage"
color="warning"
:label="t('harvester.migconfiguration.infoBanner')"
/>
<ResourceTable
v-bind="$attrs"
:groupable="false"
:namespaced="false"
:headers="headers"
:schema="schema"
:rows="rowsData"
key-field="_key"
/>
</div>
</template>
@@ -74,7 +74,7 @@ export default {
async fetch() { async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const _hash = { const _hash = {
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }), vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM, opt: { force: true } }),
pod: this.$store.dispatch(`${ inStore }/findAll`, { type: POD }), pod: this.$store.dispatch(`${ inStore }/findAll`, { type: POD }),
pvcs: this.$store.dispatch(`${ inStore }/findAll`, { type: PVC }), pvcs: this.$store.dispatch(`${ inStore }/findAll`, { type: PVC }),
pvs: this.$store.dispatch(`${ inStore }/findAll`, { type: PV }), pvs: this.$store.dispatch(`${ inStore }/findAll`, { type: PV }),
+51 -33
View File
@@ -142,6 +142,7 @@ export default {
showYaml: false, showYaml: false,
spec: null, spec: null,
osType: 'linux', osType: 'linux',
useTemplate: false,
sshKey: [], sshKey: [],
maintenanceStrategies, maintenanceStrategies,
maintenanceStrategy: 'Migrate', maintenanceStrategy: 'Migrate',
@@ -273,7 +274,7 @@ export default {
needNewSecret() { needNewSecret() {
// When creating a template it is always necessary to create a new secret. // When creating a template it is always necessary to create a new secret.
return this.showYaml ? false : this.resourceType === HCI.VM_VERSION || this.isCreate; return this.isCreate || this.showYaml ? false : this.resourceType === HCI.VM_VERSION;
}, },
defaultTerminationSetting() { defaultTerminationSetting() {
@@ -299,9 +300,11 @@ export default {
async created() { async created() {
await this.$store.dispatch(`${ this.inStore }/findAll`, { type: SECRET }); await this.$store.dispatch(`${ this.inStore }/findAll`, { type: SECRET });
if (this.value.vmMachineTypesFeatureEnabled) { if (!this.value.vmMachineTypeAutoFeatureEnabled) {
try { try {
const url = this.$store.getters['harvester-common/getHarvesterClusterUrl']('v1/harvester/clusters/local?link=machineTypes'); const url = this.$store.getters['harvester-common/getHarvesterClusterUrl'](
'v1/harvester/clusters/local?link=machineTypes'
);
const machineTypes = await this.$store.dispatch('harvester/request', { url }); const machineTypes = await this.$store.dispatch('harvester/request', { url });
this.machineTypes = machineTypes; this.machineTypes = machineTypes;
@@ -687,14 +690,12 @@ export default {
set(this.spec.template.spec, 'domain.memory.maxGuest', this.maxMemory); set(this.spec.template.spec, 'domain.memory.maxGuest', this.maxMemory);
set(this.spec.template.spec, 'domain.resources.limits.memory', this.maxMemory); set(this.spec.template.spec, 'domain.resources.limits.memory', this.maxMemory);
} else { } else {
this.spec.template.spec.domain.cpu.maxSockets = 1;
this.spec.template.spec.domain.cpu.sockets = 1; this.spec.template.spec.domain.cpu.sockets = 1;
this.spec.template.spec.domain.cpu.cores = this.cpu; this.spec.template.spec.domain.cpu.cores = this.cpu;
this.spec.template.spec.domain.resources.limits.cpu = this.cpu?.toString(); this.spec.template.spec.domain.resources.limits.cpu = this.cpu?.toString();
this.spec.template.spec.domain.resources.limits.memory = this.memory; this.spec.template.spec.domain.resources.limits.memory = this.memory;
// clean below fields as we don't need them if not enable CPU and memory hotplug // clean below fields as we don't need them if not enable CPU and memory hotplug
if (this.spec?.template?.spec?.domain?.cpu?.maxSockets) {
delete this.spec.template.spec.domain.cpu.maxSockets;
}
if (this.spec?.template?.spec?.domain?.memory?.maxGuest) { if (this.spec?.template?.spec?.domain?.memory?.maxGuest) {
delete this.spec.template.spec.domain.memory.maxGuest; delete this.spec.template.spec.domain.memory.maxGuest;
} }
@@ -709,16 +710,7 @@ export default {
disk.forEach( (R, index) => { disk.forEach( (R, index) => {
const prefixName = this.value.metadata?.name || ''; const prefixName = this.value.metadata?.name || '';
const dataVolumeName = this.parseDataVolumeName(R, prefixName);
let dataVolumeName = '';
if (R.source === SOURCE_TYPE.ATTACH_VOLUME) {
dataVolumeName = R.volumeName;
} else if (this.isClone || !this.hasCreateVolumes.includes(R.realName)) {
dataVolumeName = `${ prefixName }-${ R.name }-${ randomStr(5).toLowerCase() }`;
} else {
dataVolumeName = R.realName;
}
const _disk = this.parseDisk(R, index); const _disk = this.parseDisk(R, index);
const _volume = this.parseVolume(R, dataVolumeName); const _volume = this.parseVolume(R, dataVolumeName);
@@ -892,7 +884,7 @@ export default {
}); });
const specInterfaces = this.spec?.template?.spec?.domain?.devices?.interfaces; const specInterfaces = this.spec?.template?.spec?.domain?.devices?.interfaces;
const mergedInterfaces = this.mergeDeviceList(specInterfaces, interfaces); const mergedInterfaces = this.mergeInterfaceList(specInterfaces, interfaces);
const spec = { const spec = {
...this.spec.template.spec, ...this.spec.template.spec,
@@ -1013,6 +1005,20 @@ export default {
this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled; this['cpuMemoryHotplugEnabled'] = cpuMemoryHotplugEnabled;
}, },
parseDataVolumeName(R, prefixName) {
let dataVolumeName = '';
if (R.source === SOURCE_TYPE.ATTACH_VOLUME) {
dataVolumeName = R.volumeName;
} else if (this.isClone || !this.hasCreateVolumes.includes(R.realName)) {
dataVolumeName = `${ prefixName }-${ R.name }-${ randomStr(5).toLowerCase() }`;
} else {
dataVolumeName = R.realName;
}
return dataVolumeName;
},
parseDisk(R, index) { parseDisk(R, index) {
const out = { name: R.name }; const out = { name: R.name };
@@ -1369,21 +1375,6 @@ export default {
} }
}, },
getCPUMemoryValidation() {
const errors = [];
const { cpu, memory } = this;
if ((!cpu)) {
errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.cpu') }, true));
}
if ((!memory)) {
errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.memory') }, true));
}
return errors;
},
getAccessCredentialsValidation() { getAccessCredentialsValidation() {
const errors = []; const errors = [];
@@ -1551,6 +1542,32 @@ export default {
this.refreshYamlEditor(); this.refreshYamlEditor();
}, },
mergeInterfaceList(specInterfaceList, interfaceList) {
if (!specInterfaceList || specInterfaceList.length === 0) {
return interfaceList;
}
const specInterfaceMap = new Map(specInterfaceList.map((iface) => [iface.name, iface]));
return interfaceList.map((iface) => {
const specInterface = specInterfaceMap.get(iface.name);
if (specInterface) {
const merged = { ...specInterface, ...iface };
// currently we only have bridge and masquerade network type, they are mutually exclusive
if (iface['bridge']) {
delete merged['masquerade'];
} else {
delete merged['bridge'];
}
return merged;
}
return iface;
});
},
mergeDeviceList(specDeviceList, deviceList) { mergeDeviceList(specDeviceList, deviceList) {
if (!specDeviceList || specDeviceList.length === 0) { if (!specDeviceList || specDeviceList.length === 0) {
return deviceList; return deviceList;
@@ -1627,7 +1644,8 @@ export default {
secretRef: { secretRef: {
handler(secret) { handler(secret) {
if (secret && this.resourceType !== HCI.BACKUP) { // we should not inherit the secret if it's from VM template.
if (secret && this.resourceType !== HCI.BACKUP && !this.useTemplate) {
this.secretName = secret?.metadata.name; this.secretName = secret?.metadata.name;
} }
}, },
@@ -0,0 +1,116 @@
import { escapeHtml } from '@shell/utils/string';
import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import HarvesterResource from './harvester';
/**
* Class representing vGPU MIGConfiguration resource.
* @extends HarvesterResource
*/
export default class MIGCONFIGURATION extends HarvesterResource {
get _availableActions() {
let out = super._availableActions;
out = out.map((action) => {
if (action.action === 'showConfiguration') {
return { ...action, enabled: !this.spec.enabled };
} else if (action.action === 'goToEditYaml') {
return { ...action, enabled: !this.spec.enabled };
} else if (action.action === 'goToEdit') {
// need to wait for status to be disabled or empty value, then allow user to editConfig
return { ...action, enabled: !this.spec.enabled && ['disabled', ''].includes(this.configStatus) };
} else {
return action;
}
});
out.push(
{
action: 'enableConfig',
enabled: !this.isEnabled,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableConfig',
enabled: this.isEnabled,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
},
);
return out;
}
get canYaml() {
return false;
}
get disableResourceDetailDrawer() {
return true;
}
get canDelete() {
return false;
}
get configStatus() {
return this.status.status;
}
get actualState() {
return this.isEnabled ? 'Enabled' : 'Disabled';
}
get stateDisplay() {
return this.actualState;
}
get stateColor() {
const state = this.actualState;
return colorForState(state);
}
get isEnabled() {
return this.spec.enabled;
}
get configuredProfiles() {
const configuredProfiles = this.spec?.profileSpec?.filter((p) => p.requested > 0) || [];
if (configuredProfiles.length === 0) {
return '';
}
return configuredProfiles
.map((profile) => `${ profile.name } * ${ profile.requested }`)
.join(', ');
}
async enableConfig() {
try {
this.spec.enabled = true;
await this.save();
} catch (err) {
this.$dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.name) }),
err,
}, { root: true });
}
}
async disableConfig() {
const { enabled: currentEnabled } = this.spec;
try {
this.spec.enabled = false;
await this.save();
} catch (err) {
this.spec.enabled = currentEnabled;
this.$dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.name) }),
err,
}, { root: true });
}
}
}
@@ -35,15 +35,10 @@ export default class HciPv extends HarvesterResource {
get availableActions() { get availableActions() {
let out = super._availableActions; let out = super._availableActions;
// Longhorn V2 provisioner do not support volume clone feature yet const clone = out.find((action) => action.action === 'goToClone');
if (this.isLonghornV2) {
out = out.filter((action) => action.action !== 'goToClone');
} else {
const clone = out.find((action) => action.action === 'goToClone');
if (clone) { if (clone) {
clone.action = 'goToCloneVolume'; clone.action = 'goToCloneVolume';
}
} }
const exportImageAction = { const exportImageAction = {
@@ -65,10 +60,6 @@ export default class HciPv extends HarvesterResource {
takeSnapshotAction, takeSnapshotAction,
...out ...out
]; ];
// TODO: remove this block if Longhorn V2 engine supports restore volume snapshot
if (this.isLonghornV2) {
out = out.filter((action) => action.action !== takeSnapshotAction.action);
}
} else { // v1.4 / v1.3 } else { // v1.4 / v1.3
if (!this.isLonghorn || !this.isLonghornV2) { if (!this.isLonghorn || !this.isLonghornV2) {
out = [ out = [
@@ -113,8 +113,9 @@ export default class HciAddonConfig extends HarvesterResource {
get displayName() { get displayName() {
const isExperimental = this.metadata?.labels?.[HCI_ANNOTATIONS.ADDON_EXPERIMENTAL] === 'true'; const isExperimental = this.metadata?.labels?.[HCI_ANNOTATIONS.ADDON_EXPERIMENTAL] === 'true';
const name = this.metadata?.labels?.[HCI_ANNOTATIONS.ADDON_DISPLAYNAME] || this.metadata.name;
return isExperimental ? `${ this.metadata.name } (${ this.t('generic.experimental') })` : this.metadata.name; return isExperimental ? `${ name } (${ this.t('generic.experimental') })` : name;
} }
get customValidationRules() { get customValidationRules() {
@@ -2,7 +2,7 @@ import SteveModel from '@shell/plugins/steve/steve-class';
import { HCI } from '@shell/config/labels-annotations'; import { HCI } from '@shell/config/labels-annotations';
import { NETWORK_TYPE } from '../config/types'; import { NETWORK_TYPE } from '../config/types';
const { UNTAGGED, OVERLAY } = NETWORK_TYPE; const { UNTAGGED, OVERLAY, L2TRUNK_VLAN } = NETWORK_TYPE;
export default class NetworkAttachmentDef extends SteveModel { export default class NetworkAttachmentDef extends SteveModel {
applyDefaults() { applyDefaults() {
@@ -29,6 +29,16 @@ export default class NetworkAttachmentDef extends SteveModel {
} }
} }
get isSystem() {
const systemNamespaces = this.$rootGetters['systemNamespaces'];
if (systemNamespaces.includes(this.metadata?.namespace)) {
return true;
}
return false;
}
get isIpamStatic() { get isIpamStatic() {
return this.parseConfig.ipam?.type === 'static'; return this.parseConfig.ipam?.type === 'static';
} }
@@ -45,7 +55,7 @@ export default class NetworkAttachmentDef extends SteveModel {
} }
get vlanId() { get vlanId() {
return this.vlanType === UNTAGGED || this.vlanType === OVERLAY ? 'N/A' : this.parseConfig.vlan; return this.vlanType === UNTAGGED || this.vlanType === OVERLAY || this.vlanType === L2TRUNK_VLAN ? 'N/A' : this.parseConfig.vlan;
} }
get customValidationRules() { get customValidationRules() {
@@ -68,7 +78,7 @@ export default class NetworkAttachmentDef extends SteveModel {
const route = annotations[HCI.NETWORK_ROUTE]; const route = annotations[HCI.NETWORK_ROUTE];
let config = {}; let config = {};
if (this.vlanType === UNTAGGED || this.vlanType === OVERLAY) { if (this.vlanType === UNTAGGED || this.vlanType === OVERLAY || this.vlanType === L2TRUNK_VLAN) {
return 'N/A'; return 'N/A';
} }
@@ -87,17 +87,11 @@ const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims'];
export default class VirtVm extends HarvesterResource { export default class VirtVm extends HarvesterResource {
get availableActions() { get availableActions() {
let out = super._availableActions; const out = super._availableActions;
const clone = out.find((action) => action.action === 'goToClone');
// VM attached with Longhorn V2 volume doesn't support clone feature if (clone) {
if (this.longhornV2Volumes.length > 0) { clone.action = 'goToCloneVM';
out = out.filter((action) => action.action !== 'goToClone');
} else {
const clone = out.find((action) => action.action === 'goToClone');
if (clone) {
clone.action = 'goToCloneVM';
}
} }
return [ return [
@@ -159,7 +153,7 @@ export default class VirtVm extends HarvesterResource {
}, },
{ {
action: 'takeVMSnapshot', action: 'takeVMSnapshot',
enabled: (!!this.actions?.snapshot || !!this.action?.backup) && !this.longhornV2Volumes.length, enabled: (!!this.actions?.snapshot || !!this.action?.backup),
icon: 'icon icon-snapshot', icon: 'icon icon-snapshot',
label: this.t('harvester.action.vmSnapshot') label: this.t('harvester.action.vmSnapshot')
}, },
@@ -206,10 +200,16 @@ export default class VirtVm extends HarvesterResource {
label: this.t('harvester.action.abortMigration') label: this.t('harvester.action.abortMigration')
}, },
{ {
action: 'addHotplug', action: 'addHotplugVolume',
enabled: !!this.actions?.addVolume, enabled: !!this.actions?.addVolume,
icon: 'icon icon-plus', icon: 'icon icon-plus',
label: this.t('harvester.action.addHotplug') label: this.t('harvester.action.addHotplugVolume')
},
{
action: 'addHotplugNic',
enabled: this.hotplugNicFeatureEnabled && !!this.actions?.addNic,
icon: 'icon icon-plus',
label: this.t('harvester.action.addHotplugNic')
}, },
{ {
action: 'createTemplate', action: 'createTemplate',
@@ -395,8 +395,20 @@ export default class VirtVm extends HarvesterResource {
this.$dispatch('promptModal', { this.$dispatch('promptModal', {
resources, resources,
diskName, name: diskName,
component: 'HarvesterUnplugVolume' type: 'volume',
component: 'HarvesterHotUnplug',
});
}
unplugNIC(networkName) {
const resources = this;
this.$dispatch('promptModal', {
resources,
name: networkName,
type: 'network',
component: 'HarvesterHotUnplug',
}); });
} }
@@ -504,10 +516,17 @@ export default class VirtVm extends HarvesterResource {
}); });
} }
addHotplug(resources = this) { addHotplugVolume(resources = this) {
this.$dispatch('promptModal', { this.$dispatch('promptModal', {
resources, resources,
component: 'HarvesterAddHotplugModal' component: 'HarvesterAddHotplugVolumeModal'
});
}
addHotplugNic(resources = this) {
this.$dispatch('promptModal', {
resources,
component: 'HarvesterAddHotplugNic'
}); });
} }
@@ -1236,8 +1255,12 @@ export default class VirtVm extends HarvesterResource {
return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage'); return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
} }
get vmMachineTypesFeatureEnabled() { get vmMachineTypeAutoFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('vmMachineTypes'); return this.$rootGetters['harvester-common/getFeatureEnabled']('vmMachineTypeAuto');
}
get hotplugNicFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('hotplugNic');
} }
get isBackupTargetUnavailable() { get isBackupTargetUnavailable() {
@@ -16,7 +16,10 @@ export default class HciVlanConfig extends HarvesterResource {
uplink: { uplink: {
nics: [], nics: [],
linkAttributes: {}, linkAttributes: {},
bondOptions: { mode: 'active-backup' }, bondOptions: {
mode: 'active-backup',
miimon: -1
},
}, },
}; };
+2 -2
View File
@@ -1,13 +1,13 @@
{ {
"name": "harvester", "name": "harvester",
"description": "Rancher UI Extension for Harvester", "description": "Rancher UI Extension for Harvester",
"version": "1.7.0-dev", "version": "1.7.0",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
"catalog.cattle.io/display-name": "Harvester", "catalog.cattle.io/display-name": "Harvester",
"catalog.cattle.io/kube-version": ">= 1.16.0-0", "catalog.cattle.io/kube-version": ">= 1.16.0-0",
"catalog.cattle.io/rancher-version": ">= 2.12.0-0", "catalog.cattle.io/rancher-version": ">= 2.13.0-0",
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0" "catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
} }
}, },
@@ -12,6 +12,7 @@ import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../../config/harvester'
import ImagePercentageBar from '@shell/components/formatter/ImagePercentageBar'; import ImagePercentageBar from '@shell/components/formatter/ImagePercentageBar';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import { STORAGE_CLASS } from '@shell/config/types';
const IMAGE_METHOD = { const IMAGE_METHOD = {
NEW: 'new', NEW: 'new',
@@ -32,6 +33,7 @@ export default {
async fetch() { async fetch() {
await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE }); await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE });
await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
const value = await this.$store.dispatch('harvester/create', { const value = await this.$store.dispatch('harvester/create', {
type: HCI.UPGRADE, type: HCI.UPGRADE,
@@ -63,6 +65,7 @@ export default {
sourceType: UPLOAD, sourceType: UPLOAD,
uploadController: null, uploadController: null,
uploadResult: null, uploadResult: null,
storageClassValue: null,
imageValue: null, imageValue: null,
enableLogging: true, enableLogging: true,
IMAGE_METHOD, IMAGE_METHOD,
@@ -79,7 +82,6 @@ export default {
skipSingleReplicaDetachedVolFeatureEnabled() { skipSingleReplicaDetachedVolFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('skipSingleReplicaDetachedVol'); return this.$store.getters['harvester-common/getFeatureEnabled']('skipSingleReplicaDetachedVol');
}, },
allOSImages() { allOSImages() {
return this.$store.getters['harvester/all'](HCI.IMAGE).filter((I) => I.isOSImage) || []; return this.$store.getters['harvester/all'](HCI.IMAGE).filter((I) => I.isOSImage) || [];
}, },
@@ -116,7 +118,7 @@ export default {
}, },
fileName() { fileName() {
return this.file?.name || ''; return this.preprocessImageName(this.file?.name || '');
}, },
canEnableLogging() { canEnableLogging() {
@@ -181,6 +183,38 @@ export default {
}); });
}, },
async createImageStorageClass(imageName = '') {
// delete related SC if existed
await this.deleteImageStorageClass(imageName);
const storageClassPayload = {
apiVersion: 'storage.k8s.io/v1',
type: STORAGE_CLASS,
metadata: { name: imageName },
volumeBindingMode: 'Immediate',
reclaimPolicy: 'Delete',
allowVolumeExpansion: true, // must be boolean type
provisioner: 'driver.longhorn.io',
};
this.storageClassValue = await this.$store.dispatch('harvester/create', storageClassPayload);
if (this.storageClassValue && this.storageClassValue.save) {
await this.storageClassValue.save();
}
},
async deleteImageStorageClass(imageName = '') {
const inStore = this.$store.getters['currentProduct'].inStore;
const storageClasses = this.$store.getters[`${ inStore }/all`](STORAGE_CLASS);
const targetSC = storageClasses.find((sc) => sc.id === imageName);
if (targetSC && targetSC.remove) {
await targetSC.remove();
}
},
async initImageValue() { async initImageValue() {
this.imageValue = await this.$store.dispatch('harvester/create', { this.imageValue = await this.$store.dispatch('harvester/create', {
type: HCI.IMAGE, type: HCI.IMAGE,
@@ -191,6 +225,7 @@ export default {
annotations: {} annotations: {}
}, },
spec: { spec: {
backend: 'cdi',
sourceType: UPLOAD, sourceType: UPLOAD,
displayName: '', displayName: '',
checksum: this.imageValue?.spec?.checksum || '', checksum: this.imageValue?.spec?.checksum || '',
@@ -203,8 +238,9 @@ export default {
this.file = {}; this.file = {};
this.errors = []; this.errors = [];
const imageDisplayName = this.imageValue?.spec?.displayName || '';
if (!this.imageValue.spec.displayName && this.createNewImage) { if (!imageDisplayName && this.createNewImage) {
this.errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('generic.name') })); this.errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('generic.name') }));
buttonCb(false); buttonCb(false);
@@ -212,14 +248,14 @@ export default {
} }
try { try {
// Save the image first if creating a new one
if (this.imageSource === IMAGE_METHOD.NEW) { if (this.imageSource === IMAGE_METHOD.NEW) {
this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True'; this.imageValue.metadata.annotations[HCI_ANNOTATIONS.OS_UPGRADE_IMAGE] = 'True';
if (this.sourceType === UPLOAD && this.uploadImageId !== '') { if (this.sourceType === UPLOAD && this.uploadImageId !== '') { // upload new image
this.value.spec.image = this.uploadImageId; this.value.spec.image = this.uploadImageId;
} else if (this.sourceType === DOWNLOAD) { } else if (this.sourceType === DOWNLOAD) { // give URL to download new image
this.imageValue.spec.sourceType = DOWNLOAD; // check if URL is provided
if (!this.imageValue.spec.url) { if (!this.imageValue.spec.url) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.imageUrl')); this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.imageUrl'));
buttonCb(false); buttonCb(false);
@@ -227,10 +263,16 @@ export default {
return; return;
} }
// create related image storage class first
await this.createImageStorageClass(imageDisplayName);
this.imageValue.spec.sourceType = DOWNLOAD;
this.imageValue.spec.targetStorageClassName = imageDisplayName;
res = await this.imageValue.save(); res = await this.imageValue.save();
this.value.spec.image = res.id; this.value.spec.image = res.id;
} }
} else if (this.imageSource === IMAGE_METHOD.EXIST) { } else if (this.imageSource === IMAGE_METHOD.EXIST) { // select existing image
if (!this.imageId) { if (!this.imageId) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.chooseFile')); this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.chooseFile'));
buttonCb(false); buttonCb(false);
@@ -240,25 +282,26 @@ export default {
this.value.spec.image = this.imageId; this.value.spec.image = this.imageId;
} }
// enable logging or skip single replica detection if checked
if (this.canEnableLogging) { if (this.canEnableLogging) {
this.value.spec.logEnabled = this.enableLogging; this.value.spec.logEnabled = this.enableLogging;
} }
if (this.skipSingleReplicaDetachedVolFeatureEnabled) { if (this.skipSingleReplicaDetachedVolFeatureEnabled) {
this.value.metadata.annotations = { [HCI_ANNOTATIONS.SKIP_SINGLE_REPLICA_DETACHED_VOL]: JSON.stringify(this.skipSingleReplicaDetachedVol) }; this.value.metadata.annotations = { [HCI_ANNOTATIONS.SKIP_SINGLE_REPLICA_DETACHED_VOL]: JSON.stringify(this.skipSingleReplicaDetachedVol) };
} }
await this.value.save(); await this.value.save();
this.done(); this.done();
buttonCb(true); buttonCb(true);
} catch (e) { } catch (e) {
this.errors = [e?.message] || exceptionToErrorsArray(e); this.errors = [e?.message] || exceptionToErrorsArray(e);
buttonCb(false); buttonCb(false);
// if anything failed, delete the created image storage class
await this.deleteImageStorageClass(imageDisplayName);
} }
}, },
async uploadFile(file) { async uploadFile(file) {
const fileName = file.name; const fileName = this.preprocessImageName(file.name);
if (!fileName) { if (!fileName) {
this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.unknownImageName')); this.errors.push(this.$store.getters['i18n/t']('harvester.setting.upgrade.unknownImageName'));
@@ -282,6 +325,10 @@ export default {
this.imageValue.spec.url = ''; this.imageValue.spec.url = '';
try { try {
// before uploading image, we need to create related image storage class first
await this.createImageStorageClass(fileName);
this.imageValue.spec.targetStorageClassName = fileName;
const res = await this.imageValue.save(); const res = await this.imageValue.save();
this.uploadImageId = res.id; this.uploadImageId = res.id;
@@ -298,20 +345,35 @@ export default {
} else { } else {
this.errors = exceptionToErrorsArray(e); this.errors = exceptionToErrorsArray(e);
} }
// if upload failed, delete the created image storage class
await this.deleteImageStorageClass(fileName);
this.file = {}; this.file = {};
this.uploadImageId = ''; this.uploadImageId = '';
} }
}, },
// replace _ to - to meet storage class name requirement
preprocessImageName(name) {
if (!name) {
return '';
}
return name.toLowerCase().replace(/[_]/g, '-');
},
handleImageDelete(imageId) { handleImageDelete(imageId) {
const image = this.allOSImages.find((I) => I.id === imageId); const image = this.allOSImages.find((I) => I.id === imageId);
const imageDisplayName = image?.spec?.displayName || '';
if (image) { if (image && imageDisplayName) {
this.$store.dispatch('harvester/promptModal', { this.$store.dispatch('harvester/promptModal', {
resources: [image], resources: [image],
component: 'ConfirmRelatedToRemoveDialog', component: 'ConfirmRelatedToRemoveDialog',
needConfirmation: false, needConfirmation: false,
warningMessage: this.$store.getters['i18n/t']('harvester.modal.osImage.message', { name: image.displayName }) warningMessage: this.$store.getters['i18n/t']('harvester.modal.osImage.message', { name: imageDisplayName }),
extraActionAfterRemove: async() => {
await this.deleteImageStorageClass(imageDisplayName);
}
}); });
this.deleteImageId = ''; this.deleteImageId = '';
} }
@@ -334,6 +396,22 @@ export default {
}, },
watch: { watch: {
async sourceType(neu) {
if (neu === DOWNLOAD && this.imageValue && this.uploadController) {
if (this.uploadController) {
this.uploadController.abort();
}
try {
await this.imageValue.remove();
await this.initImageValue();
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error occurred while removing imageValue:', error);
}
}
},
imageSource(neu) { imageSource(neu) {
if (neu !== IMAGE_METHOD.DELETE) { if (neu !== IMAGE_METHOD.DELETE) {
this.deleteImageId = ''; this.deleteImageId = '';
@@ -405,13 +483,13 @@ export default {
v-if="showUploadSuccessBanner" v-if="showUploadSuccessBanner"
color="success" color="success"
class="mt-0 mb-30" class="mt-0 mb-30"
:label="t('harvester.setting.upgrade.uploadSuccess', { name: file.name })" :label="t('harvester.setting.upgrade.uploadSuccess', { name: fileName })"
/> />
<Banner <Banner
v-if="showUploadingWarningBanner" v-if="showUploadingWarningBanner"
color="warning" color="warning"
class="mt-0 mb-30" class="mt-0 mb-30"
:label="t('harvester.image.warning.osUpgrade.uploading', { name: file.name })" :label="t('harvester.image.warning.osUpgrade.uploading', { name: fileName })"
/> />
<div <div
+3 -1
View File
@@ -37,6 +37,7 @@ export const HCI = {
STORAGE: 'harvesterhci.io.storage', STORAGE: 'harvesterhci.io.storage',
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota', RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
KSTUNED: 'node.harvesterhci.io.ksmtuned', KSTUNED: 'node.harvesterhci.io.ksmtuned',
HUGEPAGES: 'node.harvesterhci.io.hugepage',
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice', PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim', PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice', SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
@@ -44,6 +45,7 @@ export const HCI = {
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice', SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
USB_DEVICE: 'devices.harvesterhci.io.usbdevice', USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim', USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig', VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus', VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
ADD_ONS: 'harvesterhci.io.addon', ADD_ONS: 'harvesterhci.io.addon',
@@ -53,7 +55,7 @@ export const HCI = {
LB: 'loadbalancer.harvesterhci.io.loadbalancer', LB: 'loadbalancer.harvesterhci.io.loadbalancer',
IP_POOL: 'loadbalancer.harvesterhci.io.ippool', IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig', HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup' LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
}; };
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot'; export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
+6 -7
View File
@@ -3,17 +3,19 @@ import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations
export function getVmCPUMemoryValues(vm) { export function getVmCPUMemoryValues(vm) {
if (!vm) { if (!vm) {
return { return {
cpu: 0, cpu: null,
memory: null, memory: null,
isHotplugEnabled: false isHotplugEnabled: false
}; };
} }
const isHotplugEnabled = isCPUMemoryHotPlugEnabled(vm); const isHotplugEnabled = isCPUMemoryHotPlugEnabled(vm);
const { sockets = 1, threads = 1, cores = null } = vm.spec.template.spec.domain.cpu || {};
const cpu = cores === null ? null : sockets * threads * cores;
if (isHotplugEnabled) { if (isHotplugEnabled) {
return { return {
cpu: vm.spec.template.spec.domain.cpu.sockets, cpu,
memory: vm.spec.template.spec.domain?.memory?.guest || null, memory: vm.spec.template.spec.domain?.memory?.guest || null,
maxCpu: vm.spec.template.spec.domain.cpu?.maxSockets || 0, maxCpu: vm.spec.template.spec.domain.cpu?.maxSockets || 0,
maxMemory: vm.spec.template.spec.domain?.memory?.maxGuest || null, maxMemory: vm.spec.template.spec.domain?.memory?.maxGuest || null,
@@ -21,7 +23,7 @@ export function getVmCPUMemoryValues(vm) {
}; };
} else { } else {
return { return {
cpu: vm.spec.template.spec.domain.cpu.cores, cpu,
memory: vm.spec.template.spec.domain.resources?.limits?.memory || null, memory: vm.spec.template.spec.domain.resources?.limits?.memory || null,
isHotplugEnabled isHotplugEnabled
}; };
@@ -29,8 +31,5 @@ export function getVmCPUMemoryValues(vm) {
} }
export function isCPUMemoryHotPlugEnabled(vm) { export function isCPUMemoryHotPlugEnabled(vm) {
return vm?.metadata?.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG] === 'true' || return vm?.metadata?.annotations[HCI_ANNOTATIONS.VM_CPU_MEMORY_HOTPLUG] === 'true' || !!vm?.spec?.template?.spec?.domain?.memory?.maxGuest || false;
!!vm?.spec?.template?.spec?.domain.cpu?.maxSockets ||
!!vm?.spec?.template?.spec?.domain?.memory?.maxGuest ||
false;
} }
+54 -6
View File
@@ -2,30 +2,78 @@ import { HCI } from '@pkg/harvester/config/labels-annotations';
export const VM_IMAGE_FILE_FORMAT = ['qcow', 'qcow2', 'raw', 'img', 'iso']; export const VM_IMAGE_FILE_FORMAT = ['qcow', 'qcow2', 'raw', 'img', 'iso'];
/**
* Extracts the filename from a URL, handling query parameters and fragments
* @param {string} url - The URL to parse
* @returns {string} - The filename without query params or fragments
*/
function getFilenameFromUrl(url) {
try {
// Try to parse as a full URL
const urlObj = new URL(url);
// Get pathname and extract the last segment
const pathname = urlObj.pathname;
return pathname.split('/').filter(Boolean).pop() || '';
} catch (e) {
// If URL parsing fails, treat as a relative path
// Remove query params and fragments manually
const cleanUrl = url.split('?')[0].split('#')[0];
return cleanUrl.split('/').pop() || '';
}
}
/**
* Validates image URL format
* @param {string} url - The image URL to validate
* @param {object} getters - Vuex getters
* @param {array} errors - Array to collect validation errors
* @param {any} validatorArgs - Additional validator arguments
* @param {string} type - Type of validation ('file' or other)
* @returns {array} - Array of validation errors
*/
export function imageUrl(url, getters, errors, validatorArgs, type) { export function imageUrl(url, getters, errors, validatorArgs, type) {
const tipString =
type === 'file' ? 'harvester.validation.image.ruleFileTip' : 'harvester.validation.image.ruleTip';
const t = getters['i18n/t']; const t = getters['i18n/t'];
if (!url || url === '') { if (!url || url === '') {
return errors; return errors;
} }
const suffixName = url.split('/').pop(); // Extract filename, handling query parameters and fragments
const fileSuffix = suffixName.split('.').pop().toLowerCase(); const filename = getFilenameFromUrl(url);
if (!filename) {
errors.push(t(tipString));
return errors;
}
// Get file extension
const fileSuffix = filename.split('.').pop().toLowerCase();
if (!VM_IMAGE_FILE_FORMAT.includes(fileSuffix)) { if (!VM_IMAGE_FILE_FORMAT.includes(fileSuffix)) {
const tipString = type === 'file' ? 'harvester.validation.image.ruleFileTip' : 'harvester.validation.image.ruleTip';
errors.push(t(tipString)); errors.push(t(tipString));
} }
return errors; return errors;
} }
export function fileRequired(annotations = {}, getters, errors, validatorArgs, type) { export function fileRequired(
annotations = {},
getters,
errors,
validatorArgs,
type
) {
const t = getters['i18n/t']; const t = getters['i18n/t'];
if (!annotations[HCI.IMAGE_NAME]) { if (!annotations[HCI.IMAGE_NAME]) {
errors.push(t('validation.required', { key: t('harvester.image.fileName') })); errors.push(
t('validation.required', { key: t('harvester.image.fileName') })
);
} }
return errors; return errors;
+914 -550
View File
File diff suppressed because it is too large Load Diff