Compare commits

..
100 Commits
Author SHA1 Message Date
mergify[bot]andTim Liou 1dcb528717 chore: bump sha for upstream action pinning (#827) (#828)
(cherry picked from commit b7119d5c4c)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-04-24 16:38:12 +08:00
Harvester Bot 36fc21ccbc chore: bump version to 1.8.0 (#826)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-04-24 15:52:17 +08:00
mergify[bot]andAndy Lee 8803b79524 fix: change auth/V3user to auth/user (#824) (#825)
* fix: change auth/V3user to auth/user



* refactor: extract to utils/auth.js



---------


(cherry picked from commit 6fdd1e3954)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-22 15:42:30 +08:00
Harvester Bot 3f80a71303 chore: bump version to 1.8.0-rc6 (#812)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-04-16 13:43:38 +08:00
Andy Lee 13e4154335 Revert "deps: update patch digest dependencies (#796)" (#809)
This reverts commit 7788161d94.
2026-04-13 16:34:30 +08:00
renovate[bot] a80b87ce4f deps: update fossas/fossa-action action to v1.9.0 (#797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 16:24:18 +08:00
mergify[bot]andAndy Lee 809b9925fa ci: add add_minrelaseday to delay dep update (#798) (#800)
(cherry picked from commit 6dd9b33336)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-07 15:30:12 +08:00
renovate[bot] 7788161d94 deps: update patch digest dependencies (#796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 08:34:47 +00:00
mergify[bot]andTim Liou 90b8a821a1 chore: bump version to 1.8.0-rc5 (#789) (#790)
(cherry picked from commit 1f9e9b336b)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-04-01 09:17:31 +08:00
mergify[bot]andAndy Lee 87ff014bd8 refactor: add banner in PCI Devices page (#785) (#788)
* refactor: add banner in PCI Devices page



* refactor: based on copilot review



---------


(cherry picked from commit c5b4f6cd1e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-01 09:05:00 +08:00
mergify[bot]andAndy Lee 20328b06e6 fix: the rancher-eio/read-vault-secret sha (#786) (#787)
(cherry picked from commit 4ce35ce075)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-31 14:09:22 +08:00
mergify[bot]andTim Liou bbc415a2b4 chore: bump version to 1.8.0-rc4 (#778) (#779)
(cherry picked from commit 9d698b1230)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-03-27 16:42:53 +08:00
c12d4b5fba feat: support advance option and creation from DataVolume (#776) (#777)
* feat: support advance option and creation from DataVolume

    - advance option would let user change the accessMode/volumeMode
    - creation from DataVolume could supports various scenario with
      3rd-party storage



* feat: add data migration action on volume page



* refactor: use show advanced options link instead of checkbox



* feat: add feature flag



* feat: add feature flag for dataMigration action



---------




(cherry picked from commit 566e79eda5)

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-26 17:33:01 +08:00
mergify[bot]andAndy Lee dc91be11f9 chore: bump version to 1.8.0-rc3 (#774) (#775)
(cherry picked from commit 42ddcfc1fe)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-26 12:35:12 +08:00
mergify[bot]andAndy Lee e5b72499b5 fix: use default value got undefined value in payload (#772) (#773)
(cherry picked from commit ad3decf71f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-25 17:12:51 +08:00
mergify[bot]andAndy Lee e66037b007 ci: remove the single quota for use commit hash (#767) (#771)
(cherry picked from commit 8083a41df0)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-25 14:13:08 +08:00
mergify[bot]andfreeze c676cad057 chore: pin GH Actions to commit sha (#765) (#766)
(cherry picked from commit 62801b3b13)

Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-03-25 14:08:49 +08:00
mergify[bot]andAndy Lee 254abd4648 ci: add CODEOWNERS file (#768) (#769)
(cherry picked from commit 46b860260a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-25 13:42:42 +08:00
renovate[bot] 1df1d3534a deps: update dependency yaml to v2.8.3 (#757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-22 13:06:18 +00:00
mergify[bot]andAndy Lee ff1e6e6e7f docs: update install instruction doc link (#753) (#754)
(cherry picked from commit 97e93dba0b)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-20 16:53:54 +08:00
mergify[bot]andAndy Lee 622242e4a5 refactor: change rwxNetwork setting to kind json (#751) (#752)
(cherry picked from commit 9a8a709e56)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-20 14:48:49 +08:00
mergify[bot]andAndy Lee d8ea9be174 feat: introduce rwxNetwork setting (#746) (#750)
* feat: add rwxNetwork setting



* fix: network payload



---------


(cherry picked from commit d1949641a7)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-19 16:19:44 +08:00
mergify[bot]andfreeze f8a479cfcf feat: add storage migration operation (#724) (#749)
- add storage migration
    - add cancel storage migration


(cherry picked from commit 9c9f59c939)

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-03-19 11:53:24 +08:00
mergify[bot]andAndy Lee 6d5e584113 chore: bump to v1.8.0-rc2 (#747) (#748)
(cherry picked from commit ccc14c7fb9)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-19 11:31:07 +08:00
mergify[bot]andAndy Lee a9eb04195e feat: introduce instance-manager-resources setting (#744) (#745)
(cherry picked from commit 2ba471907e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-18 17:21:28 +08:00
mergify[bot]andAndy Lee 0441c1f6e3 refactor: remove using resourceName to determine is vGPU device (#741) (#743)
(cherry picked from commit 5aea476f64)

Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-18 17:16:28 +08:00
mergify[bot]andAndy Lee dde58269d5 fix: typo (#740) (#742)
(cherry picked from commit 519c7d9f1f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-17 16:33:37 +08:00
mergify[bot]andTim Liou b8111f0ad7 fix: reword the error message to focus on bootable volume (#736) (#738)
(cherry picked from commit a9c392c13f)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-03-12 17:46:08 +08:00
mergify[bot]andTim Liou 6d627f82e9 fix: container disks don't need volumeClaimTemplates but volumes (#735) (#737)
(cherry picked from commit 888ec7a50f)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-03-12 17:45:40 +08:00
mergify[bot]andAndy Lee cfc7a76fe7 feat: add vGPU filter button and hide the enable/disable passthrough in PCIDevice page (#729) (#732)
* feat: add another filter button



* feat: add feature falg to hide vGPU enable/disable actions in PCIDevices page



* refactor: update with conditionally rendering



---------


(cherry picked from commit 23344e0c07)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-11 15:39:41 +08:00
mergify[bot]andPo Han Huang 71d3067354 feat: ensure the state is pending when perform cloning the efi (#730) (#734)
* feat: ensure the state is pending when perform cloning the efi



* feat: define harvesterhci.io/clone-backend-storage-status in labels-annotations.js



---------


(cherry picked from commit a2486a7d38)

Signed-off-by: pohanhuang <pohan.huang@suse.com>
Co-authored-by: Po Han Huang <hhcs9527@gmail.com>
2026-03-11 15:39:01 +08:00
mergify[bot]andAndy Lee 9ecc372009 chore: bump to v1.8.0-rc1 (#731) (#733)
(cherry picked from commit df3d249923)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-11 15:34:55 +08:00
Andy Lee 62b80b3cec feat: add Insecure Skip TLS Verify checkbox in cluster-registration-url setting (#716)
* feat: add Insecure Skip TLS Verify checkbox

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

* refactor: set insecureSkipTLSVerify default to false

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

* fix: conflict

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

* refactor: remove unneeded change

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

* fix: get the feature flag in data()

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

* refactor: make data logic simpler

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

* refactor: put tip in info banner

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-09 10:51:20 +08:00
renovate[bot] 1cf94ee550 deps: update dependency @types/node to v20.19.37 (#726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 14:15:49 +08:00
renovate[bot] dfaa9fbe33 deps: update dependency elkjs to ^0.11.0 (#727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 14:15:01 +08:00
Andy Lee 94b1c24479 chore: bump @rancher/shell to v3.0.9-rc.6 (#712)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-08 14:13:46 +08:00
Jack Yu e0b2b9ec57 feat: add support-bundle-file-name setting (#725)
* feat: support customizing support bundle file name

Signed-off-by: Jack Yu <jack.yu@suse.com>

* feat: add supportBundleFileNameSetting feature flag

Signed-off-by: Jack Yu <jack.yu@suse.com>

---------

Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-03-06 15:51:20 +08:00
Andy Lee 06b38a0f99 fix: import sources page header (#723)
* fix: the import sources page header

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

* refactor: update VMware term

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-05 16:36:15 +08:00
Yiya Chen 4be3634c82 feat: add VPC network topology in detail page (#721)
* feat: add topology

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

* feat: add provider info

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

* refactor: remove comments

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

* fix: exclude default network

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

* feat: add VPC peering

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

* refactor: remove regex

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

* refactor: adjust row height

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

* feat: introduce auto layout

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

---------

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2026-03-04 11:47:00 +08:00
Andy Lee de103ff91e fix: missing Download Logs button in Rancher integration mode (#715)
* fix: missing Download Logs button in Rancher integration mode

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>
2026-03-02 16:47:20 +08:00
Andy Lee 37a91601c9 feat: add vmMigrationTimeout in vm-force-reset-policy setting (#718)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-02 11:10:52 +08:00
renovate[bot] 9794671ccd deps: update patch digest dependencies (#719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 11:10:22 +08:00
Volker Theile 337f9186f1 fix(vmimport): Rename list pages according to their URL path (#717)
See https://github.com/harvester/harvester-ui-extension/pull/697#issuecomment-3957918146

Related to: https://github.com/harvester/harvester/issues/4663

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-02-26 16:32:40 +08:00
Volker Theile 7c187e894d fix(vmimport): Add missing list page for ovasource (#714)
... and several small improvements.

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-02-25 16:47:15 +08:00
Gaurav Mehta c2df13ad73 fix: changed logic to use power status from underlying inventory (#707)
Signed-off-by: Gaurav Mehta <gaurav.mehta@suse.com>
2026-02-23 20:08:54 +11:00
Tim Liou 93f027a57c fix: shall update hotpluggable when change to a non-iso image (#713)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-02-23 17:08:33 +08:00
renovate[bot] da83f04b6c deps: update dependency qs to v6.15.0 (#710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 11:50:05 +08:00
renovate[bot] a97cb08e3f deps: update patch digest dependencies (#705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 14:22:19 +08:00
Tim Liou 3fdc9f03a3 feat: add cdrom hotplug volume (#703)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-02-13 16:33:50 +08:00
Jack Yu c8a613874a feat: add cpu model selection (#702)
Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-02-13 15:40:07 +08:00
Andy Lee 2db7ee7397 feat: show notification if there is VM pending restart (#700)
* feat: show notification if there is VM pending restart

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

* refactor: update based on comments

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

* refactor: calculate count from vm names

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-02-03 16:21:20 +08:00
Andy Lee 8b9b5b41b7 style: set max-height for namespace dropdown menu (#693)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-30 16:43:15 +08:00
Andy Lee 77599900b5 feat: add resume button for upgrade paused node (#698)
* feat: add nodeUpgradeOption setting

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

* feat: add resume button when node paused

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

* feat: add feature flag in v1.7.0

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-30 15:12:00 +08:00
Andy Lee 473c1ba355 fix: this.action typo (#696)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-28 15:49:07 +08:00
Andy Lee 708a95b67b chore: restrict v1.8.0 ui extension to run on rancher 2.14 or above (#695)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-27 22:34:47 +08:00
Andy Lee fecb3de0cf chore: update @rancher/shell to v3.0.9-rc.1 (#694)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-26 15:44:14 +08:00
Jack Yu 0781bde188 feat: add warning message when disabling a device that have not been detached in the backend (#675)
* feat: add wanring message when disabling a device that haven not been detached in the backend

Signed-off-by: Jack Yu <jack.yu@suse.com>

* fix: remove unused en-us key

Signed-off-by: Jack Yu <jack.yu@suse.com>

---------

Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-01-26 14:07:28 +08:00
Kuan-Po Tseng 0647600e88 fix: use longhorn-static for upgrade vmimage (#690)
Signed-off-by: Cooper Tseng <cooper.tseng@suse.com>
2026-01-22 09:46:20 +08:00
Andy Lee 99dbba7958 fix: remove isCordoned condition (#689)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-21 17:26:53 +08:00
Dominik WombacherandVolker Theile 3dcc50980b feat: Introduce VM Import UI flow pages (#642)
* feat(vmimport): First working side nav attempt

Add vmimport entries when the related resource actually exists aka addon was enabled

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): improved version that uses 'store.watch' instead of polling

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): further tuning of dynamic side navi load/unload

Code formatting and commits. Also safeguard if something is wrong with the store

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): separate vmimport side nav entries from dynamic logic

function registerAddonSideNav introduced in utils/dynamic-nav.js

Decouples vmimport side nav entries from the hide/unhide based on addon status logic

Makes it reusable in the UI with other AddOns in the future

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add custom headers for HCI.VMIMPORT

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add custom headers for HCI.VMIMPORT_SOURCE_V

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add custom headers for HCI.VMIMPORT_SOURCE_O

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): array instead string passed to configureType

Caused routing issues for CRUD operations

Labels moved to labelTypes section to follow standards

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): registerAddonSideNav improved and refactored

Clear comments, code refactoring, additional checks and validations

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): show correct status for virtualmachineimport

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): custom list components with ns grouping

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): 404 on refresh and missing menu entry

Restores virtualType definitions to register routes synchronously,
preventing 404 errors during page reload.

Updates dynamic-nav to force-fetch addon data if missing, fixing
hidden menu issues on direct page access.

Restores explicit label keys for virtualTypes to ensure correct
naming in the side navigation.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for VirtualMachineImport resource

Adds a UI form for VirtualMachineImport to replace manual YAML editing.
The form fetches VmwareSource and OpenstackSource objects for the
source selection dropdown.

It validates the VM name against RFC-1123 rules and filters out
internal storage classes. Users can also configure network mappings
via a dynamic list.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for VmwareSource

Adds a UI to configure VmwareSource resources including the endpoint
and datacenter fields.

For authentication, users can either select an existing Secret or
enter a username and password directly. The form handles creating
the required Kubernetes Secret in the background when new credentials
are provided.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for OpenstackSource

Custom edit form for OpenstackSource resource. Creates new secret
or lets users select existing secrets. Support all fields the CRD has.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* chore(vmimport): vmware source default endpoint and datacenter renamed

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): add edit form for OvaSource

OvaSource (new in harvester / vm-import-controller v1.7.0).
Imports VMs from an OVA file using via HTTP or HTTPS.

The form supports URL configuration and optional Basic Auth using a
username and password. Users can also provide an optional CA Certificate
for HTTPS verification and configure advanced HTTP timeout settings.

VirtualMachineImport edit page to updated to include OvaSource in
the source dropdown.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* chore(vmimport): align tab names on openstacksource

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): import { TextArea } from '@components/Form/TextArea' not found

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): 'Destination Network' don't list all networks

Online listed the 'mgmt' Network. Adjust to read all Virtual Machine Networks.

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): rename side-nav entry to 'Virtual Machine Imports'

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): OvaSource Auth tab throws error selecting existing secret

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): Add missing caCert input field to vmware source

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): use 'LabeledInput' instead of 'TextAreaAutoGrow' for cacert fields

Changing the type allows labels to show up in the UI

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Move vars into types files and reference them

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Use 'currentProduct' value instead of hardcoded 'harvester' string

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): shorten 'selectedOption.raw' usage

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Checks to make splice() usage more robust

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): re-use existing rfc1123 val function

Move rfc1123 validation error message to l10n/en-us.yaml

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* fix(vmimport): var name typo in vmi edit rfc1123 check

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): vmi use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): oss use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): ovas use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* feat(vmimport): vms use 'FormValidation' and l10n for labels

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>

* refactor(vmimport): Display error message at the top of the page

Signed-off-by: Volker Theile <vtheile@suse.com>

---------

Signed-off-by: Dominik Wombacher <dominik.wombacher@suse.com>
Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-01-21 15:30:55 +08:00
renovate[bot] ee1c3de188 deps: update patch digest dependencies (#686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 11:22:38 +08:00
Kuan-Po Tseng 915559962a fix: use file as field name instead of chunk in cdi vmimage upload (#684)
Signed-off-by: Cooper Tseng <cooper.tseng@suse.com>
2026-01-20 10:57:48 +08:00
Andy Lee b1b1a31c04 fix: do not set cpu.maxSockets on UI (#674)
* fix: do not set cpu.maxSockets for ARM clusters

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

* fix: remove maxSocket to fix bug on ARM cluster

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-16 17:09:13 +08:00
Guilherme Macedo 7f52562d22 ci: add FOSSA scanning workflow (#683)
Signed-off-by: Guilherme Macedo <guilherme@gmacedo.com>
2026-01-16 15:42:33 +08:00
Andy Lee b140c05697 ci: only do package auto update for release-harvester-v1.7 ~ v1.9 (#682)
* ci: disable package auto update for release-harvester-v1.0/v1.5

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

* refactor: only match release v1.7 ~ v1.9 branches

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

* refactor: change basebranches

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-14 15:51:15 +08:00
Andy Lee ad9fef63c0 chore: update copyright year to 2026 (#681)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-13 15:15:50 +08:00
renovate[bot] 786e271ac6 deps: update patch digest dependencies (#676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 22:28:33 +08:00
freeze c169853e5a chore: bump version to v1.8.0-dev (#672)
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
2026-01-04 23:19:49 +08:00
Tim Liou 1352246e1e fix: drop mac-address annotation from vm template to prevent MAC address reusing (#663)
related-to: harvester/harvester#9788
related-to: harvester/harvester#9798

Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-01-02 17:24:57 +08:00
renovate[bot] 49374bb18a deps: update dependency qs to v6.14.1 [security] (#664)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-02 16:15:54 +08:00
Andy Lee fe3a12e28c docs: add README.md in pkg/harvester (#661)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-12-23 16:23:01 +08:00
Andy Lee a86302c9d5 chore: bump version to v1.7.0 (#658) 2025-12-22 13:23:54 +08:00
freeze 5fe7e13fcd chore: bump version to v1.7.0-rc7 (#656)
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
2025-12-16 20:19:22 +08:00
renovate[bot] c079984047 deps: update dependency @types/node to v20.19.27 (#651)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 14:22:49 +08:00
Andy Lee 5769588633 chore: bump version to v1.7.0-rc6 (#649)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-12-11 16:47:48 +08:00
Andy Lee b29950f99c fix: failed to create multiple VMs (#647)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-12-11 16:12:39 +08:00
Andy Lee 6c27a46274 fix: do not inherit template secret when creating new VM (#643)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-12-10 17:12:56 +08:00
Andy Lee b03fffbc30 feat: read addon displayname from label and add descheduler description (#644)
* refactor: display addon name from addon.harvesterhci.io/displayName label

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

* refactor: add descheduler description

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-12-10 15:13:21 +08:00
Andy Lee 5b668a176c feat: integrate cron editor in vm schedule edit page (#635)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-12-10 08:02:58 +08:00
renovate[bot] b4019a2c86 deps: update patch digest dependencies (#630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 15:08:40 +08:00
freeze 416098ffd8 chore: bump to v1.7.0-rc5 (#636)
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
2025-12-04 22:40:17 +08:00
Andy Lee 3d7b96d86d chore: bump to v1.7.0-rc4 (#621)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-27 17:45:36 +08:00
Caio Torres 0b37467f76 fix: create new secret on vm creation (#614)
* fix: create new secret on vm creation

Signed-off-by: Caio Torres <caio.torres@suse.com>

* fix: ensure parseVM result is immutable

Signed-off-by: Caio Torres <caio.torres@suse.com>

---------

Signed-off-by: Caio Torres <caio.torres@suse.com>
2025-11-27 17:36:55 +08:00
renovate[bot] fab7fbec5e deps: update dependency node-forge to v1.3.2 [security] (#623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 14:32:20 +08:00
Andy Lee d94003f8c2 feat: allow user to attach volume to muliple VMs (#620)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-27 14:22:06 +08:00
renovate[bot] dbb5b01cc3 deps: update patch digest dependencies (#615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 14:56:50 +08:00
Andy Lee 467933bda0 chore: bump version to v1.7.0-rc3 (#612) 2025-11-20 10:19:12 +08:00
Yiya Chen 1b183febdc fix: condition render namespaceOptions (#607)
Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-11-19 17:35:33 +08:00
Andy Lee 70d3b656f7 fix: change migConfiguration model to inherit from harvester resource (#608)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-19 17:30:58 +08:00
Andy Lee 10d19cd329 feat: create related image storageclass before OS upgrade (#595)
* feat: create related image SC before upgrade

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

* refactor: update spec.targetStorageClassName

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

* refactor: based on comment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-17 17:25:16 +08:00
renovate[bot] 87e44cb658 deps: update dependency @types/node to v20.19.25 (#600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 17:08:36 +08:00
Yiya Chen 1715ae754c feat: modified placeholder (#599) 2025-11-17 17:05:40 +08:00
30de2b1a18 feat: add support for configuring transparent hugepages (#414)
* feat: add support for configuring transparent hugepages

Related-to: https://github.com/harvester/harvester/issues/5006
Co-authored-by: Moritz Röhrich <moritz.rohrich@suse.com>
Signed-off-by: Tim Serong <tserong@suse.com>

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

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

Co-authored-by: Andy Lee <andy.lee@suse.com>
Signed-off-by: Tim Serong <tserong@suse.com>

---------

Signed-off-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 17:09:56 +11:00
Andy Lee 6fedcc353c chore: bump version to v1.7.0-rc2 (#596)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-12 09:14:24 +08:00
Yiya Chen f9bff21e84 feat: support for HotPlugNICs from Kubevirt (#582)
* 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

---------

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-11-11 11:43:46 +08:00
Andy Lee 6735826e15 chore: update yarn.lock for @rancher/shell v3.0.8-rc.8 (#591)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-10 15:59:11 +08:00
Andy Lee 9e17e239cf chore: bump shell version to v3.0.8-rc.8 (#588)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-10 14:53:49 +08:00
Tim Serong a1cf41bda9 feat: enable snapshot and clone for LHv2 (#379)
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

Signed-off-by: Tim Serong <tserong@suse.com>
2025-11-06 16:18:37 +11:00
Andy Lee db58024351 ci: lint last commit if is empty string or all zero (#584)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-05 15:07:43 +08:00
Andy Lee 81bf19419c chore: bump version to v1.7.0-rc1 (#583)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-04 16:07:02 +08:00
72 changed files with 6571 additions and 504 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
],
"baseBranches": [
"main",
"/^release-harvester-v\\d+\\.\\d+$/"
"release-harvester-v1.7",
"release-harvester-v1.8"
],
"automergeMajor": false,
"semanticCommits": "enabled",
@@ -43,10 +43,12 @@ jobs:
needs:
- setup-release-tag
- check-version
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@3b26a36bad555e5e2b8634b24823be29732f287c # master
permissions:
actions: write
contents: write
pull-requests: write
deployments: write
pages: write
with:
target_branch: gh-pages
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
+1 -1
View File
@@ -1,7 +1,7 @@
name: Tests
on:
workflow_call: # This tells GH that the workflow is reusable
workflow_call: # This tells GH that the workflow is reusable
push:
branches:
- main
+2
View File
@@ -0,0 +1,2 @@
@a110605
@houhoucoop
+2 -2
View File
@@ -7,7 +7,7 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
## Installation
For detailed installation instructions, please refer to the [official Harvester documentation](https://docs.harvesterhci.io/v1.5/rancher/harvester-ui-extension#installation-on-rancher-210).
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
## Development Setup
@@ -163,7 +163,7 @@ If you want to contribute, start by reading this document, then visit our [Getti
## License
Copyright (c) 2014-2025 [SUSE, LLC.](https://www.suse.com/)
Copyright (c) 2014-2026 [SUSE, LLC.](https://www.suse.com/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+14 -8
View File
@@ -1,17 +1,22 @@
{
"name": "harvester-ui-extension",
"version": "1.7.2",
"version": "1.8.0",
"private": false,
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.29.7",
"@rancher/shell": "3.0.8-rc.8",
"@babel/plugin-transform-class-static-block": "7.28.6",
"@rancher/shell": "3.0.9-rc.6",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
"@vue-flow/minimap": "^1.4.0",
"cache-loader": "^4.1.0",
"color": "4.2.3",
"ip": "2.0.1",
"node-polyfill-webpack-plugin": "^3.0.0",
"elkjs": "^0.11.0",
"vue-draggable-next": "^2.2.1",
"yaml": "^2.5.1"
},
@@ -20,17 +25,17 @@
"cronstrue": "2.59.0",
"d3-color": "3.1.0",
"ejs": "3.1.10",
"follow-redirects": "1.16.0",
"follow-redirects": "1.15.11",
"glob": "7.2.3",
"glob-parent": "6.0.2",
"json5": "2.2.3",
"@types/lodash": "4.17.24",
"merge": "2.1.1",
"node-forge": "1.4.0",
"node-forge": "1.3.3",
"nth-check": "2.1.1",
"qs": "6.15.3",
"roarr": "7.21.6",
"semver": "7.8.5",
"qs": "6.15.0",
"roarr": "7.21.4",
"semver": "7.7.4",
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
},
"scripts": {
@@ -38,6 +43,7 @@
"build": "./node_modules/.bin/vue-cli-service build",
"clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/.bin/eslint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/.bin/eslint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
+2 -3
View File
@@ -7,8 +7,7 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
## Installation
For detailed installation instructions, please refer to the [official Harvester documentation](https://docs.harvesterhci.io/v1.5/rancher/harvester-ui-extension#installation-on-rancher-210).
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
## Development Setup
@@ -163,7 +162,7 @@ If you want to contribute, start by reading this document, then visit our [Getti
## License
Copyright (c) 2014-2025 [SUSE, LLC.](https://www.suse.com/)
Copyright (c) 2014-2026 [SUSE, LLC.](https://www.suse.com/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -1,6 +1,8 @@
<script>
import Collapse from '@shell/components/Collapse';
import PercentageBar from '@shell/components/PercentageBar';
import { HCI } from '../types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterUpgradeProgressList',
@@ -25,13 +27,45 @@ export default {
}
},
async fetch() {
await this.$store.dispatch('harvester/findAll', { type: HCI.UPGRADE });
},
data() {
return { open: true };
},
computed: {
showResumeButton() {
return this.title === 'Upgrading Node';
},
latestUpgradeCR() {
return this.$store.getters['harvester/all'](HCI.UPGRADE).find( (U) => U.isLatestUpgrade);
},
resumeUpgradePausedNodeEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('resumeUpgradePausedNode');
},
},
methods: {
handleSwitch() {
this.open = !this.open;
},
async resumeNodeUpgrade(nodeName) {
if (!this.latestUpgradeCR || !nodeName) return;
try {
const upgradePauseMapString = this.latestUpgradeCR.metadata.annotations[HCI_ANNOTATIONS.NODE_UPGRADE_PAUSE_MAP] || '{}';
const upgradePauseMap = JSON.parse(upgradePauseMapString);
// update the upgrade CR annotation harvesterhci.io/node-upgrade-pause-map to unpause the node upgrade process
upgradePauseMap[`${ nodeName }`] = 'unpause';
this.latestUpgradeCR.setAnnotation(HCI_ANNOTATIONS.NODE_UPGRADE_PAUSE_MAP, JSON.stringify(upgradePauseMap));
await this.latestUpgradeCR.save();
} catch (e) {
console.error(`unable to update harvester upgrade CR annotations: ${ this.latestUpgradeCR.id }.`, e); // eslint-disable-line no-console
return false;
}
}
}
};
@@ -63,12 +97,28 @@ export default {
v-for="(item, i) in list"
:key="i"
>
<p>
{{ item.name }} <span
class="status"
:class="{ [item.state]: true }"
>{{ item.state }}</span>
</p>
<div class="upgrade-node-header">
<div class="upgrade-node-title">
<p>
{{ item.name }}
</p>
<span
class="status"
:class="{ [item.state]: true }"
>
{{ item.state }}
</span>
</div>
<button
v-if="showResumeButton && resumeUpgradePausedNodeEnabled && item.state === 'Node-upgrade paused'"
type="button"
class="btn bg-info btn-sm"
data-testid="add-item"
@click="resumeNodeUpgrade(item.name)"
>
{{ t('action.resume') }}
</button>
</div>
<PercentageBar
:model-value="item.percent"
preferred-direction="MORE"
@@ -102,10 +152,21 @@ export default {
}
}
.custom-content {
margin-bottom: 14px;
p {
.upgrade-node-title {
flex: 1 0 80%;
margin-right: 10px;
display: flex;
justify-content: space-between;
}
.upgrade-node-header {
display:flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
margin-bottom: 14px;
.status {
float: right;
}
@@ -117,6 +178,8 @@ export default {
}
.warning {
color: var(--error);
margin-bottom: 8px;
margin-top: 4px;
}
}
}
@@ -0,0 +1,104 @@
<script>
import UnitInput from '@shell/components/form/UnitInput';
import { Banner } from '@components/Banner';
export default {
name: 'HarvesterInstanceManagerResources',
components: {
UnitInput,
Banner,
},
props: {
value: {
type: Object,
default: () => ({
value: '',
default: '{}'
})
},
},
data() {
const resources = this.parseJSON(this.value?.value) || this.parseJSON(this.value?.default) || {};
return {
resources,
parseError: null,
};
},
methods: {
parseJSON(string) {
try {
return JSON.parse(string);
} catch (e) {
this.parseError = this.t('harvester.setting.instanceManagerResources.parseError', { error: e.message });
return null;
}
},
update() {
if (!this.value) return;
const cpu = { ...this.resources?.cpu };
if (cpu.v1 !== null && cpu.v1 !== undefined) cpu.v1 = String(cpu.v1);
if (cpu.v2 !== null && cpu.v2 !== undefined) cpu.v2 = String(cpu.v2);
this.value.value = JSON.stringify({ ...this.resources, cpu });
},
useDefault() {
if (this.value?.default) {
this.resources = this.parseJSON(this.value.default) || {};
this.update();
}
},
},
};
</script>
<template>
<div>
<Banner
v-if="parseError"
color="error"
>
{{ parseError }}
</Banner>
<div class="row">
<div class="col span-12">
<UnitInput
v-model:value="resources.cpu.v1"
:label="t('harvester.setting.instanceManagerResources.v1')"
suffix="%"
:delay="0"
type="number"
min="0"
max="100"
required
:mode="mode"
class="mb-20"
@update:value="update"
/>
<UnitInput
v-model:value="resources.cpu.v2"
:label="t('harvester.setting.instanceManagerResources.v2')"
suffix="%"
:delay="0"
type="number"
min="0"
max="100"
required
:mode="mode"
class="mb-20"
@update:value="update"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,371 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { RadioGroup } from '@components/Form/Radio';
import ArrayList from '@shell/components/form/ArrayList';
import { isValidCIDR } from '@shell/utils/validators/cidr';
import { _EDIT } from '@shell/config/query-params';
import { Banner } from '@components/Banner';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types';
import { NETWORK_TYPE } from '../../config/types';
const { L2VLAN, UNTAGGED } = NETWORK_TYPE;
const SHARE_STORAGE_NETWORK = 'share-storage-network';
const NETWORK = 'network';
const DEFAULT_DEDICATED_NETWORK = {
vlan: '',
clusterNetwork: '',
range: '',
exclude: [],
};
export default {
name: 'RwxNetworkSetting',
components: {
RadioGroup,
Banner,
ArrayList,
LabeledInput,
LabeledSelect,
},
props: {
registerBeforeHook: {
type: Function,
required: true,
},
mode: {
type: String,
default: _EDIT,
},
value: {
type: Object,
default: () => {
return {};
},
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
vlanStatus: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN_STATUS }),
});
},
data() {
let enabled = false; // enabled / disabled options
let shareStorageNetwork = false; // shareStorageNetwork / dedicatedRwxNetwork options
let dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
let networkType = L2VLAN;
let exclude = [];
try {
const parsedValue = JSON.parse(this.value.value || this.value.default || '{}');
const parsedNetwork = parsedValue?.[NETWORK] || parsedValue || {};
if (parsedValue && typeof parsedValue === 'object') {
shareStorageNetwork = !!parsedValue[SHARE_STORAGE_NETWORK];
networkType = 'vlan' in parsedNetwork ? L2VLAN : UNTAGGED;
dedicatedNetwork = {
vlan: parsedNetwork.vlan || '',
clusterNetwork: parsedNetwork.clusterNetwork || '',
range: parsedNetwork.range || '',
};
exclude = parsedNetwork?.exclude?.toString().split(',') || [];
enabled = shareStorageNetwork || !!(parsedNetwork.vlan || parsedNetwork.clusterNetwork || parsedNetwork.range);
}
} catch (error) {
enabled = false;
shareStorageNetwork = false;
dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
}
return {
enabled,
shareStorageNetwork,
dedicatedNetwork,
networkType,
exclude,
defaultAddValue: '',
};
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.willSave, 'willSave');
}
},
computed: {
showDedicatedNetworkConfig() {
return this.enabled && !this.shareStorageNetwork;
},
showVlan() {
return this.networkType === L2VLAN;
},
networkTypes() {
return [L2VLAN, UNTAGGED];
},
clusterNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
const clusterNetworksOptions = this.networkType === UNTAGGED ? clusterNetworks.filter((network) => network.id !== 'mgmt') : clusterNetworks;
return clusterNetworksOptions.map((network) => {
const disabled = !network.isReadyForStorageNetwork;
return {
label: disabled ? `${ network.id } (${ this.t('generic.notReady') })` : network.id,
value: network.id,
disabled,
};
});
},
},
methods: {
onUpdateEnabled() {
if (!this.enabled) {
this.shareStorageNetwork = false;
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
}
this.update();
},
onUpdateNetworkType() {
if (this.shareStorageNetwork) {
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
}
this.update();
},
onUpdateDedicatedType(neu) {
this.dedicatedNetwork.clusterNetwork = '';
if (neu === L2VLAN) {
this.dedicatedNetwork.vlan = '';
} else {
delete this.dedicatedNetwork.vlan;
}
this.update();
},
inputVlan(neu) {
if (neu === '') {
this.dedicatedNetwork.vlan = '';
this.update();
return;
}
const newValue = Number(neu);
if (newValue > 4094) {
this.dedicatedNetwork.vlan = 4094;
} else if (newValue < 1) {
this.dedicatedNetwork.vlan = 1;
} else {
this.dedicatedNetwork.vlan = newValue;
}
this.update();
},
useDefault() {
this.enabled = false;
this.shareStorageNetwork = false;
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
this.update();
},
update() {
const value = { [SHARE_STORAGE_NETWORK]: false };
if (this.enabled && this.shareStorageNetwork) {
value[SHARE_STORAGE_NETWORK] = true;
}
if (this.showDedicatedNetworkConfig) {
value[NETWORK] = {};
if (this.showVlan) {
value[NETWORK].vlan = this.dedicatedNetwork.vlan;
}
value[NETWORK].clusterNetwork = this.dedicatedNetwork.clusterNetwork;
value[NETWORK].range = this.dedicatedNetwork.range;
const excludeList = this.exclude.filter((ip) => ip);
if (Array.isArray(excludeList) && excludeList.length > 0) {
value[NETWORK].exclude = excludeList;
}
}
this.value.value = JSON.stringify(value);
},
willSave() {
this.update();
if (!this.showDedicatedNetworkConfig) {
return Promise.resolve();
}
const errors = [];
if (this.showVlan && !this.dedicatedNetwork.vlan) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.vlan') }, true));
}
if (!this.dedicatedNetwork.clusterNetwork) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.clusterNetwork') }, true));
}
if (!this.dedicatedNetwork.range) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.range.label') }, true));
} else if (!isValidCIDR(this.dedicatedNetwork.range)) {
errors.push(this.t('harvester.setting.storageNetwork.range.invalid', null, true));
}
if (this.exclude) {
const hasInvalidCIDR = this.exclude.find((cidr) => {
return cidr && !isValidCIDR(cidr);
});
if (hasInvalidCIDR) {
errors.push(this.t('harvester.setting.storageNetwork.exclude.invalid', null, true));
}
}
if (errors.length > 0) {
return Promise.reject(errors);
}
return Promise.resolve();
},
},
};
</script>
<template>
<div :class="mode">
<Banner color="warning">
<t
k="harvester.setting.rwxNetwork.warning"
:raw="true"
/>
</Banner>
<RadioGroup
v-model:value="enabled"
class="mb-20"
name="rwx-network-enable"
:options="[true,false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
@update:value="onUpdateEnabled"
/>
<RadioGroup
v-if="enabled"
v-model:value="shareStorageNetwork"
class="mb-20"
name="rwx-network-type"
:options="[true,false]"
:labels="[t('harvester.setting.rwxNetwork.shareStorageNetwork'), t('harvester.setting.rwxNetwork.dedicatedRwxNetwork')]"
@update:value="onUpdateNetworkType"
/>
<Banner
v-if="shareStorageNetwork"
class="mb-20"
color="warning"
>
<t
k="harvester.setting.rwxNetwork.shareStorageNetworkWarning"
:raw="true"
/>
</Banner>
<template v-if="showDedicatedNetworkConfig">
<LabeledSelect
v-model:value="networkType"
class="mb-20"
:options="networkTypes"
:mode="mode"
:label="t('harvester.fields.type')"
required
@update:value="onUpdateDedicatedType"
/>
<LabeledInput
v-if="showVlan"
v-model:value.number="dedicatedNetwork.vlan"
type="number"
class="mb-20"
:mode="mode"
required
placeholder="e.g. 1 - 4094"
label-key="harvester.setting.storageNetwork.vlan"
@update:value="inputVlan"
/>
<LabeledSelect
v-model:value="dedicatedNetwork.clusterNetwork"
label-key="harvester.setting.storageNetwork.clusterNetwork"
class="mb-20"
required
:options="clusterNetworkOptions"
@update:value="update"
/>
<LabeledInput
v-model:value="dedicatedNetwork.range"
class="mb-5"
:mode="mode"
required
:placeholder="t('harvester.setting.storageNetwork.range.placeholder')"
label-key="harvester.setting.storageNetwork.range.label"
@update:value="update"
/>
<ArrayList
v-model:value="exclude"
:show-header="true"
:default-add-value="defaultAddValue"
:mode="mode"
:add-label="t('harvester.setting.storageNetwork.exclude.addIp')"
class="mt-20"
@update:value="update"
>
<template #column-headers>
<div class="box mb-10">
<div class="key">
{{ t('harvester.setting.storageNetwork.exclude.label') }}
</div>
</div>
</template>
<template #columns="scope">
<div class="key">
<input
v-model="scope.row.value"
:placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')"
@update:value="update"
/>
</div>
</template>
</ArrayList>
</template>
</div>
</template>
@@ -4,6 +4,8 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RadioGroup } from '@components/Form/Radio';
import { mapGetters } from 'vuex';
import { allHash } from '@shell/utils/promise';
import { NODE } from '@shell/config/types';
export default {
name: 'HarvesterUpgradeConfig',
@@ -15,6 +17,13 @@ export default {
},
mixins: [CreateEditView],
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = { nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }) };
await allHash(hash);
},
data() {
let parseDefaultValue = {};
@@ -39,7 +48,25 @@ export default {
{ value: 'skip', label: 'skip' },
{ value: 'parallel', label: 'parallel' }
];
}
},
nodeUpgradeOptions() {
return [
{ value: 'auto', label: 'auto' },
{ value: 'manual', label: 'manual' }
];
},
nodesOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.map((node) => ({ value: node.id, label: node.name }));
},
showPauseNodes() {
return this.parseDefaultValue.nodeUpgradeOption?.strategy?.mode === 'manual';
},
resumeUpgradePausedNodeEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('resumeUpgradePausedNode');
},
},
created() {
@@ -48,6 +75,18 @@ export default {
methods: {
normalizeValue(obj) {
// handle nodeUpgradeOption.strategy
if (obj?.nodeUpgradeOption?.strategy?.mode === 'auto') {
delete obj.nodeUpgradeOption.strategy.pauseNodes;
}
if (obj?.nodeUpgradeOption?.strategy?.mode === 'manual') {
if (!Array.isArray(obj.nodeUpgradeOption.strategy.pauseNodes)) {
obj.nodeUpgradeOption.strategy.pauseNodes = this.nodesOptions.map((node) => node.value);
}
}
// handle imagePreloadOption.strategy
if (!obj.imagePreloadOption) {
obj.imagePreloadOption = { strategy: { type: 'sequential' } };
}
@@ -105,8 +144,8 @@ export default {
this.update();
},
deep: true
}
}
},
},
};
</script>
@@ -144,6 +183,28 @@ export default {
:labels="[t('generic.enabled'), t('generic.disabled')]"
@update:value="update"
/>
<div v-if="resumeUpgradePausedNodeEnabled">
<label class="mb-5"><b>{{ t('harvester.setting.upgrade.nodeUpgradeOption') }}</b></label>
<LabeledSelect
v-model:value="parseDefaultValue.nodeUpgradeOption.strategy.mode"
class="mb-20 label-select"
:mode="mode"
:label="t('harvester.setting.upgrade.strategy')"
:options="nodeUpgradeOptions"
@update:value="update"
/>
<LabeledSelect
v-if="showPauseNodes"
v-model:value="parseDefaultValue.nodeUpgradeOption.strategy.pauseNodes"
class="mb-20 label-select"
:clearable="true"
:multiple="true"
:mode="mode"
:label="t('harvester.setting.upgrade.pauseNodes')"
:options="nodesOptions"
@update:value="update"
/>
</div>
<div
v-if="errors.length"
class="error"
@@ -69,6 +69,15 @@ export default {
:mode="mode"
label-key="harvester.setting.vmForceDeletionPolicy.period"
/>
<LabeledInput
v-if="parseDefaultValue.enable"
v-model:value.number="parseDefaultValue.vmMigrationTimeout"
class="mb-20"
type="number"
:mode="mode"
label-key="harvester.setting.vmForceDeletionPolicy.vmMigrationTimeout"
/>
</div>
</div>
</template>
+10 -3
View File
@@ -54,11 +54,18 @@ const FEATURE_FLAGS = {
'lhV2VolExpansion',
'l2VlanTrunkMode',
'kubevirtMigration',
'hotplugNic'
'hotplugNic',
'resumeUpgradePausedNode',
],
'v1.7.1': [],
'v1.7.2': [
'clusterRegistrationTLSVerify'
'v1.8.0': [
'hotplugCdRom',
'supportBundleFileNameSetting',
'clusterRegistrationTLSVerify',
'vGPUAsPCIDevice',
'instanceManagerResourcesSetting',
'rwxNetworkSetting',
'createPVCWithDataVolume'
],
};
+150 -1
View File
@@ -35,8 +35,21 @@ import {
SNAPSHOT_TARGET_VOLUME,
IMAGE_VIRTUAL_SIZE,
IMAGE_STORAGE_CLASS,
HARVESTER_DESCRIPTION
HARVESTER_DESCRIPTION,
VM_IMPORT_SOURCE_VM,
VM_IMPORT_SOURCE_CLUSTER,
VM_IMPORT_STATUS,
VM_IMPORT_SOURCE_V_DC,
VM_IMPORT_SOURCE_V_ENDPOINT,
VM_IMPORT_SOURCE_V_STATUS,
VM_IMPORT_SOURCE_O_REGION,
VM_IMPORT_SOURCE_O_ENDPOINT,
VM_IMPORT_SOURCE_O_STATUS,
VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS,
} from './table-headers';
import { ADD_ONS } from './harvester-map';
import { registerAddonSideNav } from '../utils/dynamic-nav';
const TEMPLATE = HCI.VM_VERSION;
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
@@ -195,6 +208,142 @@ export function init($plugin, store) {
exact: false
});
// ===========================================================================
// VM Import Controller UI Flow
// ===========================================================================
// Define group (Hidden by default)
weightGroup('vmimport', 0, false);
// VirtualMachineImport
headers(HCI.VMIMPORT, [
STATE,
NAME_COL,
NAMESPACE_COL,
VM_IMPORT_SOURCE_VM,
VM_IMPORT_SOURCE_CLUSTER,
VM_IMPORT_STATUS,
AGE
]);
configureType(HCI.VMIMPORT, {
resource: HCI.VMIMPORT,
resourceDetail: HCI.VMIMPORT,
resourceEdit: HCI.VMIMPORT,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT,
labelKey: 'harvester.addons.vmImport.labels.vmimport',
group: 'vmimport',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT }
}
});
// Source: VMware
headers(HCI.VMIMPORT_SOURCE_V, [
STATE,
NAME_COL,
VM_IMPORT_SOURCE_V_ENDPOINT,
VM_IMPORT_SOURCE_V_DC,
VM_IMPORT_SOURCE_V_STATUS,
AGE
]);
configureType(HCI.VMIMPORT_SOURCE_V, {
resource: HCI.VMIMPORT_SOURCE_V,
resourceDetail: HCI.VMIMPORT_SOURCE_V,
resourceEdit: HCI.VMIMPORT_SOURCE_V,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT_SOURCE_V,
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare',
group: 'vmimport',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V }
}
});
// Source: OpenStack
headers(HCI.VMIMPORT_SOURCE_O, [
STATE,
NAME_COL,
VM_IMPORT_SOURCE_O_ENDPOINT,
VM_IMPORT_SOURCE_O_REGION,
VM_IMPORT_SOURCE_O_STATUS,
AGE
]);
configureType(HCI.VMIMPORT_SOURCE_O, {
resource: HCI.VMIMPORT_SOURCE_O,
resourceDetail: HCI.VMIMPORT_SOURCE_O,
resourceEdit: HCI.VMIMPORT_SOURCE_O,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT_SOURCE_O,
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack',
group: 'vmimport',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O }
}
});
// Source: OVA
headers(HCI.VMIMPORT_SOURCE_OVA, [
STATE,
NAME_COL,
VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS,
AGE
]);
configureType(HCI.VMIMPORT_SOURCE_OVA, {
resource: HCI.VMIMPORT_SOURCE_OVA,
resourceDetail: HCI.VMIMPORT_SOURCE_OVA,
resourceEdit: HCI.VMIMPORT_SOURCE_OVA,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT_SOURCE_OVA,
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA',
group: 'vmimport',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
}
});
// Enable SideNav based on Addon Status
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.VM_IMPORT_CONTROLLER,
resourceType: HCI.ADD_ONS,
navGroup: 'vmimport',
types: [
HCI.VMIMPORT_SOURCE_V,
HCI.VMIMPORT_SOURCE_O,
HCI.VMIMPORT_SOURCE_OVA,
HCI.VMIMPORT
]
});
// ===========================================================================
basicType([HCI.VOLUME]);
configureType(HCI.VOLUME, {
location: {
+6
View File
@@ -39,6 +39,12 @@ export const VOLUME_TYPE = [{
value: 'cd-rom'
}];
export const VOLUME_HOTPLUG_ACTION = {
INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE',
EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE',
DETACH_DISK: 'DETACH_DISK'
};
export const ACCESS_CREDENTIALS = {
RESET_PWD: 'userPassword',
INJECT_SSH: 'sshPublicKey'
@@ -28,6 +28,7 @@ export const HCI = {
NODE_ROLE_CONTROL_PLANE: 'node-role.kubernetes.io/control-plane',
NODE_ROLE_ETCD: 'node-role.harvesterhci.io/witness',
PROMOTE_STATUS: 'harvesterhci.io/promote-status',
CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status',
MIGRATION_STATE: 'harvesterhci.io/migrationState',
VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates',
IMAGE_NAME: 'harvesterhci.io/image-name',
@@ -78,4 +79,6 @@ export const HCI = {
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
};
+13 -1
View File
@@ -16,9 +16,11 @@ export const HCI_SETTING = {
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
@@ -38,7 +40,8 @@ export const HCI_SETTING = {
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration'
KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources'
};
export const HCI_ALLOWED_SETTINGS = {
@@ -71,11 +74,17 @@ export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.OVERCOMMIT_CONFIG]: { kind: 'json', from: 'import' },
[HCI_SETTING.SUPPORT_BUNDLE_TIMEOUT]: { kind: 'number' },
[HCI_SETTING.SUPPORT_BUNDLE_EXPIRATION]: { kind: 'number' },
[HCI_SETTING.SUPPORT_BUNDLE_FILE_NAME]: {
kind: 'string', canReset: true, featureFlag: 'supportBundleFileNameSetting'
},
[HCI_SETTING.SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT]: { kind: 'number', featureFlag: 'supportBundleNodeCollectionTimeoutSetting' },
[HCI_SETTING.SUPPORT_BUNDLE_IMAGE]: { kind: 'json', from: 'import' },
[HCI_SETTING.STORAGE_NETWORK]: {
kind: 'custom', from: 'import', canReset: true
},
[HCI_SETTING.RWX_NETWORK]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'rwxNetworkSetting'
},
[HCI_SETTING.VM_FORCE_RESET_POLICY]: { kind: 'json', from: 'import' },
[HCI_SETTING.SSL_CERTIFICATES]: { kind: 'json', from: 'import' },
[HCI_SETTING.SSL_PARAMETERS]: {
@@ -118,6 +127,9 @@ export const HCI_ALLOWED_SETTINGS = {
},
[HCI_SETTING.KUBEVIRT_MIGRATION]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'kubevirtMigration',
},
[HCI_SETTING.INSTANCE_MANAGER_RESOURCES]: {
kind: 'json', from: 'import', featureFlag: 'instanceManagerResourcesSetting'
}
};
+99
View File
@@ -131,3 +131,102 @@ export const PROVIDER = {
value: 'spec.provider',
align: 'left',
};
// Source VM column in migration.harvesterhci.io.virtualmachineimport list page
export const VM_IMPORT_SOURCE_VM = {
name: 'sourceVm',
labelKey: 'harvester.tableHeaders.vmImportSourceVm',
value: 'spec.virtualMachineName',
sort: 'spec.virtualMachineName',
align: 'left',
};
// Source Cluster column in migration.harvesterhci.io.virtualmachineimport list page
export const VM_IMPORT_SOURCE_CLUSTER = {
name: 'sourceCluster',
labelKey: 'harvester.tableHeaders.vmImportSourceCluster',
value: 'spec.sourceCluster.name',
sort: 'spec.sourceCluster.name',
align: 'left',
};
// Import Status column in migration.harvesterhci.io.virtualmachineimport list page
export const VM_IMPORT_STATUS = {
name: 'importStatus',
labelKey: 'harvester.tableHeaders.vmImportStatus',
value: 'status.importStatus',
sort: 'status.importStatus',
align: 'left',
};
// Datacenter column in migration.harvesterhci.io.vmwaresource list page
export const VM_IMPORT_SOURCE_V_DC = {
name: 'datacenter',
labelKey: 'harvester.tableHeaders.vmImportSourceVDatacenter',
value: 'spec.dc',
sort: 'spec.dc',
align: 'left',
};
// Endpoint column in migration.harvesterhci.io.vmwaresource list page
export const VM_IMPORT_SOURCE_V_ENDPOINT = {
name: 'endpoint',
labelKey: 'harvester.tableHeaders.vmImportSourceVEndpoint',
value: 'spec.endpoint',
sort: 'spec.endpoint',
align: 'left',
};
// Cluster Status column in migration.harvesterhci.io.vmwaresource list page
export const VM_IMPORT_SOURCE_V_STATUS = {
name: 'clusterStatus',
labelKey: 'harvester.tableHeaders.vmImportSourceVClusterStatus',
value: 'status.status',
sort: 'status.status',
align: 'left',
};
// Region column in migration.harvesterhci.io.openstacksource list page
export const VM_IMPORT_SOURCE_O_REGION = {
name: 'region',
labelKey: 'harvester.tableHeaders.vmImportSourceORegion',
value: 'spec.region',
sort: 'spec.region',
align: 'left',
};
// Endpoint column in migration.harvesterhci.io.openstacksource list page
export const VM_IMPORT_SOURCE_O_ENDPOINT = {
name: 'endpoint',
labelKey: 'harvester.tableHeaders.vmImportSourceOEndpoint',
value: 'spec.endpoint',
sort: 'spec.endpoint',
align: 'left',
};
// Cluster Status column in migration.harvesterhci.io.openstacksource list page
export const VM_IMPORT_SOURCE_O_STATUS = {
name: 'clusterStatus',
labelKey: 'harvester.tableHeaders.vmImportSourceOClusterStatus',
value: 'status.status',
sort: 'status.status',
align: 'left',
};
// URL column in migration.harvesterhci.io.ovasource list page
export const VM_IMPORT_SOURCE_OVA_URL = {
name: 'url',
labelKey: 'harvester.tableHeaders.vmImportSourceOVAUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
};
// Status column in migration.harvesterhci.io.ovasource list page
export const VM_IMPORT_SOURCE_OVA_STATUS = {
name: 'status',
labelKey: 'harvester.tableHeaders.vmImportSourceOVAStatus',
value: 'status.status',
sort: 'status.status',
align: 'left',
};
+17
View File
@@ -29,3 +29,20 @@ export const L2VLAN_MODE = {
ACCESS: 'access',
TRUNK: 'trunk',
};
export const VMIMPORT_SOURCE_PROVIDER = {
VMWARE: 'vmware',
OPENSTACK: 'openstack',
OVA: 'ova',
};
export const VMIMPORT_SOURCE_KINDS = {
VMWARE: 'VmwareSource',
OPENSTACK: 'OpenstackSource',
OVA: 'OvaSource',
};
export const CDI_POPULATOR_KIND = {
VOLUME_IMPORT_SOURCE: 'VolumeImportSource',
VOLUME_CLONE_SOURCE: 'VolumeCloneSource',
};
@@ -75,7 +75,7 @@ export default {
<div class="row">
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.cron')"
:name="t('harvester.schedule.cron.label')"
:value="cronExpression"
/>
</div>
File diff suppressed because it is too large Load Diff
+2 -10
View File
@@ -4,6 +4,7 @@ import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
import { getHarvesterUserName } from '../utils/auth';
export default {
name: 'HarvesterEnablePciPassthrough',
@@ -34,16 +35,7 @@ export default {
},
async save(buttonCb) {
// isSingleProduct == this is a standalone Harvester cluster
const isSingleProduct = this.$store.getters['isSingleProduct'];
let userName = 'admin';
// if this is imported Harvester, there may be users other than 'admin
if (!isSingleProduct) {
const user = this.$store.getters['auth/v3User'];
userName = user?.username || user?.id;
}
const userName = getHarvesterUserName(this.$store.getters);
for (let i = 0; i < this.resources.length; i++) {
const actionResource = this.resources[i];
+2 -10
View File
@@ -4,6 +4,7 @@ import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
import { getHarvesterUserName } from '../utils/auth';
export default {
name: 'HarvesterEnableUSBPassthrough',
@@ -34,16 +35,7 @@ export default {
},
async save(buttonCb) {
// isSingleProduct == this is a standalone Harvester cluster
const isSingleProduct = this.$store.getters['isSingleProduct'];
let userName = 'admin';
// if this is imported Harvester, there may be users other than 'admin
if (!isSingleProduct) {
const user = this.$store.getters['auth/v3User'];
userName = user?.username || user?.id;
}
const userName = getHarvesterUserName(this.$store.getters);
for (let i = 0; i < this.resources.length; i++) {
const actionResource = this.resources[i];
@@ -0,0 +1,183 @@
<script>
import { mapGetters } from 'vuex';
import { STORAGE_CLASS } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { isInternalStorageClass } from '../utils/storage-class';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'HarvesterDataMigrationDialog',
emits: ['close'],
components: {
AsyncButton, Banner, Card, LabeledInput, LabeledSelect
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
this.storageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
},
data() {
return {
targetVolumeName: '',
targetStorageClassName: '',
errors: [],
storageClasses: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0];
},
storageClassOptions() {
return sortBy(
this.storageClasses
.filter((sc) => !isInternalStorageClass(sc.metadata?.name))
.map((sc) => ({
label: sc.metadata?.name,
value: sc.metadata?.name
})),
'label'
);
},
disableSave() {
return !this.targetVolumeName || !this.targetStorageClassName;
},
},
methods: {
close() {
this.targetVolumeName = '';
this.targetStorageClassName = '';
this.errors = [];
this.$emit('close');
},
async apply(buttonDone) {
if (!this.actionResource) {
buttonDone(false);
return;
}
if (!this.targetVolumeName) {
const name = this.t('harvester.modal.dataMigration.fields.targetVolumeName.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
if (!this.targetStorageClassName) {
const name = this.t('harvester.modal.dataMigration.fields.targetStorageClassName.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
try {
await this.actionResource.doAction('dataMigration', {
targetVolumeName: this.targetVolumeName,
targetStorageClassName: this.targetStorageClassName
}, {}, false);
buttonDone(true);
this.close();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
},
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.dataMigration.title') }}
</template>
<template #body>
<LabeledInput
v-model:value="targetVolumeName"
:label="t('harvester.modal.dataMigration.fields.targetVolumeName.label')"
:placeholder="t('harvester.modal.dataMigration.fields.targetVolumeName.placeholder')"
class="mb-20"
required
/>
<LabeledSelect
v-model:value="targetStorageClassName"
:label="t('harvester.modal.dataMigration.fields.targetStorageClassName.label')"
:placeholder="t('harvester.modal.dataMigration.fields.targetStorageClassName.placeholder')"
:options="storageClassOptions"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</template>
<template
#actions
class="actions"
>
<div class="buttons">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="disableSave"
@click="apply"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
+30 -6
View File
@@ -5,6 +5,10 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
const VOLUME = 'volume';
const NETWORK = 'network';
const CDROM = 'cdrom';
export default {
name: 'HarvesterHotUnplug',
@@ -40,19 +44,37 @@ export default {
},
isVolume() {
return this.modalData.type === 'volume';
return this.modalData.type === VOLUME;
},
titleKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.title' : 'harvester.virtualMachine.hotUnplug.detachNIC.title';
const keys = {
[VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.title',
[CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.title',
[NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.title',
};
return keys[this.modalData.type];
},
actionLabelKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel' : 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabel';
const keys = {
[VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabels',
[CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.actionLabels',
[NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabels',
};
return keys[this.modalData.type];
},
successMessageKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.success' : 'harvester.virtualMachine.hotUnplug.detachNIC.success';
const keys = {
[VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.success',
[CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.success',
[NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.success',
};
return keys[this.modalData.type];
}
},
@@ -65,10 +87,12 @@ export default {
try {
let res;
if (this.isVolume) {
if (this.modalData.type === VOLUME) {
res = await this.actionResource.doAction('removeVolume', { diskName: this.name });
} else {
} else if (this.modalData.type === NETWORK) {
res = await this.actionResource.doAction('removeNic', { interfaceName: this.name });
} else {
res = await this.actionResource.doAction('ejectCdRomVolume', { deviceName: this.name });
}
if (res._status === 200 || res._status === 204) {
@@ -0,0 +1,198 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapState, mapGetters } from 'vuex';
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 { HCI } from '../types';
export default {
name: 'HarvesterInsertCdRomVolume',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
try {
this.images = await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE });
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.images = [];
}
},
data() {
return {
imageName: '',
images: [],
errors: [],
};
},
computed: {
...mapState('action-menu', ['modalData']),
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources?.[0];
},
isFormValid() {
return this.imageName !== '';
},
deviceName() {
return this.modalData.name;
},
imagesOption() {
return this.images
.filter((image) => {
const labels = image.metadata?.labels || {};
const type = labels[HCI_ANNOTATIONS.IMAGE_SUFFIX];
return type === 'iso';
})
.map((image) => {
return ({
label: this.imageOptionLabel(image),
value: image.id,
disabled: image.isImportedImage
});
});
}
},
methods: {
close() {
this.imageName = '';
this.errors = [];
this.$emit('close');
},
imageOptionLabel(image) {
return `${ image.metadata.namespace }/${ image.spec.displayName }`;
},
async save(buttonCb) {
if (!this.actionResource) {
buttonCb(false);
return;
}
const payload = {
deviceName: this.deviceName,
imageName: this.imageName
};
try {
const res = await this.actionResource.doAction('insertCdRomVolume', payload);
if ([200, 204].includes(res?._status)) {
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.insertCdRomVolume.success', {
deviceName: this.deviceName,
imageName: this.imageName,
})
}, { 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.insertCdRomVolume.title')"
class="text-default-text"
/>
</template>
<template #body>
<LabeledInput
v-model:value="deviceName"
:label="t('generic.name')"
disabled
/>
<LabeledSelect
v-model:value="imageName"
class="mt-20"
:label="t('harvester.modal.insertCdRomVolume.image')"
:options="imagesOption"
required
/>
<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>
@@ -0,0 +1,239 @@
<script>
import { mapGetters } from 'vuex';
import { PVC } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { HCI } from '../types';
import { parseVolumeClaimTemplates } from '@pkg/harvester/utils/vm';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'HarvesterStorageMigrationDialog',
emits: ['close'],
components: {
AsyncButton, Banner, Card, LabeledSelect
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
},
data() {
return {
sourceVolume: '',
targetVolume: '',
errors: [],
allPVCs: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0];
},
sourceVolumeOptions() {
const volumes = this.actionResource.spec?.template?.spec?.volumes || [];
return sortBy(
volumes
.map((v) => v.persistentVolumeClaim?.claimName)
.filter((name) => !!name)
.map((name) => ({
label: name,
value: name
})),
'label'
);
},
namespacePVCs() {
return this.allPVCs.filter((pvc) => pvc.metadata.namespace === this.actionResource.metadata.namespace);
},
vmUsedVolumeNames() {
const allVMs = this.$store.getters['harvester/all'](HCI.VM) || [];
const names = new Set();
allVMs.forEach((vm) => {
// Collect volume names from spec.template.spec.volumes (both PVC and DataVolume references)
const volumes = vm.spec?.template?.spec?.volumes || [];
volumes.forEach((v) => {
const name = v.persistentVolumeClaim?.claimName || v.dataVolume?.name;
if (name) {
names.add(`${ vm.metadata.namespace }/${ name }`);
}
});
// Collect volume names from volumeClaimTemplates annotation
const templates = parseVolumeClaimTemplates(vm);
templates.forEach((t) => {
if (t.metadata?.name) {
names.add(`${ vm.metadata.namespace }/${ t.metadata.name }`);
}
});
});
return names;
},
targetVolumeOptions() {
return sortBy(
this.namespacePVCs
.filter((pvc) => {
// Exclude volumes used by any VM (via spec.volumes or volumeClaimTemplates)
if (this.vmUsedVolumeNames.has(`${ pvc.metadata.namespace }/${ pvc.metadata.name }`)) {
return false;
}
return true;
})
.map((pvc) => ({
label: pvc.metadata.name,
value: pvc.metadata.name
})),
'label'
);
},
disableSave() {
return !this.sourceVolume || !this.targetVolume;
},
},
methods: {
close() {
this.sourceVolume = '';
this.targetVolume = '';
this.errors = [];
this.$emit('close');
},
async apply(buttonDone) {
if (!this.actionResource) {
buttonDone(false);
return;
}
if (!this.sourceVolume) {
const name = this.t('harvester.modal.storageMigration.fields.sourceVolume.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
if (!this.targetVolume) {
const name = this.t('harvester.modal.storageMigration.fields.targetVolume.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
try {
await this.actionResource.doAction('storageMigration', {
sourceVolume: this.sourceVolume,
targetVolume: this.targetVolume
}, {}, false);
buttonDone(true);
this.close();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
},
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.storageMigration.title') }}
</template>
<template #body>
<LabeledSelect
v-model:value="sourceVolume"
:label="t('harvester.modal.storageMigration.fields.sourceVolume.label')"
:placeholder="t('harvester.modal.storageMigration.fields.sourceVolume.placeholder')"
:options="sourceVolumeOptions"
class="mb-20"
required
/>
<LabeledSelect
v-model:value="targetVolume"
:label="t('harvester.modal.storageMigration.fields.targetVolume.label')"
:placeholder="t('harvester.modal.storageMigration.fields.targetVolume.placeholder')"
:options="targetVolumeOptions"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</template>
<template
#actions
class="actions"
>
<div class="buttons">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="disableSave"
@click="apply"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -277,8 +277,9 @@ export default {
:label="t('harvester.modal.bundle.namespaces.label')"
:clearable="true"
:multiple="true"
:append-to-body="false"
:options="namespaceOptions"
class="mb-10 label-select"
class="mb-10 namespace-select"
:tooltip="t('harvester.modal.bundle.namespaces.tooltip', _, true)"
@update:value="updateNamespaces"
/>
@@ -372,6 +373,11 @@ export default {
padding: 10px 0;
height: 160px;
}
.namespace-select {
:deep(.vs__dropdown-menu) {
max-height: 210px;
}
}
}
div {
@@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { RadioGroup } from '@components/Form/Radio';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
@@ -16,6 +17,7 @@ import { sortBy } from '@shell/utils/sort';
import { BACKUP_TYPE } from '../config/types';
import { _EDIT, _CREATE } from '@shell/config/query-params';
import { isBackupTargetSettingEmpty, isBackupTargetSettingUnavailable } from '../utils/setting';
import CronExpressionEditorModal from '@shell/components/Cron/CronExpressionEditorModal.vue';
export default {
name: 'CreateVMSchedule',
@@ -28,6 +30,7 @@ export default {
LabeledSelect,
MessageLink,
Banner,
CronExpressionEditorModal
},
mixins: [CreateEditView],
@@ -86,10 +89,12 @@ export default {
}
}
return { settings: [] };
return { settings: [], showModel: false };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
backupTargetResource() {
return this.settings.find( (O) => O.id === 'backup-target');
},
@@ -172,6 +177,9 @@ export default {
this.value.spec['maxFailure'] = this.value.spec.retain;
}
},
openModal() {
this.showModel = true;
},
},
};
</script>
@@ -256,16 +264,28 @@ export default {
:weight="99"
class="bordered-table"
>
<LabeledInput
v-model:value="value.spec.cron"
class="mb-30"
type="cron"
required
:mode="mode"
:label="t('harvester.schedule.cron')"
placeholder="0 * * * *"
:disabled="isBackupTargetUnAvailable || isView"
/>
<div class="cronEditor">
<LabeledInput
v-model:value="value.spec.cron"
class="mb-30"
type="cron"
required
:mode="mode"
:label="t('harvester.schedule.cron.label')"
placeholder="0 * * * *"
:disabled="isBackupTargetUnAvailable || isView"
/>
<button
class="editCronBtn btn role-primary"
@click="openModal"
>
{{ t('harvester.schedule.cron.editButton') }}
</button>
<CronExpressionEditorModal
v-model:show="showModel"
v-model:cron-expression="value.spec.cron"
/>
</div>
<LabeledInput
v-model:value.number="value.spec.retain"
class="mb-30"
@@ -292,3 +312,16 @@ export default {
</Tabbed>
</CruResource>
</template>
<style lang="scss" scoped>
.cronEditor {
align-items: center;
display: flex;
}
.editCronBtn {
margin-bottom: 30px;
margin-left: 10px;
height: 60px;
}
</style>
@@ -6,6 +6,7 @@ import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { set } from '@shell/utils/object';
import { Banner } from '@components/Banner';
import KeyValue from '@shell/components/form/KeyValue';
import NodeScheduling from '@shell/components/form/NodeScheduling';
@@ -22,6 +23,7 @@ import Reserved from './kubevirt.io.virtualmachine/VirtualMachineReserved';
import Volume from './kubevirt.io.virtualmachine/VirtualMachineVolume';
import Network from './kubevirt.io.virtualmachine/VirtualMachineNetwork';
import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
@@ -38,6 +40,7 @@ export default {
Network,
Checkbox,
CpuMemory,
CpuModel,
CruResource,
CloudConfig,
LabeledSelect,
@@ -70,12 +73,12 @@ export default {
return {
templateId,
templateValue: null,
templateSpec: null,
versionName: '',
description: '',
defaultVersion: null,
isDefaultVersion: false,
templateValue: null,
templateSpec: null,
versionName: '',
description: '',
defaultVersion: null,
isDefaultVersion: false,
};
},
@@ -154,6 +157,18 @@ export default {
},
methods: {
updateCpuModel(value) {
if (!this.spec?.template?.spec?.domain?.cpu) {
set(this.spec, 'template.spec.domain.cpu', {});
}
if (value && value !== '') {
set(this.spec.template.spec.domain.cpu, 'model', value);
} else {
delete this.spec.template.spec.domain.cpu.model;
}
},
async saveVMT(buttonCb) {
this.parseVM();
@@ -436,6 +451,17 @@ export default {
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<CpuModel
:value="spec.template.spec.domain.cpu?.model || ''"
:mode="mode"
@update:value="updateCpuModel"
/>
</div>
</div>
<div class="row mb-20">
<a
v-if="showAdvanced"
+135 -17
View File
@@ -9,8 +9,11 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions';
import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import jsyaml from 'js-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { allHash } from '@shell/utils/promise';
import { get } from '@shell/utils/object';
import { clone, get } from '@shell/utils/object';
import { STORAGE_CLASS, LONGHORN, PV } from '@shell/config/types';
import { sortBy } from '@shell/utils/sort';
import { saferDump } from '@shell/utils/create-yaml';
@@ -33,6 +36,7 @@ export default {
components: {
Banner,
Checkbox,
Tab,
UnitInput,
CruResource,
@@ -90,14 +94,34 @@ export default {
source,
storage,
imageId,
snapshots: [],
images: [],
showAdvanced: false,
createWithDataVolume: false,
snapshots: [],
images: [],
GIBIBYTE
};
},
created() {
this.registerBeforeHook(this.willSave, 'willSave');
if (this.mode === _CREATE) {
const originalSaveYaml = this.value.saveYaml?.bind(this.value);
this.value.saveYaml = async(yaml) => {
if (this.createWithDataVolume && this.isBlank) {
const parsed = jsyaml.load(yaml);
const dvObj = { ...parsed, type: 'cdi.kubevirt.io.datavolume' };
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
await dataVolume.save();
return dataVolume;
}
return originalSaveYaml(yaml);
};
}
},
computed: {
@@ -135,6 +159,10 @@ export default {
return Object.values(VOLUME_MODE);
},
accessModeOptions() {
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
},
imageOption() {
return sortBy(
this.images
@@ -275,6 +303,10 @@ export default {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
},
isCreatePVCWithDataVolumeFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
},
isResizeDisabled() {
return (
!this.isLHV2VolExpansionFeatureEnabled &&
@@ -341,6 +373,58 @@ export default {
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
},
buildDataVolumeObj() {
const storage = {
storageClassName: this.value.spec.storageClassName,
resources: { requests: { storage: this.storage } },
};
if (this.showAdvanced && this.value.spec.accessModes?.length > 0) {
storage.accessModes = this.value.spec.accessModes;
}
if (this.showAdvanced && this.value.spec.volumeMode) {
storage.volumeMode = this.value.spec.volumeMode;
}
return {
type: 'cdi.kubevirt.io.datavolume',
apiVersion: 'cdi.kubevirt.io/v1beta1',
kind: 'DataVolume',
metadata: {
name: this.value.metadata.name,
namespace: this.value.metadata.namespace,
annotations: this.value.metadata.annotations || {},
labels: this.value.metadata.labels || {},
},
spec: {
source: { blank: {} },
storage,
}
};
},
async save(buttonDone) {
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
try {
this.update();
const dvObj = this.buildDataVolumeObj();
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
await dataVolume.save();
buttonDone(true);
this.done();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
} else {
await CreateEditView.methods.save.call(this, buttonDone);
}
},
willSave() {
this.update();
},
@@ -383,9 +467,17 @@ export default {
this.update();
},
generateYaml() {
const out = saferDump(this.value);
this.update();
return out;
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
return saferDump(this.buildDataVolumeObj());
}
const plain = clone(this.value);
delete plain.saveYaml;
return saferDump(plain);
},
}
};
@@ -458,18 +550,6 @@ export default {
@update:value="update"
/>
<LabeledSelect
v-if="showVolumeMode"
v-model:value="value.spec.volumeMode"
:label="t('harvester.volume.volumeMode')"
:options="volumeModeOptions"
required
:disabled="!isCreate"
:mode="mode"
class="mb-20"
@update:value="update"
/>
<UnitInput
v-model:value="storage"
:label="t('harvester.volume.size')"
@@ -490,6 +570,44 @@ export default {
>
<span>{{ t('harvester.volume.longhorn.disableResize') }}</span>
</Banner>
<div class="row mb-20">
<Checkbox
v-if="isCreate && isBlank && isCreatePVCWithDataVolumeFeatureEnabled"
v-model:value="createWithDataVolume"
:label="t('harvester.volume.createWithDataVolume')"
tooltip-key="harvester.volume.createWithDataVolumeTooltip"
/>
</div>
<a
v-if="isCreate && isCreatePVCWithDataVolumeFeatureEnabled"
role="button"
class="hand"
@click="showAdvanced = !showAdvanced"
>
{{ showAdvanced ? t('harvester.volume.hideAdvanced') : t('harvester.volume.showAdvanced') }}
</a>
<LabeledSelect
v-if="showAdvanced"
v-model:value="value.spec.accessModes"
:label="t('harvester.volume.accessModes')"
:options="accessModeOptions"
:multiple="true"
:mode="mode"
class="mb-20 mt-20"
@update:value="update"
/>
<LabeledSelect
v-if="showAdvanced"
v-model:value="value.spec.volumeMode"
:label="t('harvester.volume.volumeMode')"
:options="volumeModeOptions"
:mode="mode"
class="mb-20"
@update:value="update"
/>
</Tab>
<Tab
v-if="!isCreate"
@@ -0,0 +1,133 @@
<script>
import YAML from 'yaml';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { CONFIG_MAP } from '@shell/config/types';
import { Banner } from '@components/Banner';
const CPU_MODEL_CONFIG_MAP_ID = 'harvester-system/node-cpu-model-configuration';
export default {
name: 'HarvesterCpuModel',
emits: ['update:value'],
components: {
LabeledSelect,
Banner
},
props: {
value: {
type: String,
default: ''
},
mode: {
type: String,
default: 'create',
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
await this.$store.dispatch(`${ inStore }/find`, { type: CONFIG_MAP, id: CPU_MODEL_CONFIG_MAP_ID });
this.fetchError = null;
} catch (e) {
this.fetchError = this.t('harvester.virtualMachine.cpuModel.fetchError', { error: e.message || e });
}
},
data() {
return { fetchError: null };
},
computed: {
localValue: {
get() {
return this.value ?? '';
},
set(val) {
this.$emit('update:value', val ?? '');
}
},
cpuModelConfigMap() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/byId`](
CONFIG_MAP,
CPU_MODEL_CONFIG_MAP_ID
);
},
cpuModelOptions() {
if (!this.cpuModelConfigMap?.data?.cpuModels) {
return [{ label: this.t('generic.default'), value: '' }];
}
let cpuModelsData;
try {
cpuModelsData = YAML.parse(this.cpuModelConfigMap.data?.cpuModels || '');
} catch (e) {
return [{ label: this.t('generic.default'), value: '' }];
}
const options = [];
options.push({
label: this.t('generic.default'),
value: ''
});
// Add global models (host-model, host-passthrough)
const globalModels = cpuModelsData.globalModels || [];
globalModels.forEach((modelName) => {
options.push({
label: modelName,
value: modelName
});
});
// Add regular models with node count
const modelEntries = Object.entries(cpuModelsData.models || {});
// Sort models alphabetically for consistent display
modelEntries.sort((a, b) => a[0].localeCompare(b[0]));
modelEntries.forEach(([modelName, modelInfo]) => {
const readyCount = modelInfo.readyCount || 0;
const label = this.t('harvester.virtualMachine.cpuModel.optionLabel', { modelName, count: readyCount });
options.push({
label,
value: modelName
});
});
return options;
},
},
};
</script>
<template>
<div>
<Banner
v-if="fetchError"
color="error"
class="mb-20"
>
{{ fetchError }}
</Banner>
<LabeledSelect
v-model:value="localValue"
:label="t('harvester.virtualMachine.cpuModel.label')"
:options="cpuModelOptions"
:mode="mode"
:disabled="!!fetchError"
/>
</div>
</template>
@@ -31,6 +31,7 @@ export default {
const _hash = {
pciclaims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.PCI_CLAIM }),
sriovs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOV }),
srigpuovs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SR_IOVGPU_DEVICE }),
};
await allHash(_hash);
@@ -106,19 +107,32 @@ export default {
},
computed: {
parentSriovOptions() {
allSriovs() {
const inStore = this.$store.getters['currentProduct'].inStore;
const allSriovs = this.$store.getters[`${ inStore }/all`](HCI.SR_IOV) || [];
return allSriovs.map((sriov) => {
return sriov.id;
});
return this.$store.getters[`${ inStore }/all`](HCI.SR_IOV) || [];
},
allSriovGPUs() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/all`](HCI.SR_IOVGPU_DEVICE) || [];
},
parentSriovOptions() {
return this.allSriovs.map((sriov) => sriov.id);
},
parentSriovGPUOptions() {
return this.allSriovGPUs.map((sriovgpu) => sriovgpu.id);
},
parentSriovLabel() {
return HCI_ANNOTATIONS.PARENT_SRIOV;
}
},
parentSriovGPULabel() {
return HCI_ANNOTATIONS.PARENT_SRIOV_GPU;
},
vGPUAsPCIDeviceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('vGPUAsPCIDevice');
},
},
methods: {
enableGroup(rows = []) {
const row = rows[0];
@@ -206,6 +220,15 @@ export default {
:rows="rows"
@change-rows="changeRows"
/>
<FilterBySriov
v-if="vGPUAsPCIDeviceEnabled"
ref="filterByParentSRIOVGPU"
:parent-sriov-options="parentSriovGPUOptions"
:parent-sriov-label="parentSriovGPULabel"
:label="t('harvester.sriov.parentSriovGPU')"
:rows="rows"
@change-rows="changeRows"
/>
</template>
</ResourceTable>
</template>
@@ -8,6 +8,7 @@ import { set } from '@shell/utils/object';
import { HCI } from '../../../types';
import DeviceList from './DeviceList';
import CompatibilityMatrix from '../CompatibilityMatrix';
import MessageLink from '@shell/components/MessageLink';
export default {
name: 'VirtualMachinePCIDevices',
@@ -15,7 +16,8 @@ export default {
LabeledSelect,
DeviceList,
CompatibilityMatrix,
Banner
Banner,
MessageLink
},
props: {
mode: {
@@ -54,6 +56,11 @@ export default {
const vmDevices = this.value?.domain?.devices?.hostDevices || [];
const otherDevices = this.otherDevices(vmDevices).map(({ name }) => name);
const vmDeviceNames = vmDevices.map(({ name }) => name);
this.pciDevices.forEach((row) => {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
});
vmDevices.forEach(({ name, deviceName }) => {
const checkName = (deviceName || '').split('/')?.[1];
@@ -133,6 +140,13 @@ export default {
return inUse;
},
toVGpuDevicesPage() {
return {
name: 'harvester-c-cluster-resource',
params: { cluster: this.$store.getters['clusterId'], resource: HCI.VGPU_DEVICE },
};
},
devicesByNode() {
return this.enabledDevices?.reduce((acc, device) => {
const nodeName = device.status?.nodeName;
@@ -227,7 +241,12 @@ export default {
<div class="row">
<div class="col span-12">
<Banner color="info">
<t k="harvester.pci.howToUseDevice" />
<MessageLink
:to="toVGpuDevicesPage"
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
/>
</Banner>
<Banner
v-if="selectedDevices.length > 0"
@@ -48,6 +48,13 @@ export default {
this[key] = res[key];
}
const vmDevices = this.value?.domain?.devices?.hostDevices || [];
const vmDeviceNames = vmDevices.map(({ name }) => name);
this.devices.forEach((row) => {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
});
this.selectedDevices = (this.value?.domain?.devices?.hostDevices || [])
.map(({ name }) => name)
.filter((name) => this.enabledDevices.find((device) => device?.metadata?.name === name));
@@ -46,6 +46,13 @@ export default {
this[key] = res[key];
}
const vmDevices = this.value?.domain?.devices?.gpus || [];
const vmDeviceNames = vmDevices.map(({ name }) => name);
this.devices.forEach((row) => {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
});
const vGpus = this.vm.isOff ? [
...(this.value?.domain?.devices?.gpus || []).map(({ name }) => name),
] : [
@@ -13,11 +13,12 @@ import { ucFirst, randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array';
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { SOURCE_TYPE } from '../../../config/harvester-map';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm';
export default {
emits: ['update:value'],
@@ -117,6 +118,10 @@ export default {
return this.mode === _CREATE;
},
isHotplugCdRomFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('hotplugCdRom');
},
defaultStorageClass() {
const defaultStorage = this.$store.getters['harvester/all'](STORAGE_CLASS).find((sc) => sc.isDefault);
@@ -146,7 +151,7 @@ export default {
value: {
handler(neu) {
const rows = clone(neu).map((V) => {
if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId) {
if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId && V.image !== EMPTY_IMAGE) {
V.to = {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`,
params: {
@@ -217,8 +222,48 @@ export default {
}
},
unplugVolume(volume) {
this.vm.unplugVolume(volume.name);
canDoVolumeHotplugAction(volume) {
if (!this.isHotplugCdRomFeatureEnabled && volume.type === 'cd-rom') {
return false;
}
if (volume.hotpluggable) {
return true;
}
return volume.type === 'cd-rom' && volume.bus === 'sata' && volume.image === EMPTY_IMAGE;
},
getVolumeHotplugAction(volume) {
if (volume.type === 'cd-rom' && volume.bus === 'sata') {
if (volume.image === EMPTY_IMAGE) {
return VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE;
}
return VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE;
}
return VOLUME_HOTPLUG_ACTION.DETACH_DISK;
},
getVolumeHotplugActionLabel(volume) {
const labels = {
[VOLUME_HOTPLUG_ACTION.DETACH_DISK]: 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel',
[VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE]: 'harvester.modal.insertCdRomVolume.actionLabel',
[VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.actionLabel',
};
return labels[this.getVolumeHotplugAction(volume)];
},
hotplugVolume(volume) {
const calls = {
[VOLUME_HOTPLUG_ACTION.DETACH_DISK]: () => this.vm.unplugVolume(volume.name),
[VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE]: () => this.vm.insertCdRomVolume(volume.name),
[VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE]: () => this.vm.ejectCdRomVolume(volume.name),
};
return calls[this.getVolumeHotplugAction(volume)]();
},
componentFor(type) {
@@ -346,12 +391,12 @@ export default {
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
v-if="canDoVolumeHotplugAction(volume) && isView"
type="button"
class="role-link btn btn-sm remove"
@click="unplugVolume(volume)"
@click="hotplugVolume(volume)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachVolume.actionLabel') }}
{{ t(getVolumeHotplugActionLabel(volume)) }}
</button>
</div>
<div>
@@ -14,6 +14,7 @@ import { _VIEW } from '@shell/config/query-params';
import LabelValue from '@shell/components/LabelValue';
import { ucFirst } from '@shell/utils/string';
import { GIBIBYTE } from '../../../../utils/unit';
import { EMPTY_IMAGE } from '../../../../utils/vm';
export default {
name: 'HarvesterEditVMImage',
@@ -96,8 +97,20 @@ export default {
return this.mode === _VIEW;
},
isExistingCdrom() {
return this.value.type === 'cd-rom' && !this.value.newCreateId;
},
isEmptyImage() {
return this.value.image === EMPTY_IMAGE;
},
isHotplugCdRomFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('hotplugCdRom');
},
imagesOption() {
return this.images
const images = this.images
.filter((image) => {
if (!image.isReady) return false;
@@ -114,6 +127,19 @@ export default {
value: image.id,
disabled: image.isImportedImage
}));
const options = [];
if (this.isHotplugCdRomFeatureEnabled) {
options.push({
label: this.t('harvester.virtualMachine.volume.emptyImage'),
value: EMPTY_IMAGE,
disabled: false
});
}
options.push(...images);
return options;
},
imageName() {
@@ -179,6 +205,7 @@ export default {
'value.type'(neu) {
if (neu === 'cd-rom') {
this.value['bus'] = 'sata';
this.updateHotpluggable();
this.update();
}
},
@@ -221,12 +248,48 @@ export default {
return label;
},
update() {
this.value.hasDiskError = this.showDiskTooSmallError;
this.$emit('update');
},
updateHotpluggable() {
if (this.value.type !== 'cd-rom') {
this.value['hotpluggable'] = false;
} else {
this.value['hotpluggable'] = (this.value.bus === 'sata');
}
},
onTypeChange() {
if (this.value.image === EMPTY_IMAGE && this.value.type !== 'cd-rom') {
this.value['image'] = '';
}
this.updateHotpluggable();
this.update();
},
onBusChange() {
if (this.value.image === EMPTY_IMAGE && this.value.bus !== 'sata') {
this.value['image'] = '';
}
this.updateHotpluggable();
this.update();
},
onImageChange() {
if (this.value.image === EMPTY_IMAGE) {
this.value['type'] = 'cd-rom';
this.value['bus'] = 'sata';
this.value['size'] = `0${ GIBIBYTE }`;
this.update();
return;
}
const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE)?.find( (I) => this.value.image === I.id);
const isIsoImage = /iso$/i.test(imageResource?.imageSuffix);
const imageSize = Math.max(imageResource?.status?.size, imageResource?.status?.virtualSize);
@@ -239,6 +302,8 @@ export default {
this.value['bus'] = 'virtio';
}
this.updateHotpluggable();
if (imageSize) {
let imageSizeGiB = Math.ceil(imageSize / 1024 / 1024 / 1024);
@@ -256,6 +321,10 @@ export default {
},
checkImageExists(imageId) {
if (imageId === EMPTY_IMAGE) {
return;
}
if (!!imageId && this.imagesOption.length > 0 && !findBy(this.imagesOption, 'value', imageId)) {
this.$store.dispatch('growl/error', {
title: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.title', { name: imageId }),
@@ -283,6 +352,7 @@ export default {
>
<LabeledInput
v-model:value="value.name"
:disabled="!isCreate && isExistingCdrom"
:label="t('harvester.fields.name')"
required
:mode="mode"
@@ -302,10 +372,11 @@ export default {
>
<LabeledSelect
v-model:value="value.type"
:disabled="!isCreate && isExistingCdrom"
:label="t('harvester.fields.type')"
:options="VOLUME_TYPE"
:mode="mode"
@update:value="update"
@update:value="onTypeChange"
/>
</InputOrDisplay>
</div>
@@ -323,7 +394,7 @@ export default {
>
<LabeledSelect
v-model:value="value.image"
:disabled="idx === 0 && !isCreate && !value.newCreateId && isVirtualType"
:disabled="(idx === 0 || isExistingCdrom) && (!isCreate && !value.newCreateId && isVirtualType)"
:label="t('harvester.fields.image')"
:options="imagesOption"
:mode="mode"
@@ -351,7 +422,7 @@ export default {
:label="t('harvester.fields.size')"
:mode="mode"
:required="validateRequired"
:disabled="isResizeDisabled"
:disabled="isResizeDisabled || isEmptyImage || (!isCreate && isExistingCdrom)"
:suffix="GIBIBYTE"
@update:value="update"
/>
@@ -374,7 +445,8 @@ export default {
:label="t('harvester.virtualMachine.volume.bus')"
:mode="mode"
:options="InterfaceOption"
@update:value="update"
:disabled="!isCreate && isExistingCdrom"
@update:value="onBusChange"
/>
</InputOrDisplay>
</div>
@@ -2,7 +2,7 @@
import { isEqual } from 'lodash';
import { mapGetters } from 'vuex';
import Tabbed from '@shell/components/Tabbed';
import { clone } from '@shell/utils/object';
import { clone, set } from '@shell/utils/object';
import Tab from '@shell/components/Tabbed/Tab';
import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource';
@@ -32,6 +32,7 @@ import PciDevices from './VirtualMachinePciDevices/index';
import AccessCredentials from './VirtualMachineAccessCredentials';
import CloudConfig from './VirtualMachineCloudConfig';
import CpuMemory from './VirtualMachineCpuMemory';
import CpuModel from './VirtualMachineCpuModel';
import Network from './VirtualMachineNetwork';
import Volume from './VirtualMachineVolume';
import SSHKey from './VirtualMachineSSHKey';
@@ -57,6 +58,7 @@ export default {
SSHKey,
Network,
CpuMemory,
CpuModel,
CloudConfig,
NodeScheduling,
PodAffinity,
@@ -209,6 +211,10 @@ export default {
return false;
},
vGPUAsPCIDeviceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('vGPUAsPCIDevice');
},
usbPassthroughEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough');
},
@@ -538,6 +544,18 @@ export default {
return out;
},
updateCpuModel(value) {
if (!this.spec?.template?.spec?.domain?.cpu) {
set(this.spec, 'template.spec.domain.cpu', {});
}
if (value && value !== '') {
set(this.spec.template.spec.domain.cpu, 'model', value);
} else {
delete this.spec.template.spec.domain.cpu.model;
}
},
},
};
</script>
@@ -726,7 +744,7 @@ export default {
</Tab>
<Tab
v-if="enabledSriovgpu"
v-if="enabledSriovgpu && !vGPUAsPCIDeviceEnabled"
:label="t('harvester.tab.vGpuDevices')"
name="vGpuDevices"
:weight="-6"
@@ -870,6 +888,16 @@ export default {
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<CpuModel
v-model:value="cpuModel"
:mode="mode"
@update:value="updateCpuModel"
/>
</div>
</div>
<div class="row mb-20">
<a
v-if="showAdvanced"
@@ -0,0 +1,357 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import UnitInput from '@shell/components/form/UnitInput';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
export default {
name: 'EditOpenstackSource',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
UnitInput
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) this.value.spec = {};
if (!this.value.spec.credentials) this.value.spec.credentials = {};
const initialMode = this.value.spec.credentials.name ? 'existing' : 'new';
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newProjectName: '',
newDomainName: '',
newCaCert: '',
// Rules for fields that exist in the value object (Model)
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.endpoint', rules: ['endpointRequired'] },
{ path: 'spec.region', rules: ['regionRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
authModeOptions() {
return [
{ label: this.t('harvester.addons.vmImport.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.vmImport.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
// Define custom rules for the FormValidation mixin
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
endpointRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.endpoint') }) : undefined,
regionRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.region') }) : undefined,
};
},
// Combine mixin validation + conditional manual checks
isFormValid() {
// Check static fields via Mixin
if (!this.fvFormIsValid) {
return false;
}
// Check conditional fields
if (this.authMode === 'new') {
if (!this.newUsername || !this.newPassword) return false;
if (!this.newProjectName || !this.newDomainName) return false;
} else {
if (!this.value.spec.credentials.name) return false;
}
return true;
}
},
methods: {
usernameRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.username') }) : undefined;
},
passwordRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.password') }) : undefined;
},
projectRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.projectName') }) : undefined;
},
domainRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.openstack.fields.domainName') }) : undefined;
},
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.selectSecret') }) : undefined;
},
async saveSource(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
username: btoa(this.newUsername),
password: btoa(this.newPassword),
project_name: btoa(this.newProjectName),
domain_name: btoa(this.newDomainName),
ca_cert: this.newCaCert ? btoa(this.newCaCert) : undefined
};
await newSecret.save();
this.value.spec.credentials = {
name: secretName,
namespace
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveSource"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.endpoint"
:label="t('harvester.addons.vmImport.openstack.fields.endpoint')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.endpoint')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.endpoint')"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.region"
:label="t('harvester.addons.vmImport.openstack.fields.region')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.region')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.region')"
required
/>
</div>
</div>
</Tab>
<Tab
name="auth"
:label="t('harvester.addons.vmImport.titles.auth')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.vmImport.fields.username')"
:mode="mode"
:rules="[usernameRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.vmImport.fields.password')"
:mode="mode"
:rules="[passwordRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newProjectName"
:label="t('harvester.addons.vmImport.openstack.fields.projectName')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.projectName')"
:mode="mode"
:rules="[projectRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newDomainName"
:label="t('harvester.addons.vmImport.openstack.fields.domainName')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.domainName')"
:mode="mode"
:rules="[domainRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.vmImport.fields.caCert')"
:placeholder="t('harvester.addons.vmImport.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.credentials.name"
:options="secretOptions"
:label="t('harvester.addons.vmImport.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
<Tab
name="advanced"
:label="t('harvester.addons.vmImport.titles.advanced')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-6">
<UnitInput
v-model:value="value.spec.uploadImageRetryCount"
:label="t('harvester.addons.vmImport.openstack.fields.retryCount')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.retryCount')"
suffix="Times"
:mode="mode"
/>
</div>
<div class="col span-6">
<UnitInput
v-model:value="value.spec.uploadImageRetryDelay"
:label="t('harvester.addons.vmImport.openstack.fields.retryDelay')"
:placeholder="t('harvester.addons.vmImport.openstack.placeholders.retryDelay')"
suffix="Seconds"
:mode="mode"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -0,0 +1,322 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import UnitInput from '@shell/components/form/UnitInput';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
export default {
name: 'EditOvaSource',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
UnitInput
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) this.value.spec = {};
// Auth is optional for OVA (public URLs).
// If credentials.name exists -> Existing.
// If not -> None (default).
let initialMode = 'none';
if (this.value.spec.credentials?.name) {
initialMode = 'existing';
}
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newCaCert: '', // Key will be "ca.crt"
// Validation Rules for static fields
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.url', rules: ['urlRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
authModeOptions() {
return [
{ label: this.t('harvester.addons.vmImport.fields.none'), value: 'none' },
{ label: this.t('harvester.addons.vmImport.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.vmImport.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
// Define custom rules for the FormValidation mixin
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
urlRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.ova.fields.url') }) : undefined,
};
},
isFormValid() {
if (!this.fvFormIsValid) {
return false;
}
if (this.authMode === 'new') {
// At least a username/password OR a CA cert to be provided.
// If the user selected "Create New", they likely intend to enter something.
if (!this.newUsername && !this.newPassword && !this.newCaCert) return false;
} else if (this.authMode === 'existing') {
if (!this.value.spec.credentials?.name) return false;
}
return true;
}
},
watch: {
authMode(newMode) {
if (newMode === 'existing') {
// Bind to value.spec.credentials.name for existing credential
// Ensure 'credentials' object exists first when selected
if (!this.value.spec.credentials) {
this.value.spec.credentials = {
name: '',
namespace: this.value.metadata.namespace || 'default'
};
}
}
}
},
methods: {
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.selectSecret') }) : undefined;
},
async saveSource(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
if (this.authMode === 'none') {
// Clear any credential reference
delete this.value.spec.credentials;
} else if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
// Optional fields logic
username: this.newUsername ? btoa(this.newUsername) : undefined,
password: this.newPassword ? btoa(this.newPassword) : undefined,
// vm-import-controller code specifies "ca.crt" with a dot.
'ca.crt': this.newCaCert ? btoa(this.newCaCert) : undefined
};
await newSecret.save();
this.value.spec.credentials = {
name: secretName,
namespace
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveSource"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="value.spec.url"
:label="t('harvester.addons.vmImport.ova.fields.url')"
:placeholder="t('harvester.addons.vmImport.ova.placeholders.url')"
tooltip="Supports HTTP and HTTPS protocols."
:mode="mode"
:rules="fvGetAndReportPathRules('spec.url')"
required
/>
</div>
</div>
</Tab>
<Tab
name="auth"
:label="t('harvester.addons.vmImport.titles.auth')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.vmImport.fields.username')"
placeholder="(Optional)"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.vmImport.fields.password')"
placeholder="(Optional)"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.vmImport.fields.caCert')"
:placeholder="t('harvester.addons.vmImport.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.credentials.name"
:options="secretOptions"
:label="t('harvester.addons.vmImport.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
<Tab
name="advanced"
:label="t('harvester.addons.vmImport.titles.advanced')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-12">
<UnitInput
v-model:value="value.spec.httpTimeoutSeconds"
:label="t('harvester.addons.vmImport.ova.fields.httpTimeout')"
:placeholder="t('harvester.addons.vmImport.ova.placeholders.httpTimeout')"
suffix="Seconds"
:mode="mode"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -0,0 +1,570 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { Checkbox } from '@components/Form/Checkbox';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { allHash } from '@shell/utils/promise';
import { MANAGEMENT_NETWORK } from '../mixins/harvester-vm';
import { VMIMPORT_SOURCE_PROVIDER, VMIMPORT_SOURCE_KINDS } from '../config/types';
import { HCI } from '../types';
import { isValidDNSLabelName } from '@pkg/utils/regular';
import { mapGetters } from 'vuex';
// Full API types for the fetch dispatch
const VMWARE_SOURCE_TYPE = `${ HCI.MIGRATION }.${ VMIMPORT_SOURCE_KINDS.VMWARE.toLowerCase() }`;
const OPENSTACK_SOURCE_TYPE = `${ HCI.MIGRATION }.${ VMIMPORT_SOURCE_KINDS.OPENSTACK.toLowerCase() }`;
const OVA_SOURCE_TYPE = `${ HCI.MIGRATION }.${ VMIMPORT_SOURCE_KINDS.OVA.toLowerCase() }`;
export default {
name: 'EditVirtualMachineImport',
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
Checkbox
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
// Fetch all dependencies in parallel to speed up the page load
const hash = {
storageClasses: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
networks: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vmwareSources: this.$store.dispatch(`${ inStore }/findAll`, { type: VMWARE_SOURCE_TYPE }),
openstackSources: this.$store.dispatch(`${ inStore }/findAll`, { type: OPENSTACK_SOURCE_TYPE }),
ovaSources: this.$store.dispatch(`${ inStore }/findAll`, { type: OVA_SOURCE_TYPE }).catch(() => []),
};
const res = await allHash(hash);
this.allStorageClasses = res.storageClasses;
this.allNetworks = res.networks;
this.vmwareSources = res.vmwareSources;
this.openstackSources = res.openstackSources;
this.ovaSources = res.ovaSources;
},
data() {
// Ensure the spec object exists to prevent 'undefined' errors during rendering
if (!this.value.spec) this.value.spec = {};
if (!this.value.spec.sourceCluster) this.value.spec.sourceCluster = {};
if (!this.value.spec.networkMapping) this.value.spec.networkMapping = [];
// Detect if in Edit mode by checking the existing kind
// This allows to pre-select the correct Provider Type tab
let initialProvider = '';
const existingKind = this.value.spec.sourceCluster.kind;
if (existingKind === VMIMPORT_SOURCE_KINDS.VMWARE) initialProvider = VMIMPORT_SOURCE_PROVIDER.VMWARE;
else if (existingKind === VMIMPORT_SOURCE_KINDS.OPENSTACK) initialProvider = VMIMPORT_SOURCE_PROVIDER.OPENSTACK;
else if (existingKind === VMIMPORT_SOURCE_KINDS.OVA) initialProvider = VMIMPORT_SOURCE_PROVIDER.OVA;
// Construct the unique key (Kind/Namespace/Name) if we are editing an existing resource
let initialSourceKey = null;
if (this.value.spec.sourceCluster.name) {
initialSourceKey = `${ existingKind }/${ this.value.spec.sourceCluster.namespace }/${ this.value.spec.sourceCluster.name }`;
}
return {
allStorageClasses: [],
allNetworks: [],
vmwareSources: [],
openstackSources: [],
ovaSources: [],
// UI State
sourceProviderType: initialProvider,
selectedSourceKey: initialSourceKey,
// Static Options
providerTypeOptions: [
{ label: 'VMware', value: VMIMPORT_SOURCE_PROVIDER.VMWARE },
{ label: 'OpenStack', value: VMIMPORT_SOURCE_PROVIDER.OPENSTACK },
{ label: 'OVA', value: VMIMPORT_SOURCE_PROVIDER.OVA }
],
diskBusOptions: [
// Allow resetting selection / reset to the default behavior (sending null/empty)
{ label: this.t('harvester.addons.vmImport.options.useDefault'), value: '' },
{ label: 'VirtIO', value: 'virtio' },
{ label: 'SCSI', value: 'scsi' },
{ label: 'SATA', value: 'sata' },
{ label: 'USB', value: 'usb' },
],
interfaceModelOptions: [
// Allow resetting selection / reset to the default behavior (sending null/empty)
{ label: this.t('harvester.addons.vmImport.options.useDefault'), value: '' },
{ label: 'VirtIO', value: 'virtio' },
{ label: 'e1000', value: 'e1000' },
{ label: 'e1000e', value: 'e1000e' },
{ label: 'ne2k_pci', value: 'ne2k_pci' },
{ label: 'pcnet', value: 'pcnet' },
{ label: 'rtl8139', value: 'rtl8139' },
],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.virtualMachineName', rules: ['vmNameRequired', 'rfc1123'] },
],
};
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
...mapGetters({ t: 'i18n/t' }),
// Return only the sources that match the selected Provider Type (VMware or OpenStack)
sourceOptions() {
let list = [];
if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.VMWARE) {
list = this.vmwareSources;
} else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OPENSTACK) {
list = this.openstackSources;
} else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OVA) {
list = this.ovaSources;
}
return list.map((s) => {
// Fallback for API version/kind if missing on the object
let kind = s.kind;
if (!kind) {
if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.VMWARE) kind = VMIMPORT_SOURCE_KINDS.VMWARE;
else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OPENSTACK) kind = VMIMPORT_SOURCE_KINDS.OPENSTACK;
else if (this.sourceProviderType === VMIMPORT_SOURCE_PROVIDER.OVA) kind = VMIMPORT_SOURCE_KINDS.OVA;
}
const apiVersion = s.apiVersion || `${ HCI.MIGRATION }/v1beta1`;
return {
label: s.metadata.name,
value: `${ kind }/${ s.metadata.namespace }/${ s.metadata.name }`,
// We attach the raw metadata so we can easily populate the spec later without re-finding the object
raw: {
kind,
apiVersion,
name: s.metadata.name,
namespace: s.metadata.namespace
}
};
});
},
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
vmNameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.vmName') }) : undefined,
rfc1123:
(val) => {
if (val && !isValidDNSLabelName(val)) {
return this.t('harvester.addons.vmImport.errors.rfc1123');
}
return undefined;
}
};
},
// Perform various form validations before allowing to submit
isFormValid() {
// Check VM Name is valid
const nameError = this.fvNameRule(this.value.spec.virtualMachineName);
if (nameError) return false;
// Check mandatory fields in Basics
if (!this.value.spec.virtualMachineName) return false;
if (!this.selectedSourceKey) return false;
// Check Network Mappings
// If any row is missing source or destination, the form is invalid.
const networks = this.value.spec.networkMapping || [];
const hasInvalidRow = networks.some((row) => !row.sourceNetwork || !row.destinationNetwork);
if (hasInvalidRow) return false;
return true;
},
isNetworkTabInvalid() {
const networks = this.value.spec.networkMapping || [];
// Only error if a row exists AND it is missing fields
return networks.some((row) => !row.sourceNetwork || !row.destinationNetwork);
},
// Filter out internal storage classes
// to prevent selecting a class that might cause the import to fail
storageClassOptions() {
return this.allStorageClasses
.filter((sc) => {
const isInternal = sc.parameters?.['harvesterhci.io/isInternalStorageClass'] === 'true';
return !isInternal;
})
.map((sc) => ({
label: sc.nameDisplay,
value: sc.id
}));
},
networkOptions() {
const mgmtOption = {
label: 'Management Network',
value: MANAGEMENT_NETWORK
};
const vlanOptions = this.allNetworks.map((n) => ({
label: n.nameDisplay || n.metadata.name,
value: n.id
}));
return [mgmtOption, ...vlanOptions];
}
},
methods: {
// Clear the selected cluster if the user switches providers (e.g. VMware -> OpenStack)
// Prevents submitting a VMware cluster name while the kind is OpenStack
onProviderTypeChange(newType) {
this.selectedSourceKey = null;
this.value.spec.sourceCluster = {};
},
// Update the sourceCluster object based on the single dropdown selection
updateSource(key) {
this.selectedSourceKey = key;
const selectedOption = this.sourceOptions.find((o) => o.value === key);
if (selectedOption) {
const {
kind, apiVersion, name, namespace
} = selectedOption.raw;
this.value.spec.sourceCluster = {
kind,
apiVersion,
name,
namespace
};
} else {
this.value.spec.sourceCluster = {};
}
},
addNetworkMapping() {
this.value.spec.networkMapping.push({
sourceNetwork: '',
destinationNetwork: '',
networkInterfaceModel: ''
});
},
removeNetworkMapping(index) {
if (!this.value?.spec?.networkMapping) {
return;
}
if (index >= 0 && index < this.value.spec.networkMapping.length) {
this.value.spec.networkMapping.splice(index, 1);
}
},
requiredRule(val) {
if (!val) {
return this.t('validation.required', { key: this.t('generic.value') });
}
return undefined;
},
// Validates that the input follows Kubernetes Naming Rules (RFC 1123).
// If the source VM has uppercase letters or spaces, the user must be warned
// that they cannot import it until they rename it on the source. See:
// https://docs.harvesterhci.io/v1.6/advanced/addons/vmimport/#source-virtual-machine-name-is-not-rfc1123-compliant
fvNameRule(val) {
if (!val) return undefined; // 'Required' check handles empty state separately
// valid RFC 1123
if (!isValidDNSLabelName(val)) {
return this.t('harvester.addons.vmImport.errors.rfc1123');
}
return undefined;
},
updateBeforeSave() {
// If networkMapping exists, filter out the "Management Network" rows
// Let the vm-import-controller set the default network mapping
if (this.value.spec.networkMapping) {
this.value.spec.networkMapping = this.value.spec.networkMapping.filter((row) => {
return row.destinationNetwork !== MANAGEMENT_NETWORK;
});
}
},
// Only handles complex logic that doesn't fit into simple field rules
async saveOverride(buttonCb) {
const errors = [];
this.errors = [];
// Validate Provider Type
if (!this.sourceProviderType) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.sourceProvider') }));
}
// Validate Network Tab
if (this.isNetworkTabInvalid) {
errors.push(this.t('harvester.addons.vmImport.errors.networkMappingRequired'));
}
// Return immediately in case of an error, avoid that `this.save()` runs, preventing `updateBeforeSave` from resetting data.
if (errors.length > 0) {
this.errors = errors;
buttonCb(false);
return;
}
// Only proceed if valid
this.save(buttonCb);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="fvFormIsValid"
@finish="saveOverride"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="sourceProviderType"
:options="providerTypeOptions"
:label="t('harvester.addons.vmImport.fields.sourceProvider')"
:mode="mode"
:rules="[requiredRule]"
required
@update:value="onProviderTypeChange"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="selectedSourceKey"
:options="sourceOptions"
:label="t('harvester.addons.vmImport.fields.sourceCluster')"
:placeholder="sourceProviderType ? t('harvester.addons.vmImport.placeholders.selectCluster') : t('harvester.addons.vmImport.placeholders.selectProviderFirst')"
:disabled="!sourceProviderType"
:mode="mode"
:rules="fvGetAndReportPathRules('selectedSourceKey')"
required
@update:value="updateSource"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.virtualMachineName"
:label="t('harvester.addons.vmImport.fields.vmName')"
:placeholder="t('harvester.addons.vmImport.placeholders.matchSource')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.virtualMachineName')"
required
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.storageClass"
:options="storageClassOptions"
:label="t('harvester.addons.vmImport.fields.targetStorageClass')"
:mode="mode"
/>
</div>
</div>
</Tab>
<Tab
name="networking"
:label="t('harvester.addons.vmImport.titles.networking')"
:weight="2"
:error="isNetworkTabInvalid"
>
<div
v-for="(row, i) in value.spec.networkMapping"
:key="i"
class="network-row box mb-10"
>
<div class="row">
<div class="col span-4">
<LabeledInput
v-model:value="row.sourceNetwork"
:label="t('harvester.addons.vmImport.fields.sourceNetwork')"
:mode="mode"
:rules="[requiredRule]"
required
/>
</div>
<div class="col span-4">
<LabeledSelect
v-model:value="row.destinationNetwork"
:options="networkOptions"
:label="t('harvester.addons.vmImport.fields.destNetwork')"
:mode="mode"
:rules="[requiredRule]"
required
/>
</div>
<div class="col span-3">
<LabeledSelect
v-model:value="row.networkInterfaceModel"
:options="interfaceModelOptions"
:label="t('harvester.addons.vmImport.fields.interfaceModel')"
:mode="mode"
/>
</div>
<div class="col span-1 remove-btn-container">
<button
type="button"
class="btn role-link"
@click="removeNetworkMapping(i)"
>
{{ t('harvester.addons.vmImport.actions.remove') }}
</button>
</div>
</div>
</div>
<button
type="button"
class="btn role-secondary"
@click="addNetworkMapping"
>
{{ t('harvester.addons.vmImport.actions.addNetwork') }}
</button>
</Tab>
<Tab
name="advanced"
:label="t('harvester.addons.vmImport.titles.advanced')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.folder"
:label="t('harvester.addons.vmImport.fields.folder')"
:placeholder="t('harvester.addons.vmImport.placeholders.folderExample')"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.defaultDiskBusType"
:options="diskBusOptions"
:label="t('harvester.addons.vmImport.fields.diskBus')"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.defaultNetworkInterfaceModel"
:options="interfaceModelOptions"
:label="t('harvester.addons.vmImport.fields.defaultInterface')"
:mode="mode"
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<Checkbox
v-model:value="value.spec.skipPreflightChecks"
:label="t('harvester.addons.vmImport.fields.skipPreflight')"
:mode="mode"
/>
<Checkbox
v-model:value="value.spec.forcePowerOff"
:label="t('harvester.addons.vmImport.fields.forcePowerOff')"
:mode="mode"
class="mt-10"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
<style lang="scss" scoped>
.network-row {
border: 1px solid var(--border);
padding: 10px;
border-radius: var(--border-radius);
background: var(--body-bg);
}
.remove-btn-container {
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,302 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
export default {
name: 'EditVmwareSource',
// Declare the event, fixes a console warning
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) this.value.spec = {};
if (!this.value.spec.credentials) this.value.spec.credentials = {};
const initialMode = this.value.spec.credentials.name ? 'existing' : 'new';
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newCaCert: '',
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.endpoint', rules: ['endpointRequired'] },
{ path: 'spec.dc', rules: ['dcRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
authModeOptions() {
return [
{ label: this.t('harvester.addons.vmImport.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.vmImport.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
// Define custom rules for the FormValidation mixin
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
endpointRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.vmware.fields.endpoint') }) : undefined,
dcRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.vmware.fields.datacenter') }) : undefined,
};
},
isFormValid() {
if (!this.fvFormIsValid) {
return false;
}
if (this.authMode === 'new') {
if (!this.newUsername || !this.newPassword) return false;
} else {
if (!this.value.spec.credentials.name) return false;
}
return true;
}
},
methods: {
usernameRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.username') }) : undefined;
},
passwordRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.password') }) : undefined;
},
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.vmImport.fields.selectSecret') }) : undefined;
},
async saveSource(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
// Create the model with the correct Schema ID (SECRET)
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace
}
});
// Use '_type' to set the Kubernetes 'type' field.
newSecret['_type'] = 'Opaque';
// base64 encode the data
newSecret['data'] = {
username: btoa(this.newUsername),
password: btoa(this.newPassword),
// Only include CA cert if the user provided one
caCert: this.newCaCert ? btoa(this.newCaCert) : undefined
};
await newSecret.save();
// Link the new secret to the Source
this.value.spec.credentials = {
name: secretName,
namespace
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveSource"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.vmImport.titles.basic')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.endpoint"
:label="t('harvester.addons.vmImport.vmware.fields.endpoint')"
:placeholder="t('harvester.addons.vmImport.vmware.placeholders.endpoint')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.endpoint')"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.dc"
:label="t('harvester.addons.vmImport.vmware.fields.datacenter')"
:placeholder="t('harvester.addons.vmImport.vmware.placeholders.datacenter')"
:tooltip="t('harvester.addons.vmImport.vmware.tooltips.datacenter')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.dc')"
required
/>
</div>
</div>
</Tab>
<Tab
name="auth"
:label="t('harvester.addons.vmImport.titles.auth')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.vmImport.fields.username')"
:mode="mode"
:rules="[usernameRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.vmImport.fields.password')"
:mode="mode"
:rules="[passwordRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.vmImport.fields.caCert')"
:placeholder="t('harvester.addons.vmImport.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
<div class="text-muted">
Note: A new Kubernetes Secret will be created to store these credentials.
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.credentials.name"
:options="secretOptions"
:label="t('harvester.addons.vmImport.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -1,6 +1,7 @@
<script>
import { NORMAN } from '@shell/config/types';
import { HCI } from '../types';
import { getHarvesterUser } from '../utils/auth';
export default {
props: {
@@ -25,7 +26,7 @@ export default {
},
data() {
const user = this.$store.getters['auth/v3User'];
const user = getHarvesterUser(this.$store.getters);
return {
harvesterSettings: [],
+1
View File
@@ -7,6 +7,7 @@ import harvesterStore from './store/harvester-store';
import customValidators from './validators';
import { PRODUCT_NAME } from './config/harvester';
import { defineAsyncComponent } from 'vue';
import './styles/vue-flow.scss';
// Init the package
export default function (plugin: IPlugin) {
+210 -6
View File
@@ -20,6 +20,7 @@ nav:
Monitoring: Monitoring
Logging: Logging
'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports
resourceTable:
groupBy:
@@ -122,6 +123,24 @@ harvester:
namespace: Namespace
message:
success: 'Image { name } created successfully.'
storageMigration:
title: Storage Migration
fields:
sourceVolume:
label: Source Volume
placeholder: Select a source volume
targetVolume:
label: Target Volume
placeholder: Select a target volume
dataMigration:
title: Data Migration
fields:
targetVolumeName:
label: Target Volume Name
placeholder: Enter a target volume name
targetStorageClassName:
label: Target Storage Class
placeholder: Select a storage class
migration:
failedMessage: Latest migration failed!
title: Migration
@@ -163,6 +182,11 @@ harvester:
vmNetwork: Virtual Machine Network
macAddress: MAC Address
macAddressTooltip: If left blank, the MAC address will be automatically generated.
insertCdRomVolume:
success: '{ imageName } is inserted into device { deviceName }.'
title: Insert Image
image: Image
actionLabel: Insert Image
cpuMemoryHotplug:
success: 'CPU and Memory are updated to the virtual machine { vm }.'
title: Edit CPU and Memory
@@ -195,6 +219,9 @@ harvester:
info: Info
warning: Warning
error: Error
restartRequired:
title: '{count} {count, plural, =1 {Virtual Machine is} other {Virtual Machines are}} Pending Restart'
message: 'Please restart { vmNames } to apply updated configurations'
action:
createVM: Create Virtual Machine
start: Start
@@ -223,6 +250,9 @@ harvester:
migrate: Migrate
cpuAndMemoryHotplug: Edit CPU and Memory
abortMigration: Abort Migration
storageMigration: Storage Migration
cancelStorageMigration: Cancel Storage Migration
dataMigration: Data Migration
createTemplate: Generate Template
enableMaintenance: Enable Maintenance Mode
disableMaintenance: Disable Maintenance Mode
@@ -295,6 +325,17 @@ harvester:
totalSnapshotQuota: Total Snapshot Quota
storageClass: Storage Class
restore: Restore
vmImportSourceVm: Source VM
vmImportSourceCluster: Source Cluster
vmImportStatus: Import Status
vmImportSourceVDatacenter: Datacenter
vmImportSourceVEndpoint: Endpoint
vmImportSourceVClusterStatus: Cluster Status
vmImportSourceORegion: Region
vmImportSourceOEndpoint: Endpoint
vmImportSourceOClusterStatus: Cluster Status
vmImportSourceOVAUrl: URL
vmImportSourceOVAStatus: Status
tab:
volume: Volumes
network: Networks
@@ -335,7 +376,14 @@ harvester:
available: Available Devices
compatibleNodes: Compatible Nodes
impossibleSelection: 'There are no hosts with all of the selected devices.'
howToUseDevice: 'Use the table below to enable PCI passthrough on each device you want to use in this virtual machine.'
howToUseDeviceInVMCreation:
prefix: 'Use the table below to enable PCI passthrough on each device you want to use in this virtual machine. <br>For vGPU devices, please enable them on the'
middle: vGPU Devices
suffix: page first.
howToUseDevice:
prefix: 'Select the device in the table to enable PCI passthrough. <br>For vGPU devices, please enable them on the'
middle: vGPU Devices
suffix: page.
deviceInTheSameHost: 'You can only select devices on the same host.'
oldFormatDevices:
help: |-
@@ -357,6 +405,9 @@ harvester:
claimError: Error enabling passthrough on {name}
unclaimError: Error disabling passthrough on {name}
cantUnclaim: You cannot disable passthrough on a device claimed by another user.
detachWarning:
title: Cannot Disable Passthrough
message: Please detach the device from the VM and save it first before disabling passthrough.
enableGroup: Enable Group
disableGroup: Disable Group
labelRequired: "This rule should not be manually altered: it ensures that the PCI devices selected for this virtual machine are available on the virtual machine's host."
@@ -402,7 +453,7 @@ harvester:
volume:
upperType: Volume name
lowerType: volume name
needImageOrExisting: 'At least an image volume or an existing root-disk volume is required!'
needAtLeastOneBootable: 'At least one bootable volume is required!'
image:
ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
@@ -598,6 +649,10 @@ harvester:
virtualMachine:
label: Virtual Machines
osType: OS Type
cpuModel:
label: CPU Model
fetchError: 'Failed to load CPU model configuration: {error}'
optionLabel: "{modelName} ({count} {count, plural, one {node} other {nodes}})"
hotplug:
title: Enable CPU and memory hotplug
tooltip: The default maximum CPU and maximum memory are {hotPlugTimes} times based on CPU and memory.
@@ -608,6 +663,10 @@ harvester:
title: 'Are you sure that you want to detach volume {name}?'
actionLabel: Detach Volume
success: 'Volume { name } is detached successfully.'
ejectCdRomVolume:
title: 'Are you sure that you want to eject image from device {name}?'
actionLabel: Eject Image
success: 'Image from device { name } is ejected successfully.'
detachNIC:
title: 'Are you sure that you want to detach network interface {name}?'
actionLabel: Detach Network Interface
@@ -714,6 +773,7 @@ harvester:
unmount:
title: Are you sure?
message: Are you sure you want to unmount this volume?
emptyImage: No media
network:
title: Network
addNetwork: Add Network
@@ -866,6 +926,11 @@ harvester:
conditions: Conditions
size: Size
volumeMode: Volume Mode
accessModes: Access Modes
createWithDataVolume: Create with DataVolume
createWithDataVolumeTooltip: Create Volume with Kubevirt/Containerized Data Importer way. It can fill accessMode/volumeMode automatically.
showAdvanced: Show Advanced Options
hideAdvanced: Hide Advanced Options
source: Source
kind: Kind
sourceOptions:
@@ -985,7 +1050,9 @@ harvester:
createTitle: Create Schedule
createButtonText: Create Schedule
scheduleType: Virtual Machine Schedule Type
cron: Cron Schedule
cron:
label: Cron Schedule
editButton: Edit
detail:
namespace: Namespace
sourceVM: Source Virtual Machine
@@ -1112,6 +1179,25 @@ harvester:
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:
viewTopology: Topology
topology:
loading: Loading topology...
empty: No resources found
visibility:
vpc: VPC
subnets: Subnets
overlayNetworks: Overlay Networks
vms: VMs
labels:
cidr: CIDR
provider: Provider
type: Type
clusterNetwork: Cluster Network
network: Network
subnet: Subnet
ip: IP
mac: MAC
peering: Peering
noAddonEnabled:
prefix: The kubeovn-operator add-on is not enabled, click
middle: here
@@ -1213,8 +1299,14 @@ harvester:
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.'
tip: 'Specify an IP range in the IPv4 CIDR format. <code>Number of IPs Required = Number of Nodes * 2 + Number of Disks * 2 + Number of Images to Download/Upload </code>. For more information about storage network settings, see the <a href="{url}" target="_blank">documentation</a>.'
rwxNetwork:
warning: 'WARNING: <br/> Any change to rwx-network requires longhorn RWX volumes detached 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.'
shareStorageNetwork: Share Storage Network
dedicatedRwxNetwork: Dedicated RWX Network
shareStorageNetworkWarning: The rwx-network is governed by storage-network, and changes here won't take effect until share-storage-network is set to false.
vmForceDeletionPolicy:
period: Period
vmMigrationTimeout: VM Migration Timeout
vmMigrationNetwork:
parseError: "Failed to parse existing configuration."
fetchError: "Failed to load required network resources: {error}. Please refresh the page or try again later."
@@ -1278,7 +1370,10 @@ harvester:
deleteImage: Please select an image to delete.
deleteSuccess: "{name} deleted successfully."
imagePreloadStrategy: Image Preload Strategy
nodeUpgradeOption: Node Upgrade Option
restoreVM: Restore VM
strategy: Strategy
pauseNodes: Pause Nodes
strategyType: Strategy Type
concurrency: Concurrency
harvesterMonitoring:
@@ -1296,14 +1391,18 @@ harvester:
clusterRegistrationUrl:
url: URL
insecureSkipTLSVerify: Insecure Skip TLS Verify
tip:
prefix: Harvester secures cluster registration via TLS by default. If opt out "Insecure SKip TLS Verify", you must provide custom CA certificates using the
tip:
prefix: Harvester secures cluster registration via TLS by default. If opt out "Insecure Skip TLS Verify", you must provide custom CA certificates using the
middle: 'additional-ca'
suffix: setting.
message: To completely unset the imported Harvester cluster, please also remove it on the Rancher Dashboard UI via the <code> Virtualization Management </code> page.
ntpServers:
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.
instanceManagerResources:
parseError: "Failed to parse configuration: {error}"
v1: "V1 Data Engine"
v2: "V2 Data Engine"
kubevirtMigration:
parseError: "Failed to parse configuration: {error}"
parallelMigrationsPerCluster: "Parallel Migrations Per Cluster"
@@ -1570,10 +1669,85 @@ harvester:
'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.
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
vmImport:
titles:
basic: Basic
auth: Authentication
pvc: Volume
networking: Network Mapping
advanced: Advanced
labels:
vmimport: Virtual Machine Import
vmimportSourceVMWare: Source VMWare
vmimportSourceOpenStack: Source OpenStack
vmimportSourceOVA: Source OVA
fields:
sourceProvider: Source Provider Type
sourceCluster: Source Cluster
vmName: VM Name
targetStorageClass: Target Storage Class
sourceNetwork: Source Network Name
destNetwork: Destination Network
interfaceModel: Interface Model
folder: Folder
diskBus: Default Disk Bus
defaultInterface: Default Network Interface
skipPreflight: Skip Preflight Checks
forcePowerOff: Force Power Off Source VM
username: Username
password: Password
caCert: CA Certificate (PEM)
selectSecret: Select Secret
createSecret: Create New Credentials
useSecret: Use Existing Secret
none: None (Public URL)
placeholders:
selectCluster: Select a cluster...
selectProviderFirst: Select a provider type first
matchSource: Must match the name in the source cluster
folderExample: e.g. /Datacenters/DC1/vm
caCert: "-----BEGIN CERTIFICATE----- ..."
options:
useDefault: Use Default
actions:
addNetwork: Add Network Mapping
remove: Remove
errors:
rfc1123: 'Invalid format. Name must be lowercase, alphanumeric, and cannot contain spaces (e.g. "my-vm-1"). If your Source VM name does not match this, you must rename it on the Source cluster first.'
networkMappingRequired: Every Network Mapping row must have a Source and Destination selected.
openstack:
fields:
endpoint: Identity Service Endpoint
region: Region
projectName: Project Name
domainName: Domain Name
retryCount: Upload Image Retry Count
retryDelay: Upload Image Retry Delay
placeholders:
endpoint: "e.g. https://devstack/identity"
region: e.g. RegionOne
projectName: e.g. admin
domainName: e.g. default
retryCount: "Default: 30"
retryDelay: "Default: 10"
vmware:
fields:
endpoint: vCenter Endpoint
datacenter: Datacenter
placeholders:
endpoint: "e.g. https://vscim/sdk"
datacenter: e.g. DC0
tooltips:
datacenter: The exact name of the Datacenter object in vCenter
ova:
fields:
url: URL
httpTimeout: HTTP Timeout
placeholders:
url: "e.g. https://download.example.com/images/my-vm.ova"
httpTimeout: "Default: 600"
rancherVcluster:
accessRancher: Access the Rancher Dashboard
hostname: Hostname
@@ -1700,7 +1874,8 @@ harvester:
numVFs: Number Of Virtual Functions
vfAddresses: Virtual Functions Addresses
showMore: Show More
parentSriov: Filter By Parent SR-IOV
parentSriov: Filter By Parent SR-IOV Netork Device
parentSriovGPU: Filter By Parent SR-IOV GPU Device
sriovgpu:
label: SR-IOV GPU Devices
@@ -1739,6 +1914,9 @@ harvester:
vgpu:
label: vGPU Devices
noPermission: Please contact system administrator to add Harvester add-ons first.
detachWarning:
title: Cannot Disable vGPU
message: Please detach the device from the VM and save it first before disabling this vGPU device.
goSetting:
prefix: The nvidia-driver-toolkit add-on is not enabled, click
middle: here
@@ -1773,6 +1951,9 @@ harvester:
claimError: Error enabling passthrough on {name}
unclaimError: Error disabling passthrough on {name}
cantUnclaim: You cannot disable passthrough on a device claimed by another user.
detachWarning:
title: Cannot Disable Passthrough
message: Please detach the device from the VM and save it first before disabling passthrough.
enablePassthroughWarning: 'Please re-enable the USB device if the device path changes in the following situations:<br/>&nbsp1) Re-plugging the USB device.<br/>&nbsp2) Rebooting the node.<br/><br/>An incorrect device path may cause passthrough to fail.'
harvesterVlanConfigMigrateDialog:
@@ -1835,11 +2016,13 @@ advancedSettings:
'harv-additional-ca': 'Custom CA root certificates for TLS validation.'
'harv-overcommit-config': 'Resource overcommit configuration.'
'harv-support-bundle-timeout': 'Support bundle timeout configuration in minutes, use 0 to disable the timeout.'
'harv-support-bundle-file-name': 'Support bundle file name configuration.'
'harv-support-bundle-expiration': 'Support bundle expiration configuration in minutes.'
'harv-support-bundle-node-collection-timeout': 'Support bundle node collection timeout configuration in minutes.'
'harv-vm-force-reset-policy': Configuration for the force-reset action when a virtual machine is stuck on a node that is down.
'harv-ssl-parameters': Custom SSL Parameters for TLS validation.
'harv-storage-network': 'Longhorn storage-network setting.'
'harv-rwx-network': 'Configure RWX network behavior for shared or dedicated storage network usage.'
'harv-support-bundle-namespaces': Select additional namespaces to include in the support bundle.
'harv-auto-disk-provision-paths': Specify the disks(using glob pattern) that Harvester will automatically add as virtual machine storage.
'harv-support-bundle-image': Support bundle image configuration. Find different versions in <a href="https://hub.docker.com/r/rancher/support-bundle-kit/tags" target="_blank">rancher/support-bundle-kit</a>.
@@ -1855,6 +2038,7 @@ advancedSettings:
'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-kubevirt-migration': 'Configure cluster-wide KubeVirt live migration parameters.'
'harv-instance-manager-resources': 'Configure resource percentage reservations for Longhorn instance manager V1 and V2. Valid instance manager CPU range between 0 - 40.'
typeLabel:
kubevirt.io.virtualmachine: |-
@@ -2036,3 +2220,23 @@ typeLabel:
one { IP Pool }
other { IP Pools }
}
migration.harvesterhci.io.openstacksource: |-
{count, plural,
one { OpenStack Source }
other { OpenStack Sources }
}
migration.harvesterhci.io.vmwaresource: |-
{count, plural,
one { VMware Source }
other { VMware Sources }
}
migration.harvesterhci.io.ovasource: |-
{count, plural,
one { OVA Source }
other { OVA Sources }
}
migration.harvesterhci.io.virtualmachineimport: |-
{count, plural,
one { Virtual Machine Import }
other { Virtual Machine Imports }
}
@@ -60,10 +60,25 @@ export default {
return schema;
},
toVGpuDevicesPage() {
return {
name: 'harvester-c-cluster-resource',
params: { cluster: this.$store.getters['clusterId'], resource: HCI.VGPU_DEVICE },
};
},
vGPUAsPCIDeviceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('vGPUAsPCIDevice');
},
rows() {
const inStore = this.$store.getters['currentProduct'].inStore;
const rows = this.$store.getters[`${ inStore }/all`](HCI.PCI_DEVICE);
rows.forEach((row) => {
row.allowDisable = true;
});
return rows;
}
},
@@ -81,11 +96,23 @@ export default {
{{ t('harvester.pci.noPCIPermission') }}
</Banner>
</div>
<DeviceList
v-else-if="hasSchema && enabledPCI"
:devices="rows"
:schema="schema"
/>
<div v-else-if="hasSchema && enabledPCI">
<Banner
v-if="vGPUAsPCIDeviceEnabled"
color="info"
>
<MessageLink
:to="toVGpuDevicesPage"
prefix-label="harvester.pci.howToUseDevice.prefix"
middle-label="harvester.pci.howToUseDevice.middle"
suffix-label="harvester.pci.howToUseDevice.suffix"
/>
</Banner>
<DeviceList
:devices="rows"
:schema="schema"
/>
</div>
<div v-else>
<Banner color="warning">
<MessageLink
@@ -54,7 +54,13 @@ export default {
devices() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
data.forEach((row) => {
row.allowDisable = true;
});
return data;
}
},
@@ -65,6 +65,10 @@ export default {
const vGpuDevices = this.$store.getters[`${ inStore }/all`](HCI.VGPU_DEVICE) || [];
const srioVGpuDevices = this.$store.getters[`${ inStore }/all`](HCI.SR_IOVGPU_DEVICE) || [];
vGpuDevices.forEach((row) => {
row.allowDisable = true;
});
if (this.hasSRIOVGPUSchema) {
return vGpuDevices.filter((device) => !!srioVGpuDevices.find((s) => s.isEnabled && s.spec?.nodeName === device.spec?.nodeName));
}
@@ -70,8 +70,13 @@ export default {
return schema;
},
filterRows() {
// we only show the non golden image PVCs in the list
return this.rows.filter((pvc) => !pvc?.isGoldenImageVolume);
return this.rows.filter((pvc) => {
if (pvc?.isGoldenImageVolume || pvc?.isCDIPopulatorVolume) {
return false;
}
return true;
});
},
headers() {
return [
+17
View File
@@ -155,6 +155,15 @@ export default {
return location;
},
viewTopology(group) {
const vpc = group.key;
const resource = this.$store.getters[`harvester/byId`](HCI.VPC, vpc);
if (resource && resource.goToDetail) {
resource.goToDetail();
}
},
showVpcAction(event, group) {
const vpc = group.key;
@@ -218,6 +227,14 @@ export default {
>
{{ t('harvester.vpc.createSubnet') }}
</router-link>
<button
type="button"
class="btn btn-sm role-secondary mr-5"
@click="viewTopology(group)"
>
<i class="icon icon-globe mr-5" />
{{ t('harvester.vpc.viewTopology') }}
</button>
<button
type="button"
class="btn btn-sm role-multi-action actions mr-10"
@@ -110,11 +110,12 @@ export default {
data() {
return {
hasNode: false,
allVMs: [],
allVMIs: [],
allNodeNetworks: [],
allClusterNetworks: [],
hasNode: false,
allVMs: [],
allVMIs: [],
allNodeNetworks: [],
allClusterNetworks: [],
restartNotificationDisplayed: false,
HCI
};
},
@@ -174,6 +175,48 @@ export default {
this['allVMIs'] = vmis;
},
beforeUnmount() {
// clear restart message before component unmount
this.$store.dispatch('growl/clear');
},
watch: {
allVMs: {
handler(neu) {
const vmNames = [];
neu.forEach((vm) => {
if (vm.isRestartRequired) {
vmNames.push(vm.metadata.name);
}
});
const count = vmNames.length;
if ( count === 0 && this.restartNotificationDisplayed) {
this.restartNotificationDisplayed = false;
return;
}
if (count > 0) {
// clear old notification before showing new one
if (this.restartNotificationDisplayed) {
this.$store.dispatch('growl/clear');
}
}
if (count > 0 && vmNames.length > 0) {
this.$store.dispatch('growl/warning', {
title: this.t('harvester.notification.restartRequired.title', { count }),
message: this.t('harvester.notification.restartRequired.message', { vmNames: vmNames.join(', ') }),
timeout: 10000,
}, { root: true });
this.restartNotificationDisplayed = true;
}
},
deep: true,
}
},
methods: {
lockIconTooltipMessage(row) {
const message = '';
@@ -243,6 +286,12 @@ export default {
</div>
</template>
<style lang="scss">
.growl-container {
z-index: 56 !important; // set to be lower than the vm action menu (z-index: 57)
}
</style>
<style lang="scss" scoped>
.state {
display: flex;
@@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.VMIMPORT_SOURCE_O,
type: SCHEMA,
attributes: {
kind: HCI.VMIMPORT_SOURCE_O,
namespaced: true
},
metadata: { name: HCI.VMIMPORT_SOURCE_O },
};
export default {
name: 'HarvesterVMImportSourceO',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIMPORT_SOURCE_O });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.VMIMPORT_SOURCE_O);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.VMIMPORT_SOURCE_O, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>
@@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.VMIMPORT_SOURCE_OVA,
type: SCHEMA,
attributes: {
kind: HCI.VMIMPORT_SOURCE_OVA,
namespaced: true
},
metadata: { name: HCI.VMIMPORT_SOURCE_OVA },
};
export default {
name: 'HarvesterVMImportSourceOVA',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIMPORT_SOURCE_OVA });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.VMIMPORT_SOURCE_OVA);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.VMIMPORT_SOURCE_OVA, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>
@@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.VMIMPORT,
type: SCHEMA,
attributes: {
kind: HCI.VMIMPORT,
namespaced: true
},
metadata: { name: HCI.VMIMPORT },
};
export default {
name: 'HarvesterVMImportVirtualMachine',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIMPORT });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.VMIMPORT);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.VMIMPORT, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>
@@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.VMIMPORT_SOURCE_V,
type: SCHEMA,
attributes: {
kind: HCI.VMIMPORT_SOURCE_V,
namespaced: true
},
metadata: { name: HCI.VMIMPORT_SOURCE_V },
};
export default {
name: 'HarvesterVMImportSourceV',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIMPORT_SOURCE_V });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.VMIMPORT_SOURCE_V);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.VMIMPORT_SOURCE_V, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>
+36 -14
View File
@@ -22,7 +22,7 @@ import {
} from '../../config/harvester-map';
import { HCI_SETTING } from '../../config/settings';
import { HCI } from '../../types';
import { parseVolumeClaimTemplates } from '../../utils/vm';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
@@ -182,6 +182,7 @@ export default {
immutableMode: this.realMode === _CREATE ? _CREATE : _VIEW,
terminationGracePeriodSeconds: '',
cpuPinning: false,
cpuModel: '',
};
},
@@ -394,6 +395,7 @@ export default {
const efiPersistentStateEnabled = this.isEFIPersistentStateEnabled(spec);
const secureBoot = this.isSecureBoot(spec);
const cpuPinning = this.isCpuPinning(spec);
const cpuModel = spec.template.spec.domain.cpu?.model || '';
const secretRef = this.getSecret(spec);
const accessCredentials = this.getAccessCredentials(spec);
@@ -431,6 +433,7 @@ export default {
this['tpmPersistentStateEnabled'] = tpmPersistentStateEnabled;
this['secureBoot'] = secureBoot;
this['cpuPinning'] = cpuPinning;
this['cpuModel'] = cpuModel;
this['hasCreateVolumes'] = hasCreateVolumes;
this['networkRows'] = networkRows;
@@ -508,12 +511,15 @@ export default {
const type = DISK?.cdrom ? CD_ROM : DISK?.disk ? HARD_DISK : '';
if (volume?.containerDisk) { // SOURCE_TYPE.CONTAINER
if (type === CD_ROM && volume === undefined) {
// Empty CD_ROM
source = SOURCE_TYPE.IMAGE;
image = EMPTY_IMAGE;
size = `0${ GIBIBYTE }`;
} else if (volume.containerDisk) { // SOURCE_TYPE.CONTAINER
source = SOURCE_TYPE.CONTAINER;
container = volume.containerDisk.image;
}
if (volume.persistentVolumeClaim && volume.persistentVolumeClaim?.claimName) {
} else if (volume.persistentVolumeClaim && volume.persistentVolumeClaim?.claimName) {
volumeName = volume.persistentVolumeClaim.claimName;
const DVT = _volumeClaimTemplates.find( (T) => T.metadata.name === volumeName);
@@ -701,25 +707,41 @@ export default {
}
},
needVolumeRelatedInfo(R) {
// return [needVolume, needVolumeClaimTemplate]
if (R.source === SOURCE_TYPE.CONTAINER) {
return [true, false];
}
if (R.source === SOURCE_TYPE.IMAGE && R.image === EMPTY_IMAGE) {
return [false, false];
}
return [true, true];
},
parseDiskRows(disk) {
const disks = [];
const volumes = [];
const diskNameLabels = [];
const volumeClaimTemplates = [];
disk.forEach( (R, index) => {
const prefixName = this.value.metadata?.name || '';
const dataVolumeName = this.parseDataVolumeName(R, prefixName);
const _disk = this.parseDisk(R, index);
const _volume = this.parseVolume(R, dataVolumeName);
const _dataVolumeTemplate = this.parseVolumeClaimTemplate(R, dataVolumeName);
disks.push(_disk);
volumes.push(_volume);
diskNameLabels.push(dataVolumeName);
if (R.source !== SOURCE_TYPE.CONTAINER) {
const prefixName = this.value.metadata?.name || '';
const dataVolumeName = this.parseDataVolumeName(R, prefixName);
const [needVolume, needVolumeClaimTemplate] = this.needVolumeRelatedInfo(R);
if (needVolume) {
const _volume = this.parseVolume(R, dataVolumeName);
volumes.push(_volume);
}
if (needVolumeClaimTemplate) {
const _dataVolumeTemplate = this.parseVolumeClaimTemplate(R, dataVolumeName);
volumeClaimTemplates.push(_dataVolumeTemplate);
}
});
@@ -1,6 +1,8 @@
import SteveModel from '@shell/plugins/steve/steve-class';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { getHarvesterUserName } from '../utils/auth';
const STATUS_DISPLAY = {
enabled: {
@@ -32,7 +34,7 @@ export default class PCIDevice extends SteveModel {
out.push(
{
action: 'enablePassthroughBulk',
enabled: !this.isEnabling,
enabled: !this.isEnabling && !this.isvGPUDevice,
icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough',
bulkable: true,
@@ -41,7 +43,7 @@ export default class PCIDevice extends SteveModel {
},
{
action: 'disablePassthrough',
enabled: this.isEnabling && this.claimedByMe,
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough',
bulkable: true,
@@ -52,6 +54,14 @@ export default class PCIDevice extends SteveModel {
return out;
}
get isvGPUDevice() {
if (!this.vGPUAsPCIDeviceFeatureEnabled) {
return false;
}
return !!this.metadata?.labels?.[HCI_ANNOTATIONS.PARENT_SRIOV_GPU];
}
get canYaml() {
return false;
}
@@ -87,15 +97,8 @@ export default class PCIDevice extends SteveModel {
if (!this.passthroughClaim) {
return false;
}
const isSingleProduct = this.$rootGetters['isSingleProduct'];
let userName = 'admin';
// if this is imported Harvester, there may be users other than admin
if (!isSingleProduct) {
const user = this.$rootGetters['auth/v3User'];
userName = user?.username || user?.id;
}
const userName = getHarvesterUserName(this.$rootGetters);
return this.claimedBy === userName;
}
@@ -144,6 +147,12 @@ export default class PCIDevice extends SteveModel {
// 'disable' passthrough deletes claim
// backend should return error if device is in use
async disablePassthrough() {
if (!this.allowDisable) {
this.showDetachWarning();
return;
}
try {
if (!this.claimedByMe) {
throw new Error(this.$rootGetters['i18n/t']('harvester.pci.cantUnclaim', { name: escapeHtml(this.metadata.name) }));
@@ -169,4 +178,24 @@ export default class PCIDevice extends SteveModel {
get groupByDevice() {
return this.status?.description;
}
get vGPUAsPCIDeviceFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('vGPUAsPCIDevice');
}
showDetachWarning() {
this.$dispatch('growl/warning', {
title: this.$rootGetters['i18n/t']('harvester.pci.detachWarning.title'),
message: this.$rootGetters['i18n/t']('harvester.pci.detachWarning.message'),
timeout: 5000
}, { root: true });
}
get allowDisable() {
return this._allowDisable;
}
set allowDisable(value) {
this._allowDisable = value;
}
}
@@ -1,6 +1,7 @@
import SteveModel from '@shell/plugins/steve/steve-class';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
import { getHarvesterUserName } from '../utils/auth';
const STATUS_DISPLAY = {
enabled: {
@@ -87,15 +88,8 @@ export default class USBDevice extends SteveModel {
if (!this.passthroughClaim) {
return false;
}
const isSingleProduct = this.$rootGetters['isSingleProduct'];
let userName = 'admin';
// if this is imported Harvester, there may be users other than admin
if (!isSingleProduct) {
const user = this.$rootGetters['auth/v3User'];
userName = user?.username || user?.id;
}
const userName = getHarvesterUserName(this.$rootGetters);
return this.claimedBy === userName;
}
@@ -133,6 +127,12 @@ export default class USBDevice extends SteveModel {
// 'disable' passthrough deletes claim
// backend should return error if device is in use
async disablePassthrough() {
if (!this.allowDisable) {
this.showDetachWarning();
return;
}
try {
if (!this.claimedByMe) {
throw new Error(this.$rootGetters['i18n/t']('harvester.usb.cantUnclaim', { name: escapeHtml(this.metadata.name) }));
@@ -158,4 +158,20 @@ export default class USBDevice extends SteveModel {
get groupByDevice() {
return this.status?.description;
}
showDetachWarning() {
this.$dispatch('growl/warning', {
title: this.$rootGetters['i18n/t']('harvester.usb.detachWarning.title'),
message: this.$rootGetters['i18n/t']('harvester.usb.detachWarning.message'),
timeout: 5000
}, { root: true });
}
get allowDisable() {
return this._allowDisable;
}
set allowDisable(value) {
this._allowDisable = value;
}
}
@@ -100,6 +100,12 @@ export default class VGpuDevice extends SteveModel {
}
async disableVGpu() {
if (!this.allowDisable) {
this.showDetachWarning();
return;
}
const { vGPUTypeName, enabled } = this.spec;
try {
@@ -126,4 +132,20 @@ export default class VGpuDevice extends SteveModel {
get vGpuAvailableTypes() {
return this.status?.availableTypes ? Object.keys(this.status.availableTypes) : [];
}
showDetachWarning() {
this.$dispatch('growl/warning', {
title: this.$rootGetters['i18n/t']('harvester.vgpu.detachWarning.title'),
message: this.$rootGetters['i18n/t']('harvester.vgpu.detachWarning.message'),
timeout: 5000
}, { root: true });
}
get allowDisable() {
return this._allowDisable;
}
set allowDisable(value) {
this._allowDisable = value;
}
}
+2 -4
View File
@@ -536,8 +536,7 @@ export default class HciNode extends HarvesterResource {
get isStopped() {
const inventory = this.inventory || {};
return inventory.spec?.powerActionRequested === 'shutdown' &&
inventory.status?.powerAction?.actionStatus === 'complete';
return inventory.status?.machinePowerState === 'off';
}
get isStopping() {
@@ -553,8 +552,7 @@ export default class HciNode extends HarvesterResource {
get isStarted() {
const inventory = this.inventory || {};
return inventory.spec?.powerActionRequested === 'poweron' &&
inventory.status?.powerAction?.actionStatus === 'complete';
return inventory.status?.machinePowerState === 'on';
}
get isStarting() {
@@ -9,6 +9,7 @@ import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import { HCI, VOLUME_SNAPSHOT } from '../../types';
import HarvesterResource from '../harvester';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../config/harvester';
import { CDI_POPULATOR_KIND } from '../../config/types';
import { LVM_DRIVER } from './storage.k8s.io.storageclass';
const DEGRADED_ERRORS = ['replica scheduling failed', 'precheck new replica failed'];
@@ -44,7 +45,7 @@ export default class HciPv extends HarvesterResource {
const exportImageAction = {
action: 'exportImage',
enabled: this.hasAction('export') && !this.isEncrypted,
icon: 'icon icon-copy',
icon: 'icon icon-external-link',
label: this.t('harvester.action.exportImage')
};
const takeSnapshotAction = {
@@ -77,10 +78,23 @@ export default class HciPv extends HarvesterResource {
icon: 'icon icon-backup',
label: this.t('harvester.action.cancelExpand')
},
{
action: 'dataMigration',
enabled: this.hasAction('dataMigration') && this.createPVCWithDataVolumeFeatureEnabled,
icon: 'icon icon-copy',
label: this.t('harvester.action.dataMigration')
},
...out
];
}
dataMigration(resources = this) {
this.$dispatch('promptModal', {
resources,
component: 'HarvesterDataMigrationDialog'
});
}
exportImage(resources = this) {
this.$dispatch('promptModal', {
resources,
@@ -339,10 +353,20 @@ export default class HciPv extends HarvesterResource {
return this?.metadata?.annotations?.[HCI_ANNOTATIONS.GOLDEN_IMAGE] === 'true';
}
get isCDIPopulatorVolume() {
const kind = this?.metadata?.annotations?.[HCI_ANNOTATIONS.CDI_POPULATOR_KIND];
return kind === CDI_POPULATOR_KIND.VOLUME_IMPORT_SOURCE || kind === CDI_POPULATOR_KIND.VOLUME_CLONE_SOURCE;
}
get thirdPartyStorageFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
}
get createPVCWithDataVolumeFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
}
get resourceExternalLink() {
const host = window.location.host;
const { params } = this.currentRoute();
@@ -153,7 +153,7 @@ export default class VirtVm extends HarvesterResource {
},
{
action: 'takeVMSnapshot',
enabled: (!!this.actions?.snapshot || !!this.action?.backup),
enabled: (!!this.actions?.snapshot || !!this.actions?.backup),
icon: 'icon icon-snapshot',
label: this.t('harvester.action.vmSnapshot')
},
@@ -183,7 +183,7 @@ export default class VirtVm extends HarvesterResource {
},
{
action: 'ejectCDROM',
enabled: !!this.actions?.ejectCdRom,
enabled: !this.hotplugCdRomEnabled && !!this.actions?.ejectCdRom,
icon: 'icon icon-delete',
label: this.t('harvester.action.ejectCDROM')
},
@@ -199,6 +199,18 @@ export default class VirtVm extends HarvesterResource {
icon: 'icon icon-close',
label: this.t('harvester.action.abortMigration')
},
{
action: 'storageMigration',
enabled: !!this.actions?.storageMigration,
icon: 'icon icon-copy',
label: this.t('harvester.action.storageMigration')
},
{
action: 'cancelStorageMigration',
enabled: !!this.actions?.cancelStorageMigration,
icon: 'icon icon-close',
label: this.t('harvester.action.cancelStorageMigration')
},
{
action: 'addHotplugVolume',
enabled: !!this.actions?.addVolume,
@@ -368,6 +380,13 @@ export default class VirtVm extends HarvesterResource {
});
}
storageMigration(resources = this) {
this.$dispatch('promptModal', {
resources,
component: 'HarvesterStorageMigrationDialog'
});
}
backupVM(resources = this) {
this.$dispatch('promptModal', {
resources,
@@ -401,6 +420,17 @@ export default class VirtVm extends HarvesterResource {
});
}
ejectCdRomVolume(diskName) {
const resources = this;
this.$dispatch('promptModal', {
resources,
name: diskName,
type: 'cdrom',
component: 'HarvesterHotUnplug',
});
}
unplugNIC(networkName) {
const resources = this;
@@ -509,6 +539,10 @@ export default class VirtVm extends HarvesterResource {
this.doActionGrowl('abortMigration', {});
}
cancelStorageMigration() {
this.doActionGrowl('cancelStorageMigration', {});
}
createTemplate(resources = this) {
this.$dispatch('promptModal', {
resources,
@@ -523,6 +557,16 @@ export default class VirtVm extends HarvesterResource {
});
}
insertCdRomVolume(diskName) {
const resources = this;
this.$dispatch('promptModal', {
resources,
name: diskName,
component: 'HarvesterInsertCdRomVolume',
});
}
addHotplugNic(resources = this) {
this.$dispatch('promptModal', {
resources,
@@ -749,11 +793,11 @@ export default class VirtVm extends HarvesterResource {
}
get isPending() {
if (this &&
if ((this &&
!this.isVMExpectedRunning &&
this.isVMCreated &&
this.vmi?.status?.phase === VMIPhase.Pending
) {
) || (this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS] === 'cloning')) {
return { status: VMIPhase.Pending };
}
@@ -1180,11 +1224,15 @@ export default class VirtVm extends HarvesterResource {
);
}
get stateDescription() {
get isRestartRequired() {
const conditions = get(this, 'status.conditions');
const restartRequired = findBy(conditions, 'type', 'RestartRequired');
if (restartRequired && restartRequired.status === 'True') {
return restartRequired && restartRequired.status === 'True';
}
get stateDescription() {
if (this.isRestartRequired) {
return this.t('harvester.virtualMachine.hotplug.restartVMMessage');
}
@@ -1263,6 +1311,10 @@ export default class VirtVm extends HarvesterResource {
return this.$rootGetters['harvester-common/getFeatureEnabled']('hotplugNic');
}
get hotplugCdRomEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('hotplugCdRom');
}
get isBackupTargetUnavailable() {
const allSettings = this.$rootGetters['harvester/all'](HCI.SETTING) || [];
const backupTargetSetting = allSettings.find( (O) => O.id === 'backup-target');
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "harvester",
"description": "Rancher UI Extension for Harvester",
"version": "1.7.2",
"version": "1.8.0",
"private": false,
"rancher": {
"annotations": {
"catalog.cattle.io/display-name": "Harvester",
"catalog.cattle.io/kube-version": ">= 1.16.0-0",
"catalog.cattle.io/rancher-version": ">= 2.13.0-0",
"catalog.cattle.io/rancher-version": ">= 2.14.0-0",
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
}
},
+4
View File
@@ -0,0 +1,4 @@
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
+6
View File
@@ -18,6 +18,7 @@ export const HCI = {
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
SUBNET: 'kubeovn.io.subnet',
VPC: 'kubeovn.io.vpc',
IP: 'kubeovn.io.ip',
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
@@ -56,6 +57,11 @@ export const HCI = {
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
MIGRATION: 'migration.harvesterhci.io',
};
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
+31
View File
@@ -0,0 +1,31 @@
/**
* Resolve the Harvester username from Vuex getters.
*
* Works with both `this.$store.getters` (in components) and
* `this.$rootGetters` (in Steve models).
*
* - In single-product (standalone Harvester) mode, always returns the
* default username (`admin`).
* - Otherwise, falls back to the authenticated user's `username` or `id`.
*/
export function getHarvesterUserName(getters, defaultUserName = 'admin') {
const isSingleProduct = getters?.['isSingleProduct'];
if (isSingleProduct) {
return defaultUserName;
}
const user = getHarvesterUser(getters);
return user?.username || user?.id || defaultUserName;
}
/**
* Return the authenticated user object from Vuex getters.
*
* Works with both `this.$store.getters` (in components) and
* `this.$rootGetters` (in Steve models).
*/
export function getHarvesterUser(getters) {
return getters?.['auth/user'];
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Dynamically toggles SideNav entries based on the enabled status of a specific Addon.
*
* @param {Object} store - The Vuex store instance.
* @param {String} productName - The product name (e.g. 'harvester').
* @param {Object} config - Configuration object.
* @param {String} config.addonName - The name of the addon to watch.
* @param {String} config.resourceType - The schema ID for addons.
* @param {String} config.navGroup - The group name in the side nav.
* @param {Array<String>} config.types - Array of Resource IDs to show/hide.
*/
export function registerAddonSideNav(store, productName, {
addonName, resourceType, navGroup, types
}) {
if (typeof window === 'undefined') {
return;
}
// Forces the SideNav component to re-render by toggling a dummy user preference.
// Necessary because the menu component does not automatically detect
// changes to the allowed types list.
const kickSideNav = () => {
const TRIGGER = 'ui.refresh.trigger';
store.dispatch('type-map/addFavorite', TRIGGER);
// SideNav component seem to ignore rapid state changes.
// Wait 600ms to ensure the toggle event triggers a re-render.
setTimeout(() => {
store.dispatch('type-map/removeFavorite', TRIGGER);
}, 600);
};
// Adds or removes the resource IDs from the product visibility whitelist.
const setMenuVisibility = (visible) => {
if (visible) {
store.commit('type-map/basicType', {
product: productName,
group: navGroup,
types
});
} else {
// Manually delete the keys from the state object to hide them.
const basicTypes = store.state['type-map'].basicTypes[productName];
if (basicTypes) {
types.forEach((t) => delete basicTypes[t]);
}
}
kickSideNav();
};
// Start polling to check if the store is ready.
let attempts = 0;
const MAX_ATTEMPTS = 60;
const waitForStore = setInterval(() => {
attempts++;
try {
// Check if the Schema definitions are loaded.
const hasSchema = store.getters[`${ productName }/schemaFor`] &&
store.getters[`${ productName }/schemaFor`](resourceType);
// Check if the resource list data is fully loaded to prevent race conditions.
const hasData = store.getters[`${ productName }/haveAll`] &&
store.getters[`${ productName }/haveAll`](resourceType);
if (hasSchema && hasData) {
// Store is ready. Stop polling.
clearInterval(waitForStore);
// Watch the specific addon resource for changes to its enabled status.
store.watch(
(state, getters) => {
const addons = getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName);
return addon?.spec?.enabled === true;
},
(isEnabled) => {
setMenuVisibility(isEnabled);
},
{ immediate: true, deep: true }
);
} else if (hasSchema && !hasData) {
// If the schema is ready but the data is missing, request the list from the API.
// Ensures the script does not wait indefinitely if the UI has not loaded the addons yet.
store.dispatch(`${ productName }/findAll`, { type: resourceType });
} else if (attempts >= MAX_ATTEMPTS) {
// Stop checking if the store does not load within the timeout limit.
clearInterval(waitForStore);
}
} catch (e) {
// Ignore errors if the store module is not yet registered and wait for the next attempt.
if (attempts >= MAX_ATTEMPTS) clearInterval(waitForStore);
}
}, 1000);
}
+2
View File
@@ -9,3 +9,5 @@ export function parseVolumeClaimTemplates(data) {
return out;
}
export const EMPTY_IMAGE = 'EMPTY_IMAGE';
+5 -5
View File
@@ -69,7 +69,7 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType);
});
let requiredVolume = false;
let hasBootableVolume = false;
_volumes.forEach((V, idx) => {
const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value);
@@ -77,7 +77,7 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
const prefix = V.name || idx + 1;
if ([SOURCE_TYPE.IMAGE, SOURCE_TYPE.ATTACH_VOLUME, SOURCE_TYPE.CONTAINER].includes(type)) {
requiredVolume = true;
hasBootableVolume = true;
}
if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) {
@@ -137,10 +137,10 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
});
/**
* At least one volume must be create. (Verify only when create.)
* At least one bootable volume must be provided. (Verify only when create.)
*/
if ((!requiredVolume || _volumes.length === 0) && !value.links) {
errors.push(getters['i18n/t']('harvester.validation.vm.volume.needImageOrExisting'));
if (!hasBootableVolume && !value.links) {
errors.push(getters['i18n/t']('harvester.validation.vm.volume.needAtLeastOneBootable'));
}
return errors;
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
# Usage: update package.json and pkg/harvester/package.json to desired version
# Example: ./scripts/bump_version.sh v1.8.0-rc3
VERSION="$1"
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version>"
echo "Example: $0 v1.8.0-rc3"
exit 1
fi
# Strip leading 'v' if present
VERSION="${VERSION#v}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
FILES=(
"$ROOT_DIR/package.json"
"$ROOT_DIR/pkg/harvester/package.json"
)
for FILE in "${FILES[@]}"; do
if [ ! -f "$FILE" ]; then
echo "File not found: $FILE"
exit 1
fi
# Use node to update version in-place while preserving formatting
node -e "
const fs = require('fs');
const raw = fs.readFileSync('$FILE', 'utf8');
const updated = raw.replace(/\"version\":\s*\"[^\"]*\"/, '\"version\": \"$VERSION\"');
fs.writeFileSync('$FILE', updated);
"
echo "Updated $FILE -> $VERSION"
done
+431 -316
View File
File diff suppressed because it is too large Load Diff