Compare commits

...
Author SHA1 Message Date
Andy Lee 28aa1061d5 ci: build UI artifact
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-08 15:45:07 +08:00
Marcelo Fukumoto a4299a9597 feat: added comments from copilot review
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:15 +02:00
Marcelo Fukumoto 107e44bd4b feat: added loading to provider steps
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:15 +02:00
Marcelo Fukumoto a11369ad6d feat: Added some final fixes to structure
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto f270a7b1f8 feat: Fix some labels
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto 0d9b976dd2 feat: Added new API for the vms
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto 77d80051c9 feat(forklift): small changes before rebase
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:14 +02:00
Marcelo Fukumoto fbcc235fa7 feat(forklift): Fixed the type for VMware
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto 9ddfaefc39 feat(forklift): Fix the way it is registered
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto a180372564 feat(forklift): small fixes after Copilot
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto b4210037e9 feat(forklift): reverted settings.json changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:13 +02:00
Marcelo Fukumoto 97e4b7271c feat(forklift): small fixes after Copilot
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:12 +02:00
Marcelo Fukumoto 2deb0aa254 feat(forklift): small fixes after Copilot
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:12 +02:00
Marcelo Fukumoto 5c7dd1953d feat(forklift): small fixes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:12 +02:00
Marcelo Fukumoto 3ecb6bfc4a feat(forklift): update size
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto d67813eb39 feat(forklift): Removed wrong configs used for testing
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto 1d26c48117 feat(forklift): Fixed some UI changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto de7598b7af feat(forklift): added the provider wizard
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:11 +02:00
Marcelo Fukumoto e703f34879 feat(forklift): Changed to Wizard
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:37:10 +02:00
Marcelo Fukumoto 7c8f172012 feat(forklift): Design changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:48 +02:00
Marcelo Fukumoto 6e17a84581 feat(forklift): fixed title and button
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:48 +02:00
Marcelo Fukumoto d075871afc feat(forklift): Changed based on review
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:48 +02:00
Marcelo Fukumoto 4d230ebd42 feat(forklift): Remove test flags
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto c887186e1a feat(forklift): Added better handling on the saving and testing
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto dc6ae60b9d feat(forklift): Added overflow hidden to mapping target list
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto 5d8d7ec986 feat(forklift): fixed to fix the icon on extension
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:47 +02:00
Marcelo Fukumoto bf01a9dc43 feat(forklift): route and router doesnt work properly on extensions
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto 4480b2cd8f feat(forklift): FIxed the routing for the extension
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto b84167a4da feat(forklift): Added pagination UI
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto f7a1dec58f feat(forklift): Change to use the proper provider instead of a hardcoded
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:46 +02:00
Marcelo Fukumoto a0d145ee60 feat(forklift): Added icons, critical state, fix grids, action button
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto 740ca94841 feat(forklift): Applied fix to select the proper data on network. Added condition to show critical
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto d4f59a04be Merge the new APIs
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto d195c5dd18 feat(forklift): Add the succeded state
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00
Marcelo Fukumoto 341f7734a9 feat(forklift): New changes added based on new figma
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 63d726aa91 feat(forklift): Fixed config based on new figma changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 2259910be1 feat(forklift): UI based on the FIGMA
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 7d8d74ce17 feat: add migration and plan detail pages with progress tracking
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:44 +02:00
Marcelo Fukumoto 9a20ad5edd feat: forklift poc first commit
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:43 +02:00
Volker Theile 495e80267d feat: Unattend.xml support for Windows (#954)
Documentation: https://kubevirt.io/user-guide/user_workloads/startup_scripts/#sysprep
Related to: https://github.com/harvester/harvester/issues/1836

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-07-06 08:45:33 +02:00
Alexandre Alves 479c298f4a feat: security update (#967) 2026-07-06 11:19:32 +08:00
renovate[bot] 3fdde25d17 deps: update dependency qs to v6.15.3 (#968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 11:09:50 +08:00
Andy Lee 5090d8ecad ci: disable digest update (#966)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-01 16:54:30 +08:00
Andy Lee b9334bafb7 feat: add overlay networks and underlay networks resources page (#938)
* feat: allow user to select kube-system ns

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

* feat: add NICs tab when creating overlay network

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

* refactor: only show NIC tab if ns is kube-system

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

* feat: add NAT & Internet tabs

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

* feat: add provider networks / vlan edit pages

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

* feat: add edit pages

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

* refactor: review comments

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-01 16:33:20 +08:00
Andy Lee 9a675da756 docs: add release block in README.md (#964)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-01 12:18:28 +08:00
renovate[bot] 8c0c36e022 deps: update patch dependencies (#945)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 11:22:07 +08:00
Siva Kanakala 140a85f6a8 fix: Fix VM Template UserData editor remaining editable in view-mode (#951)
Signed-off-by: Siva Kanakala <siva.kanakala@suse.com>
2026-07-01 11:21:26 +08:00
Volker Theile e62ae7e1d8 fix: Fix the title of the CloudConfig DataTemplate dialog (#953)
Signed-off-by: Volker Theile <vtheile@suse.com>
2026-07-01 11:21:07 +08:00
Tim Serong d03cff645b feat: add Longhorn V2 hugepage settings (#942)
This exposes the longhorn-v2-data-engine-hugepage-enabled and
longhorn-v2-data-engine-memory-size settings in the Harvester GUI, on
Harvester v1.9.0 and later.

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

Signed-off-by: Tim Serong <tserong@suse.com>
2026-06-25 14:39:23 +10:00
Volker Theile 34dfe4027e fix: 'No Media' image selection removes 'harvesterhci.io/os' label from VM CR (#941)
When editing a Virtual Machine's volumes, if the user selects 'No media' for the Image, the 'harvesterhci.io/os' label is incorrectly removed from the VM's metadata.

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

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-06-24 15:36:31 +02:00
Siva Kanakala 135d520b8d fix: Fix CloudConfigTemplate UserData editor remaining editable in view-mode (#939)
Signed-off-by: Siva Kanakala <siva.kanakala@suse.com>
2026-06-24 11:33:19 +08:00
Andy Lee 62a19ee3fb feat: add namespace filtering when updating namespaces (#940)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-23 21:44:39 +08:00
renovate[bot] 64f0f5fb87 deps: update patch dependencies (#932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 14:36:19 +08:00
Andy Lee 93a692ff0c feat: add NICs tab when creating overlay network (#931)
* feat: allow user to select kube-system ns

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

* feat: add NICs tab when creating overlay network

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

* refactor: only show NIC tab if ns is kube-system

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-22 14:32:26 +08:00
Andy Lee 5e3f12de35 feat: clear selected VM after doing bulk action (#929)
* feat: clear selected VM rows after click bulk action

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

* refactor: await altStopVM action

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

* refactor: multiple VM start actions

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

* refactor: modify altRestartVM()

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-17 13:55:01 +08:00
Po Han Huang f115261889 feat: improve clone status (#928)
* feat: improve clone status

Signed-off-by: pohanhuang <pohan.huang@suse.com>

* fix: pr comments

Signed-off-by: pohanhuang <pohan.huang@suse.com>

---------

Signed-off-by: pohanhuang <pohan.huang@suse.com>
2026-06-17 11:10:43 +08:00
Andy Lee 5985913f5e feat: add Host Networks tab list and edit pages (#920)
* feat: add Host Network tab

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

* feat: add Host Network edit page

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

* feat: add node selector tab

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

* refactor: copilot review

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

* fix: lint

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

* fix: copilot review

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

* refactor: add warning message if addon is not enabled or already hasone

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

* refactor: some wordings in en-us.yaml

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-16 10:57:15 +08:00
renovate[bot] 661ab995f6 deps: update patch dependencies (#921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 14:01:50 +08:00
Andy Lee d3f63df883 refactor: disable and hide filesystem warning banner in edit / view mode (#919)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-09 14:49:39 +08:00
Renuka Devi Rajendran 44ef9195eb feat: add exclusive VLAN field to storage network settings (#914)
Signed-off-by: Renuka Devi Rajendran <renuka.rajendran@suse.com>
2026-06-08 08:44:24 -07:00
Andy Lee b18aebbbd4 fix: snapshot/backup/volume attach VM name (#918)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-08 16:49:09 +08:00
renovate[bot] d2db23d69a deps: update minor dependencies to v7.29.7 (#915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 15:40:18 +08:00
Andy Lee 55918232a6 feat: add volume expansion checkbox in storage class configuration (#911)
* feat: add support for volume expansion in storage configuration

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

* refactor: update volume expansion wording

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

* refactor: hide checkbox if feature flag is not enabled

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

* refactor: revert unneeded change

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-05 14:54:10 +08:00
Andy Lee ce2adbdc3b feat: support adding VM display name in creation page (#912)
* feat: add VM display name annotation support in edit and list views

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

* refactor: add display name checkbox and input field

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-05 12:11:07 +08:00
Jack Yu ae65037083 feat: add disable resource pooling checkbox on pcidevice page (#902)
Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-06-05 11:18:14 +08:00
Jack Yu b4ea7c8f98 feat: remove old format pci device name checking (#889)
when implementing the first version pci device passthrough,
we didn't have our own device plugin, then resource name was changed
after upgrade.

Right now, we already have our own device plugin, which we can control
resource name by our ourselves. So, we can remove old mechanism.

Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-06-05 11:18:04 +08:00
Andy Lee 836b04f222 feat: support filesystem volume in create VM page (#910)
* feat: add filesystem tab

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

* feat: add filesystem tab in create VM page

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

* refactor: update some wordings

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

* feat: add support for filesystem feature flag and enable filesystem tab

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

* refactor: remove unneeded wordings

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

* feat: add support for filesystem feature flag and update icon button positioning

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

* refactor: based on copilot review

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

* refactor: remove wrong feature flag

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

* fix: vm template

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-02 13:34:09 +08:00
Andy Lee 0908c7fc6b Revert "feat: support filesystem disk for VM (#898)" (#909)
This reverts commit b34d618c7c.

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-02 11:21:40 +08:00
Andy Lee b34d618c7c feat: support filesystem disk for VM (#898)
* feat: add filesystem tab

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

* feat: add filesystem tab in create VM page

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

* refactor: update some wordings

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

* feat: add support for filesystem feature flag and enable filesystem tab

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

* refactor: remove unneeded wordings

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

* feat: add support for filesystem feature flag and update icon button positioning

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

* refactor: based on copilot review

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

* refactor: tab name

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-06-02 10:33:04 +08:00
renovate[bot] 8945b9f158 deps: update dependency @types/node to v25.9.1 (#894)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 16:49:08 +08:00
renovate[bot] c5af2b576b deps: update dependency semver to v7.8.1 (#893)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 16:34:06 +08:00
Andy Lee 45872cef3b feat: add autoattachPodInterface in edit VM mode if no network interface configured (#888)
* feat: add autoattachPodInterface in edit VM mode if no network interface configured

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

* refactor: remove unneeded code

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-31 15:51:51 +08:00
khushalchandak17 3e5ee422ce fix(console): defer window.open to prevent dropdown staying open in Firefox (#890)
Signed-off-by: khushalchandak17 <khushal.chandak@suse.com>
2026-05-29 17:09:13 +08:00
Andy Lee 75202a9e55 chore: bump shell to 3.0.12-rc3 (#892)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-29 15:49:49 +08:00
Andy Lee 5f5ce291fb ci: disable body-max-line-length eslint rule (#887)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-27 17:17:14 +08:00
Jack Yu a27e38fc81 feat: display message from MIG status and change the state to match the case (#879)
Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-05-26 13:44:27 +08:00
Andy Lee 89e1484884 feat: add natOutgoing option for custom subnet with corresponding tooltip (#878)
* feat: add external connectivity option for subnets with corresponding tooltip

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

* fix: remove gatewayType from subnet spec when not needed

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

* refactor: update external connectivity feature to use NAT outgoing and improve tooltip descriptions

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

* fix: update subnet creation to correctly handle natOutgoing and improve comment clarity

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

* refactor: improve tooltip clarity for external connectivity NAT option

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-26 12:13:59 +08:00
renovate[bot] eacca055c7 deps: update minor dependencies (#884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 18:02:44 +08:00
renovate[bot] 56c1738055 deps: update dependency roarr to v7.21.5 (#883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 14:29:21 +08:00
renovate[bot] 8f4b335cae deps: update dependency qs to v6.15.2 [security] (#880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 14:24:22 +08:00
Andy Lee 91232beffc fix: vGPU and USB enable/disable actions needs to be hidden for read only users (#877)
* fix: vGPU / USB enable/disable actions needs to be hidden for read only users

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

* style: add scoped styles for group actions in DeviceList component

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

* style: remove width property from group actions in DeviceList components

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

* fix: update logging type in init function and improve SideNav visibility handling

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

* fix: ensure canManageGroup only returns true for non-empty rows with updatable permissions

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-22 11:56:44 +08:00
Andy Lee 09e8946cc3 fix: missing VMIM in host detail VM tab (#876)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-18 14:55:02 +08:00
Volker Theile e70c684382 feat: Prevent VM start after restore from backup or snapshot (#865)
* feat: Prevent VM start after restore from backup or snapshot

Related to: https://github.com/harvester/harvester/issues/10422
Related to: https://github.com/harvester/harvester/pull/10480

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

* feat: Prevent VM start after restore from backup or snapshot

Related to: https://github.com/harvester/harvester/issues/10422
Related to: https://github.com/harvester/harvester/pull/10480

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

---------

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-05-18 08:10:36 +02:00
Andy Lee 1446aac168 refactor: enable add volume button regardless of rows length (#869)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-18 12:34:32 +08:00
renovate[bot] 8e65274b0c deps: update dependency @types/node to v25.6.2 (#870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:04:13 +08:00
renovate[bot] e5432210b9 deps: update dependency semver to v7.8.0 (#871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:00:49 +08:00
Andy Lee af52df0ba0 fix: add alternative action for VM restart and soft reboot (#868)
* fix: add alternative action for VM restart and soft reboot

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

* refactor: fix alignment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-15 12:03:20 +08:00
Andy Lee e5a1929ac5 refactor: remove unused pluralization for persistent volume claim in localization file (#867)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-15 12:02:51 +08:00
Andy Lee 9de065a5c9 feat: allow migrate mutliple VMs functionality and localization (#863)
* feat: enhance VM migration functionality and localization

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

* refactor: add allVmsOnTargetNode method and update migration titles in localization

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

* feat: enhance migration error handling and update localization for migration dialog

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-13 15:59:13 +08:00
renovate[bot] cd933bdbf8 deps: update dependency eslint-plugin-promise to v7.3.0 (#858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 16:19:04 +08:00
Andy Lee 5fe642a42d feat: add tooltip for route connectivity in network attachment definition (#864) 2026-05-12 16:14:11 +08:00
renovate[bot] 18c66083ab deps: update dependency yaml to v2.8.4 (#857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 15:59:13 +08:00
85 changed files with 9444 additions and 2640 deletions
+7 -8
View File
@@ -14,10 +14,17 @@
"semanticCommits": "enabled",
"semanticCommitType": "deps",
"prHourlyLimit": 12,
"digest": {
"enabled": false
},
"timezone": "Asia/Taipei",
"schedule": ["after 10am on sunday"],
"postUpdateOptions": ["yarnDedupeFewer"],
"packageRules": [
{
"matchUpdateTypes": ["digest"],
"enabled": false
},
{
"matchUpdateTypes": ["major"],
"enabled": false
@@ -45,14 +52,6 @@
"groupName": "patch dependencies",
"labels": ["patch-update"],
"reviewers": ["a110605", "houhoucoop"]
},
{
"matchUpdateTypes": ["digest", "pinDigest"],
"automerge": false,
"groupName": "digest dependencies",
"labels": ["digest-update"],
"schedule": ["on the first day of the month"],
"reviewers": ["a110605", "houhoucoop"]
}
]
}
@@ -43,12 +43,10 @@ jobs:
needs:
- setup-release-tag
- check-version
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@3b26a36bad555e5e2b8634b24823be29732f287c # master
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
permissions:
actions: write
contents: write
deployments: write
pages: write
pull-requests: write
with:
target_branch: gh-pages
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
@@ -6,11 +6,13 @@ on:
- main
- 'release-harvester-v*'
- '*-dev'
- forklift-poc
pull_request:
branches:
- main
- 'release-harvester-v*'
- '*-dev'
- forklift-poc
types:
- merged
+20
View File
@@ -5,6 +5,19 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
> **Note:**
> This extension is available starting from **Rancher 2.10.0**. Ensure your Rancher version is **2.10.0 or later** to access Harvester integration.
## Table of Contents
- [Installation](#installation)
- [Development Setup](#development-setup)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Branch Structure](#branch-structure)
- [Testing Guidelines](#testing-guidelines)
- [Release](#release)
- [Contributing](#contributing)
- [License](#license)
## Installation
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
@@ -157,6 +170,13 @@ To test the standalone UI, configure Harvester to load the UI from an external s
2. Set **ui-source** to `External`
3. Set **ui-index** to the desired URL
## Release
The Harvester UI Extension follows the [Harvester](https://github.com/harvester/harvester) release cycle. After RC1 is cut for a new Harvester version, we usually create and work from the corresponding release branch (for example, `release-harvester-v1.8`). The remaining RC builds and the final official release are published from that branch.
After Harvester releases a new version, update the Harvester entry in rancher/ui-plugin-charts [manifest.json](https://github.com/rancher/ui-plugin-charts/blob/aafd215debbc6cb3100e7ba4b0a542c932397acd/manifest.json#L133-L151). This ensures air-gapped users can pull the new Harvester UI Extension image.
## Contributing
If you want to contribute, start by reading this document, then visit our [Getting Started guide](https://extensions.rancher.io/extensions/next/extensions-getting-started) to learn how to develop and submit changes.
+1 -1
View File
@@ -33,7 +33,7 @@ module.exports = {
'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'],
'body-leading-blank': [2, 'always'],
'body-max-line-length': [2, 'always', 100],
'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [2, 'always', 100],
},
+6 -6
View File
@@ -6,8 +6,8 @@
"node": ">=24.0.0"
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.28.6",
"@rancher/shell": "3.0.12-rc.1",
"@babel/plugin-transform-class-static-block": "7.29.7",
"@rancher/shell": "3.0.12-rc.3",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
@@ -21,7 +21,7 @@
"yaml": "^2.5.1"
},
"resolutions": {
"@types/node": "25.6.0",
"@types/node": "25.9.4",
"cronstrue": "2.59.0",
"d3-color": "3.1.0",
"ejs": "3.1.10",
@@ -33,9 +33,9 @@
"merge": "2.1.1",
"node-forge": "1.4.0",
"nth-check": "2.1.1",
"qs": "6.15.1",
"roarr": "7.21.4",
"semver": "7.7.4",
"qs": "6.15.3",
"roarr": "7.21.6",
"semver": "7.8.5",
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
},
"scripts": {
@@ -0,0 +1,186 @@
<script>
import { Banner } from '@components/Banner';
import MatchExpressions from '@shell/components/form/MatchExpressions';
import ResourceTable from '@shell/components/ResourceTable';
import { _EDIT } from '@shell/config/query-params';
import { convert, simplify, matching as selectorMatching } from '@shell/utils/selector';
import throttle from 'lodash/throttle';
import { NODE } from '@shell/config/types';
import { NAME, AGE } from '@shell/config/table-headers';
export default {
name: 'HarvesterNodeSelector',
components: {
Banner,
MatchExpressions,
ResourceTable,
},
props: {
mode: {
type: String,
default: _EDIT,
},
value: {
type: Object,
required: true,
},
},
async fetch() {
this.updateMatchingResources();
},
data() {
const inStore = this.$store.getters['currentProduct'].inStore;
return {
matchingResources: {
matched: 0,
matches: [],
none: true,
sample: null,
total: 0,
},
tableHeaders: [
NAME,
{
name: 'host-ip',
labelKey: 'tableHeaders.hostIp',
search: ['internalIp'],
value: 'internalIp',
sort: ['internalIp'],
align: 'center',
},
{
name: 'cpuManager',
labelKey: 'harvester.tableHeaders.cpuManager',
value: 'id',
formatter: 'HarvesterCPUPinning',
width: 150,
align: 'center',
},
{
name: 'diskState',
labelKey: 'tableHeaders.diskState',
value: 'diskState',
formatter: 'HarvesterDiskState',
width: 130,
},
AGE,
],
inStore,
};
},
watch: {
value: {
handler: 'updateMatchingResources',
deep: true,
},
allResourcesInScope() {
this.updateMatchingResources();
},
},
computed: {
schema() {
return this.$store.getters[`${ this.inStore }/schemaFor`](NODE);
},
selectorExpressions: {
get() {
return convert(
this.value.matchLabels || {},
this.value.matchExpressions || []
);
},
set(selectorExpressions) {
const { matchLabels, matchExpressions } = simplify(selectorExpressions);
this.value['matchLabels'] = matchLabels;
this.value['matchExpressions'] = matchExpressions;
this.updateMatchingResources();
},
},
allNodes() {
return this.$store.getters[`${ this.inStore }/all`](NODE) || [];
},
allResourcesInScope() {
return this.allNodes.length;
},
},
methods: {
updateMatchingResources: throttle(function() {
const expressions = this.selectorExpressions;
const allNodes = this.allNodes;
// Empty expressions with no key = no match
const hasValidExpression = expressions.length > 0 && expressions.every((e) => !!e.key);
if (!hasValidExpression) {
this.matchingResources = {
matched: 0,
matches: [],
none: true,
sample: null,
total: allNodes.length,
};
return;
}
const matches = selectorMatching(allNodes, expressions, 'metadata.labels');
this.matchingResources = {
matched: matches.length,
matches,
none: matches.length === 0,
sample: matches[0]?.nameDisplay || null,
total: allNodes.length,
};
}, 100, { trailing: true })
},
};
</script>
<template>
<div>
<div class="row">
<div class="col span-12">
<MatchExpressions
v-model:value="selectorExpressions"
:mode="mode"
:show-remove="false"
:type="'node'"
:target-resources="allResourcesInScope"
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<Banner :color="(matchingResources.none ? 'warning' : 'success')">
<span v-clean-html="t('generic.selectors.matchingResources.matchesSome', matchingResources)" />
</Banner>
</div>
</div>
<div class="row">
<div class="col span-12">
<ResourceTable
:rows="matchingResources.matches"
:headers="tableHeaders"
key-field="id"
:table-actions="false"
:row-actions="false"
:schema="schema"
:groupable="false"
:search="false"
/>
</div>
</div>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup>
defineProps({
networkEntries: {
type: Array,
default: () => [],
},
storageEntries: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div class="mappings-cell">
<div
v-for="(entry, idx) in networkEntries"
:key="'net-' + idx"
class="mapping-entry"
>
<i
class="icon icon-network"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
<div
v-for="(entry, idx) in storageEntries"
:key="'stor-' + idx"
class="mapping-entry"
>
<i
class="icon icon-datastore"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-cell {
display: flex;
flex-direction: column;
gap: 8px;
}
.mapping-entry {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
.icon {
font-size: 16px;
}
}
</style>
+8 -5
View File
@@ -58,11 +58,14 @@ export default {
const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
window.open(
url,
'_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
// Defer so v-select can finish closing the dropdown before the popup steals focus
this.$nextTick(() => {
window.open(
url,
'_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
});
},
isEmpty(o) {
@@ -64,6 +64,9 @@ export default {
try {
parsedDefaultValue = JSON.parse(this.value.value);
if (typeof parsedDefaultValue.exclusiveVlan !== 'boolean') {
parsedDefaultValue.exclusiveVlan = false;
}
networkType = 'vlan' in parsedDefaultValue ? L2VLAN : UNTAGGED; // backend doesn't provide networkType, so we check if vlan is provided instead
openVlan = true;
} catch (error) {
@@ -72,7 +75,8 @@ export default {
vlan: '',
clusterNetwork: '',
range: '',
exclude: []
exclude: [],
exclusiveVlan: false
};
}
const exclude = parsedDefaultValue?.exclude?.toString().split(',') || [];
@@ -94,6 +98,10 @@ export default {
},
computed: {
showExclusiveVlan() {
return this.networkType === L2VLAN &&
Number(this.parsedDefaultValue.vlan) !== 1;
},
showVlan() {
return this.networkType === L2VLAN;
},
@@ -132,6 +140,18 @@ export default {
};
});
},
exclusiveVlanOptions() {
return [
{
label: this.t('generic.enabled'),
value: true
},
{
label: this.t('generic.disabled'),
value: false
}
];
},
},
watch: {
@@ -174,7 +194,8 @@ export default {
vlan: '',
clusterNetwork: '',
range: '',
exclude: []
exclude: [],
exclusiveVlan: false
};
},
@@ -280,7 +301,15 @@ export default {
label-key="harvester.setting.storageNetwork.vlan"
@update:value="inputVlan"
/>
<LabeledSelect
v-if="showExclusiveVlan"
v-model:value="parsedDefaultValue.exclusiveVlan"
class="mb-20"
:options="exclusiveVlanOptions"
:mode="mode"
label-key="harvester.setting.storageNetwork.exclusiveVlan"
@update:value="update"
/>
<LabeledSelect
v-model:value="parsedDefaultValue.clusterNetwork"
label-key="harvester.setting.storageNetwork.clusterNetwork"
@@ -0,0 +1,548 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import MappingColumn from './MappingColumn.vue';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
useAllProviderData: { type: Boolean, default: false },
existingNetworkMap: { type: Object, default: null },
existingStorageMap: { type: Object, default: null },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const errors = ref([]);
const loading = ref(true);
const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE;
const harvesterNetworkOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
];
harvesterNetworks.value.forEach((net) => {
const ns = net.metadata?.namespace || '';
const name = net.metadata?.name || net.id;
const fullName = ns ? `${ ns }/${ name }` : name;
options.push({ label: fullName, value: fullName });
});
return options;
});
const storageClassOptions = computed(() => {
const options = storageClasses.value.map((sc) => ({
label: sc.metadata?.name || sc.id,
value: sc.metadata?.name || sc.id,
}));
if (!options.find((o) => o.value === 'harvester-longhorn')) {
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
}
return options;
});
const applyNetworkMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
networkEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination) {
const dest = match.destination;
if (dest.type === 'pod') {
entry.target = 'pod';
} else if (dest.type === 'ignored') {
entry.target = 'ignored';
} else if (dest.type === 'multus' && dest.name) {
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
}
}
});
};
const applyStorageMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
storageEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination?.storageClass) {
entry.target = match.destination.storageClass;
}
});
};
const applyDefaultNetworkMap = () => {
const defaultMap = allNetworkMaps.value.find(
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
);
applyNetworkMapTargets(defaultMap?.spec?.map);
};
const applyDefaultStorageMap = () => {
const defaultMap = allStorageMaps.value.find(
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map);
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
const canSave = computed(() => {
if (props.useAllProviderData) {
return true;
}
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value;
});
watch(canSave, (val) => {
emit('ready', val);
});
// After restore, check if ready
if (canSave.value) {
emit('ready', true);
}
const buildNetworkEntries = () => {
const networkMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => {
const netKey = net.id || net.name || 'default';
if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ netKey }`,
};
}
if (!networkMap[netKey].usedBy.includes(vmName)) {
networkMap[netKey].usedBy.push(vmName);
}
});
}
});
networkEntries.value = Object.values(networkMap);
};
const buildStorageEntries = () => {
const datastoreMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.disks && vm.disks.length > 0) {
vm.disks.forEach((disk) => {
const ds = disk.datastore;
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
datastoreMap[ds.id].capacity += disk.capacity || 0;
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
datastoreMap[ds.id].usedBy.push(vmName);
}
}
});
}
});
storageEntries.value = Object.values(datastoreMap);
};
const buildNetworkEntriesFromProvider = (networksData) => {
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
name: net.name || net.id,
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ net.id || net.name }`,
}));
};
const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
const formatStorageDetail = (entry) => {
const parts = [];
if (entry.type) {
parts.push(entry.type);
}
if (entry.capacity) {
const gb = entry.capacity / (1024 * 1024 * 1024);
if (gb >= 1024) {
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
} else {
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
}
}
return parts.join(' • ');
};
const buildNetworkMapSpec = (providerRef) => ({
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
provider: providerRef,
});
const buildStorageMapSpec = (providerRef) => ({
map: buildStorageMapEntries(storageEntries.value),
provider: providerRef,
});
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Update existing maps in place (edit mode)
if (props.existingNetworkMap && props.existingStorageMap) {
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
await props.existingNetworkMap.save();
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
await props.existingStorageMap.save();
return {
networkMapName: props.existingNetworkMap.metadata.name,
storageMapName: props.existingStorageMap.metadata.name,
};
}
const providerOwnerRef = props.provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.provider.metadata.name,
uid: props.provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildNetworkMapSpec(providerRef),
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildStorageMapSpec(providerRef),
});
await storageMap.save();
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
};
defineExpose({ saveMappings: saveAndReturn });
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
} catch (e) {
harvesterNetworks.value = [];
}
try {
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch (e) {
storageClasses.value = [];
}
try {
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
} catch (e) {
allNetworkMaps.value = [];
}
try {
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
} catch (e) {
allStorageMaps.value = [];
}
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
try {
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [networksData, datastoresData] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
]);
if (props.useAllProviderData) {
if (networkEntries.value.length === 0) {
buildNetworkEntriesFromProvider(networksData);
}
if (storageEntries.value.length === 0) {
buildStorageEntriesFromProvider(datastoresData);
}
} else {
vms.value = props.selectedVms;
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
}
return resolved;
});
if (networkEntries.value.length === 0) {
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
}
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
}
}
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
if (!hasExistingNetworkTargets) {
if (props.existingNetworkMap?.spec?.map) {
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
} else {
applyDefaultNetworkMap();
}
}
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map);
} else {
applyDefaultStorageMap();
}
}
loading.value = false;
};
init();
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-mappings"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
:entries="networkEntries"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
</template>
</MappingColumn>
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
:entries="storageEntries"
:options="storageClassOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span
v-if="entry.type || entry.capacity"
class="source-detail text-deemphasized"
>{{ formatStorageDetail(entry) }}</span>
</template>
</MappingColumn>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-columns {
display: flex;
flex-direction: column;
gap: 32px;
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
gap: 48px;
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
gap: 64px;
}
}
.configure-mappings {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,538 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { decodeSecretValue } from '../../utils/forklift';
const CREATE_NEW = '__create_new__';
const props = defineProps({
stepData: { type: Object, required: true },
createOnly: { type: Boolean, default: false },
editMode: { type: Boolean, default: false },
});
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
const store = useStore();
const { t } = useI18n(store);
const allProviders = ref([]);
const allSecrets = ref([]);
const errors = ref([]);
const testBtnRef = ref(null);
const loading = ref(true);
const testing = ref(false);
const {
selectedProvider,
providerName,
url,
username,
password,
skipTlsVerify,
testPassed,
testResult,
testError,
createdProvider,
createdSecret,
} = toRefs(props.stepData);
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const providerOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
];
allProviders.value.forEach((p) => {
if (!p.spec?.url) {
return;
}
const secretRef = p.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (!secret?.data?.user || !secret?.data?.password) {
return;
}
} else {
return;
}
options.push({
label: p.metadata.name,
value: p.metadata.name,
});
});
return options;
});
// When the provider selection changes, populate or clear the fields
watch(selectedProvider, (val) => {
testPassed.value = false;
testResult.value = null;
testError.value = null;
if (val === CREATE_NEW) {
providerName.value = '';
url.value = '';
username.value = '';
password.value = '';
skipTlsVerify.value = false;
createdProvider.value = null;
createdSecret.value = null;
} else {
const provider = allProviders.value.find(
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
);
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
const secretRef = provider.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
username.value = decodeSecretValue(secret.data.user);
password.value = decodeSecretValue(secret.data.password);
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
}
}
createdProvider.value = provider;
}
}
});
// Reset test state when any field changes (only for create new)
watch([providerName, url, username, password], () => {
if (!isExistingProvider.value) {
testPassed.value = false;
testResult.value = null;
}
});
// Emit ready/complete when testPassed changes
watch(testPassed, (val) => {
emit('ready', val);
if (val) {
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
}
}, { immediate: true });
// Emit form-valid when form validity changes
watch(isFormValid, (val) => {
emit('form-valid', val);
}, { immediate: true });
watch(testing, (val) => {
emit('testing', val);
}, { immediate: true });
const pollProviderReady = async(name) => {
const inStore = store.getters['currentProduct'].inStore;
const namespace = FORKLIFT_NAMESPACE;
const maxAttempts = 15;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ name }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
const readyCondition = conditions.find((c) => c.type === 'Ready');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
return { connected: true };
}
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
}
if (readyCondition) {
if (readyCondition.status === 'True') {
return { connected: true };
} else if (readyCondition.status === 'False') {
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
}
}
}
return { connected: false, errorMsg: '' };
};
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
};
const cleanupCreatedResources = async() => {
if (createdProvider.value) {
try {
await createdProvider.value.remove();
} catch (e) {}
createdProvider.value = null;
createdSecret.value = null;
} else if (createdSecret.value) {
try {
await createdSecret.value.remove();
} catch (e) {}
createdSecret.value = null;
}
};
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
testing.value = false;
buttonCb(false);
return;
}
const inStore = store.getters['currentProduct'].inStore;
// Edit mode: update existing provider URL + secret, then poll
if (props.editMode && createdProvider.value) {
try {
createdProvider.value.spec.url = url.value;
await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret;
if (secretRef && createdSecret.value) {
createdSecret.value.data = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await createdSecret.value.save();
}
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For new providers, create provider + secret then poll
try {
await cleanupCreatedResources();
const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec: {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
}
});
await provider.save();
createdProvider.value = provider;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
},
],
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await newSecret.save();
createdSecret.value = newSecret;
const result = await pollProviderReady(providerName.value);
if (!result.connected) {
await cleanupCreatedResources();
}
handlePollResult(result, buttonCb);
} catch (err) {
await cleanupCreatedResources();
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
}
loading.value = false;
};
init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-provider-step"
>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
</p>
<div class="configure-provider-step-content">
<h3 class="table-title m-0">
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
</h3>
<Banner
class="requirements-banner m-0"
color="info"
>
<div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
<br>
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
</div>
</Banner>
<div class="configure-provider-step-form">
<div v-if="!createOnly && !editMode">
<LabeledSelect
v-model:value="selectedProvider"
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider || editMode"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
:disabled="editMode"
required
/>
</div>
<div>
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
</div>
<div>
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider && !editMode"
/>
<p class="text-deemphasized ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p>
</div>
<div>
<AsyncButton
ref="testBtnRef"
mode="test"
:disabled="!isFormValid"
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
:action-color="'role-secondary'"
:waiting-color="'role-disabled'"
@click="testConnection"
/>
</div>
<Banner
v-if="testResult"
color="success"
>
{{ testResult }}
</Banner>
<Banner
v-if="testError"
color="error"
>
{{ testError }}
</Banner>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
>
{{ err }}
</Banner>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.requirements-banner {
font-weight: 400;
.requirements-banner-title {
font-weight: 600;
}
}
.configure-provider-step {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
.configure-provider-step-content {
display: flex;
flex-direction: column;
gap: 12px;
.configure-provider-step-form {
display: flex;
flex-direction: column;
gap: 20px;
}
}
:deep(.checkbox-label) {
color: var(--body-text);
}
}
</style>
@@ -0,0 +1,184 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
const store = useStore();
const { t } = useI18n(store);
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
});
const selectOptions = computed(() => {
if (!props.clearable) {
return props.options;
}
return [
{
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
value: null,
kind: 'highlighted',
},
{
label: 'divider',
disabled: true,
kind: 'divider',
},
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options,
];
});
</script>
<template>
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ title }}
</h3>
<p
v-if="description"
class="text-deemphasized line-height-20"
>
{{ description }}
</p>
</div>
<RcItemCard
v-for="entry in entries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
>
<template #item-card-content>
<div class="card-content-column">
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<slot
name="source-detail"
:entry="entry"
/>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
<i
class="icon icon-right-arrow-alt"
aria-hidden="true"
/>
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="selectOptions"
:placeholder="placeholder+'...'"
:searchable="true"
/>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
</template>
<style lang="scss" scoped>
.mapping-column {
display: flex;
gap: 12px;
flex-direction: column;
:deep(.item-card-header) {
display: none;
}
}
.mapping-section-title {
font-weight: 600;
margin: 0 0 5px 0;
line-height: 28px;
}
.card-content-row {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
flex: 2;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 3;
min-width: 0;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
</style>
@@ -0,0 +1,439 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import {
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
} from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
mappingEntries: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
const NAMESPACE = FORKLIFT_NAMESPACE;
const TARGET_NAMESPACE = 'default';
watch(planName, (val) => {
props.stepData.planName = val;
emit('ready', !!val);
}, { immediate: true });
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => {
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return mbToGB(totalMB);
});
const totalStorageGB = computed(() => {
let totalBytes = 0;
vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) {
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
});
return bytesToGB(totalBytes);
});
const vmCards = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
return {
id: vm.id,
name: vm.name || vm.id,
os,
cpus,
memGB,
diskDisplay,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
};
});
});
const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
const networkMapName = `${ planName.value }-network-map`;
const storageMapName = `${ planName.value }-storage-map`;
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NAMESPACE },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NAMESPACE },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
});
await storageMap.save();
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NAMESPACE },
spec: {
provider: providerRef,
map: {
network: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: networkMapName,
namespace: NAMESPACE,
},
storage: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: storageMapName,
namespace: NAMESPACE,
},
},
targetNamespace: TARGET_NAMESPACE,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.save();
const planOwnerRef = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
networkMap.metadata.ownerReferences = [planOwnerRef];
await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
};
const init = () => {
vms.value = props.selectedVms;
if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
}
loading.value = false;
};
init();
defineExpose({ startMigration: startMigrationAction });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<div
class="review-migration-content"
>
<!-- Migration Details Summary -->
<div class="migration-details">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<div class="span-9">
<LabeledInput
v-model:value="planName"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
</div>
<div class="details-grid span-9">
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value detail-value-large">{{ vms.length }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ providerName }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ TARGET_NAMESPACE }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div>
</div>
<div class="detail-column grid-column-2">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
</span>
</div>
</div>
</div>
</div>
<!-- Virtual Machines -->
<div class="vm-section">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h3>
<div class="vm-cards-grid">
<RcItemCard
v-for="vm in vmCards"
:id="vm.id"
:key="vm.id"
:variant="'small'"
:header="{ title: { text: vm.name } }"
>
<template #item-card-content>
<div class="vm-card-content">
<div class="vm-card-specs">
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
<span class="vm-resources">
<i
class="icon icon-disk"
aria-hidden="true"
/>
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
</span>
</div>
<MappingsCell
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
/>
</div>
</template>
</RcItemCard>
</div>
</div>
<!-- Cold Migration Warning -->
<Banner
color="warning"
class="m-0"
>
<div>
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br>
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
</div>
</Banner>
<!-- Error banner -->
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-migration {
gap: 36px;
}
.review-migration-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
font-weight: 600;
}
.details-grid {
display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px clamp(64px, 8vw, 128px);
line-height: 20px;
}
.detail-column {
display: flex;
flex-direction: column;
gap: 4px;
&.grid-column-2 {
grid-column: span 2;
}
}
.detail-item {
display: flex;
flex-direction: row;
justify-content: space-between;
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
&.detail-value-large {
font-size: 24px;
}
}
}
.vm-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.vm-card-content {
display: flex;
flex-direction: column;
gap: 16px;
line-height: 20px;
}
.vm-card-specs {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.vm-os {
font-size: 14px;
}
.vm-resources {
font-size: 14px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.banner-title {
font-weight: 600;
}
.migration-details {
display: flex;
flex-direction: column;
gap: 12px;
}
header {
margin: 0;
}
.vm-section {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
@@ -0,0 +1,578 @@
<script setup>
import {
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
} from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { useI18n } from '@shell/composables/useI18n';
import { bytesToGB, mbToGB } from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['complete', 'loading']);
const store = useStore();
const { t } = useI18n(store);
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]);
const loading = ref(true);
const networkMap = ref({});
const datastoreMap = ref({});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const errors = ref([]);
let skipNextSelectionEvent = false;
const lastFetchedAt = ref(null);
const now = ref(Date.now());
const formatElapsed = (ms) => {
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
}
if (hours > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
}
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
};
const lastSyncedTime = computed(() => {
if (!lastFetchedAt.value) {
return '';
}
return formatElapsed(now.value - lastFetchedAt.value);
});
const nowTimer = setInterval(() => {
now.value = Date.now();
}, 30000);
onBeforeUnmount(() => {
clearInterval(nowTimer);
});
// Restore selection state from stepData
if (selectedVMIds.value.size > 0) {
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
}
const vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length);
const showSelectAllBanner = computed(() => {
if (allVMsSelected.value) {
return true;
}
const pagedRows = sortableTableRef.value?.pagedRows || [];
if (pagedRows.length === 0) {
return false;
}
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
return allPageSelected && tableRows.value.length > pagedRows.length;
});
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os',
sort: ['os'],
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources',
sort: false,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState',
sort: ['powerState'],
},
{
name: 'network',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
];
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
let networks = [];
if (vm.networks && vm.networks.length > 0) {
networks = vm.networks.map((n) => ({
name: n.name || networkMap.value[n.id] || n.id || n,
id: n.id || '',
vlanId: n.vlanId || '',
}));
}
const datastores = [];
if (vm.disks && vm.disks.length > 0) {
const seen = new Set();
vm.disks.forEach((d) => {
const dsId = d.datastore?.id || d.datastore?.name;
if (dsId && !seen.has(dsId)) {
seen.add(dsId);
datastores.push({
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
id: d.datastore?.id || '',
type: d.datastore?.type || '',
capacity: d.capacity || 0,
});
}
});
}
return {
_original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name,
vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-',
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
networks,
network: networks.map((n) => n.name).join(', ') || '-',
datastores,
datastore: datastores.map((d) => d.name).join(', ') || '-',
};
});
};
const onSelect = (rows) => {
if (skipNextSelectionEvent) {
return;
}
const currentPageRows = sortableTableRef.value?.pagedRows || [];
const selectedKeys = new Set(rows.map((r) => r._key));
currentPageRows.forEach((row) => {
const vmId = row._original?.id;
if (selectedKeys.has(row._key)) {
selectedVMIds.value.add(vmId);
} else {
selectedVMIds.value.delete(vmId);
}
});
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
emit('complete', { selectedVMs: selectedVMs.value });
};
const clearSelection = () => {
allVMsSelected.value = false;
selectedVMIds.value = new Set();
selectedVMs.value = [];
nextTick(() => {
const table = sortableTableRef.value;
if (table) {
table.clearSelection();
}
});
};
const selectAllVMs = () => {
allVMsSelected.value = true;
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
selectedVMs.value = discoveredVMs.value.slice();
};
watch(
() => sortableTableRef.value?.page,
() => {
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
return;
}
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
}
);
const refreshing = ref(false);
const isLoading = computed(() => loading.value || refreshing.value);
watch(isLoading, (val) => {
emit('loading', val);
}, { immediate: true });
const fetchVMs = async() => {
if (!props.provider) {
return;
}
const inStore = store.getters['currentProduct'].inStore;
const providerUid = props.provider.metadata.uid;
const providerType = props.provider.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
]);
if (Array.isArray(vmsResp)) {
discoveredVMs.value = vmsResp;
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
discoveredVMs.value = vmsResp.data;
}
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
networkMap.value = networks.reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
datastoreMap.value = datastores.reduce((map, d) => {
map[d.id] = d.name;
return map;
}, {});
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
const previousSelectedIds = new Set(selectedVMIds.value);
try {
errors.value = [];
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
}
selectedVMIds.value = new Set(
discoveredVMs.value
.filter((vm) => previousSelectedIds.has(vm.id))
.map((vm) => vm.id)
);
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
props.stepData.discoveredVMs = discoveredVMs.value;
props.stepData.selectedVMIds = selectedVMIds.value;
props.stepData.tableRows = tableRows.value;
emit('complete', { selectedVMs: selectedVMs.value });
refreshing.value = false;
await nextTick();
const table = sortableTableRef.value;
if (table) {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
}
skipNextSelectionEvent = false;
};
const init = async() => {
if (discoveredVMs.value.length > 0) {
if (!lastFetchedAt.value) {
lastFetchedAt.value = Date.now();
}
tableRows.value = buildTableRows();
loading.value = false;
return;
}
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
}
loading.value = false;
};
// Emit initial complete if VMs are already selected (restored from stepData)
if (selectedVMs.value.length > 0) {
emit('complete', { selectedVMs: selectedVMs.value });
}
init();
</script>
<template>
<Loading v-if="loading || refreshing" />
<div
v-else
class="select-vms-step"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link
v-if="provider"
class="provider-link"
:to="provider._detailLocation"
>
{{ providerName }}
</router-link>
<br>
<span
v-if="lastSyncedTime"
>
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
<a
role="button"
class="text-bold"
:class="{ disabled: refreshing }"
@click.prevent="refreshVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
</a>
</span>
</p>
<!-- Discovered VMs table -->
<SortableTable
ref="sortableTableRef"
:rows="tableRows"
:headers="headers"
:search="true"
:table-actions="true"
:row-actions="false"
:groupable="false"
:paging="true"
:rows-per-page="20"
key-field="_key"
@selection="onSelect"
>
<template #header-left>
<div class="vm-table-title">
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
</div>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.vmName }}</span>
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resourcesDisplay }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:network="{ row }">
<div>
<div
v-for="(net, i) in row.networks"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ net.name }}</span><br>
<span class="text-deemphasized">{{ net.id }}</span>
</div>
</div>
</template>
<template #cell:datastore="{ row }">
<div>
<div
v-for="(ds, i) in row.datastores"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ ds.name }}</span><br>
<span class="text-deemphasized">{{ ds.id }}</span>
</div>
</div>
</template>
</SortableTable>
<Teleport
v-if="showSelectAllBanner && theadElement"
:to="theadElement"
>
<tr class="select-all-banner-row">
<td
:colspan="headers.length + 1"
class="select-all-banner-cell"
>
<template v-if="allVMsSelected">
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a>
</template>
<template v-else>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a
role="button"
@click.prevent="selectAllVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a>
</template>
</td>
</tr>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.vm-table-title {
.text-deemphasized {
font-size: 13px;
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
font-size: 14px;
.vm-name {
line-height: 20px;
}
.vm-id {
line-height: 20px;
}
}
.select-vms-step {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
.line-height-20 {
line-height: 20px;
}
}
:deep(.select-all-banner-row) {
.select-all-banner-cell {
text-align: center;
padding: 8px 16px;
background-color: var(--sortable-table-header-bg);
border-bottom: 1px solid var(--border);
font-size: 13px;
a {
color: var(--primary);
cursor: pointer;
font-weight: 600;
margin-left: 5px;
&:hover {
text-decoration: underline;
}
}
}
}
</style>
+7 -2
View File
@@ -66,10 +66,15 @@ const FEATURE_FLAGS = {
'instanceManagerResourcesSetting',
'rwxNetworkSetting',
'createPVCWithDataVolume',
'clusterPodSecurityStandardSetting'
'clusterPodSecurityStandardSetting',
],
'v1.8.1': [],
'v1.9.0': [],
'v1.9.0': [
'supportFilesystem',
'disableResourcePooling',
'expandOnlineEncryptedVolume',
'longhornV2HugepageSettings'
],
};
const generateFeatureFlags = () => {
+380 -9
View File
@@ -47,6 +47,13 @@ import {
VM_IMPORT_SOURCE_O_STATUS,
VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
FORKLIFT_MIGRATION_PLAN,
} from './table-headers';
import { ADD_ONS } from './harvester-map';
import { registerAddonSideNav } from '../utils/dynamic-nav';
@@ -54,6 +61,14 @@ import { registerAddonSideNav } from '../utils/dynamic-nav';
const TEMPLATE = HCI.VM_VERSION;
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
const LOGGING_GROUP = 'Monitoring & Logging::Logging';
const OVERLAY_NETWORKS_GROUP = 'Overlay Networks';
const UNDERLAY_NETWORKS_GROUP = 'Underlay Networks';
const NAT_INTERNET_GROUP = `${ OVERLAY_NETWORKS_GROUP }::NAT & Internet`;
const GATEWAYS_GROUP = `${ NAT_INTERNET_GROUP }::Gateways`;
const EXTERNAL_IPS_GROUP = `${ NAT_INTERNET_GROUP }::External IPs`;
const RULES_GROUP = `${ NAT_INTERNET_GROUP }::Rules`;
const SOURCE_RULES_GROUP = `${ RULES_GROUP }::Source Rules`;
const DESTINATION_RULES_GROUP = `${ RULES_GROUP }::Destination Rules`;
export const PRODUCT_NAME = 'harvester';
@@ -237,6 +252,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimport',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT }
@@ -266,6 +282,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_V,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V }
@@ -295,6 +312,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_O,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O }
@@ -323,6 +341,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_OVA,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
@@ -484,6 +503,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_FLOW,
labelKey: 'harvester.logging.clusterFlow.label',
name: HCI.CLUSTER_FLOW,
namespaced: true,
@@ -507,6 +527,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_OUTPUT,
labelKey: 'harvester.logging.clusterOutput.label',
name: HCI.CLUSTER_OUTPUT,
namespaced: true,
@@ -530,6 +551,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.FLOW,
labelKey: 'harvester.logging.flow.label',
name: HCI.FLOW,
namespaced: true,
@@ -553,6 +575,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.OUTPUT,
labelKey: 'harvester.logging.output.label',
name: HCI.OUTPUT,
namespaced: true,
@@ -573,14 +596,53 @@ export function init($plugin, store) {
[
HCI.CLUSTER_NETWORK,
HCI.NETWORK_ATTACHMENT,
HCI.VPC,
NETWORK_POLICY,
HCI.HOST_NETWORK_CONFIG,
HCI.LB,
HCI.IP_POOL,
],
'networks'
);
basicType(
[HCI.VPC],
OVERLAY_NETWORKS_GROUP
);
basicType(
[NETWORK_POLICY],
OVERLAY_NETWORKS_GROUP
);
basicType(
[HCI.VPC_NAT_GATEWAY],
GATEWAYS_GROUP
);
basicType(
[HCI.IPTABLES_EIP],
EXTERNAL_IPS_GROUP
);
basicType(
[HCI.IPTABLES_SNAT_RULE],
SOURCE_RULES_GROUP
);
basicType(
[HCI.IPTABLES_DNAT_RULE],
DESTINATION_RULES_GROUP
);
basicType(
[HCI.PROVIDER_NETWORK],
UNDERLAY_NETWORKS_GROUP
);
basicType(
[HCI.VLAN],
UNDERLAY_NETWORKS_GROUP
);
basicType(
[
HCI.SCHEDULE_VM_BACKUP,
@@ -592,7 +654,11 @@ export function init($plugin, store) {
);
weightGroup('networks', 494, true);
weightGroup('backupAndSnapshot', 493, true);
weightGroup('Overlay Networks', 493, true);
weightGroup('NAT & Internet', 492, true);
weightGroup('Rules', 491, true);
weightGroup('Underlay Networks', 490, true);
weightGroup('backupAndSnapshot', 489, true);
basicType(
[
@@ -671,7 +737,7 @@ export function init($plugin, store) {
name: HCI.CLUSTER_NETWORK,
ifHaveType: HCI.CLUSTER_NETWORK,
namespaced: false,
weight: 189,
weight: 484,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.CLUSTER_NETWORK }
@@ -693,7 +759,7 @@ export function init($plugin, store) {
labelKey: 'harvester.network.label',
name: HCI.NETWORK_ATTACHMENT,
namespaced: true,
weight: 188,
weight: 485,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.NETWORK_ATTACHMENT }
@@ -707,7 +773,7 @@ export function init($plugin, store) {
labelKey: 'harvester.vpc.label',
name: HCI.VPC,
namespaced: true,
weight: 187,
weight: 195,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VPC }
@@ -716,13 +782,73 @@ export function init($plugin, store) {
ifHaveType: HCI.VPC,
});
configureType(HCI.VPC_NAT_GATEWAY, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.natGateway.label',
name: HCI.VPC_NAT_GATEWAY,
namespaced: false,
weight: 193,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VPC_NAT_GATEWAY }
},
exact: false,
ifHaveType: HCI.VPC_NAT_GATEWAY,
});
configureType(HCI.IPTABLES_EIP, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.externalIP.label',
name: HCI.IPTABLES_EIP,
namespaced: false,
weight: 192,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IPTABLES_EIP }
},
exact: false,
ifHaveType: HCI.IPTABLES_EIP,
});
configureType(HCI.IPTABLES_SNAT_RULE, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.snat.label',
name: HCI.IPTABLES_SNAT_RULE,
namespaced: false,
weight: 191,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IPTABLES_SNAT_RULE }
},
exact: false,
ifHaveType: HCI.IPTABLES_SNAT_RULE,
});
configureType(HCI.IPTABLES_DNAT_RULE, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.dnat.label',
name: HCI.IPTABLES_DNAT_RULE,
namespaced: false,
weight: 190,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IPTABLES_DNAT_RULE }
},
exact: false,
ifHaveType: HCI.IPTABLES_DNAT_RULE,
});
configureType(NETWORK_POLICY, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.networkPolicy.label',
name: NETWORK_POLICY,
namespaced: true,
weight: 186,
weight: 194,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: NETWORK_POLICY }
@@ -731,6 +857,53 @@ export function init($plugin, store) {
ifHaveType: NETWORK_POLICY,
});
configureType(HCI.PROVIDER_NETWORK, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.providerNetwork.label',
name: HCI.PROVIDER_NETWORK,
namespaced: false,
weight: 189,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.PROVIDER_NETWORK }
},
exact: false,
ifHaveType: HCI.PROVIDER_NETWORK,
});
configureType(HCI.VLAN, { hiddenNamespaceGroupButton: true, canYaml: false });
headers(HCI.VLAN, [
STATE,
NAME_COL,
{
name: 'id',
label: 'ID',
value: 'spec.id',
sort: 'spec.id'
},
{
name: 'provider',
labelKey: 'harvester.subnet.provider.label',
value: 'spec.provider',
sort: 'spec.provider'
}
]);
virtualType({
labelKey: 'harvester.vlanNetwork.label',
name: HCI.VLAN,
namespaced: false,
weight: 188,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VLAN }
},
exact: false,
ifHaveType: HCI.VLAN,
});
configureType(HCI.SNAPSHOT, {
isCreatable: false,
location: {
@@ -1085,7 +1258,7 @@ export function init($plugin, store) {
labelKey: 'harvester.loadBalancer.label',
name: HCI.LB,
namespaced: true,
weight: 185,
weight: 483,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.LB }
@@ -1124,7 +1297,7 @@ export function init($plugin, store) {
labelKey: 'harvester.ipPool.label',
name: HCI.IP_POOL,
namespaced: false,
weight: 184,
weight: 482,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IP_POOL }
@@ -1133,4 +1306,202 @@ export function init($plugin, store) {
ifHaveType: HCI.IP_POOL,
});
headers(HCI.IP_POOL, IP_POOL_HEADERS);
configureType(HCI.HOST_NETWORK_CONFIG, {
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST_NETWORK_CONFIG }
},
canYaml: false,
});
virtualType({
labelKey: 'harvester.hostNetworkConfig.label',
name: HCI.HOST_NETWORK_CONFIG,
namespaced: false,
weight: 481,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST_NETWORK_CONFIG }
},
exact: false,
ifHaveType: HCI.HOST_NETWORK_CONFIG,
});
// ===========================================================================
// Forklift Addon UI Flow
// ===========================================================================
weightGroup('vmMigration', 0, false);
// Provider
headers(HCI.FORKLIFT_PROVIDER, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
AGE
]);
configureType(HCI.FORKLIFT_PROVIDER, {
resource: HCI.FORKLIFT_PROVIDER,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'vmMigration',
namespaced: true,
weight: 100,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
// NetworkMap
headers(HCI.FORKLIFT_NETWORK_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_NETWORK_MAP, {
resource: HCI.FORKLIFT_NETWORK_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
// StorageMap
headers(HCI.FORKLIFT_STORAGE_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_STORAGE_MAP, {
resource: HCI.FORKLIFT_STORAGE_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
// Plan
headers(HCI.FORKLIFT_PLAN, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
AGE
]);
configureType(HCI.FORKLIFT_PLAN, {
resource: HCI.FORKLIFT_PLAN,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
// Migration
headers(HCI.FORKLIFT_MIGRATION, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MIGRATION_PLAN,
AGE
]);
configureType(HCI.FORKLIFT_MIGRATION, {
resource: HCI.FORKLIFT_MIGRATION,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({
name: 'forklift-create',
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'vmMigration',
namespaced: true,
weight: 200,
route: {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {}
}
});
// Register the dashboard entry directly — it's a virtual type with no schema,
// so it cannot go through registerAddonSideNav (which filters by schema).
basicType(['forklift-create'], 'vmMigration');
// Enable SideNav based on Forklift Addon Status
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
types: [
HCI.FORKLIFT_PROVIDER,
]
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration::Advanced',
types: [
HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP,
HCI.FORKLIFT_PLAN,
HCI.FORKLIFT_MIGRATION,
]
});
// ===========================================================================
}
+6
View File
@@ -79,6 +79,7 @@ export const ADD_ONS = {
VM_IMPORT_CONTROLLER: 'vm-import-controller',
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
KUBEOVN_OPERATOR: 'kubeovn-operator',
FORKLIFT_OPERATOR: 'forklift-operator',
};
export const CSI_SECRETS = {
@@ -88,8 +89,13 @@ export const CSI_SECRETS = {
CSI_NODE_PUBLISH_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-publish-secret-namespace',
CSI_NODE_STAGE_SECRET_NAME: 'csi.storage.k8s.io/node-stage-secret-name',
CSI_NODE_STAGE_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-stage-secret-namespace',
CSI_NODE_EXPAND_SECRET_NAME: 'csi.storage.k8s.io/node-expand-secret-name',
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
};
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
export const FORKLIFT_NAMESPACE = 'forklift';
// Some harvester CRD type is not equal to model file name, define the mapping here
export const HARVESTER_CRD_MAP = {
node: HCI.HOST,
@@ -19,6 +19,7 @@ export const HCI = {
NETWORK_TYPE: 'network.harvesterhci.io/type',
VM_NAME: 'harvesterhci.io/vmName',
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
VM_DISPLAY_NAME: 'harvesterhci.io/vmDisplayName',
VM_RESERVED_MEMORY: 'harvesterhci.io/reservedMemory',
MAINTENANCE_STATUS: 'harvesterhci.io/maintain-status',
HOST_CUSTOM_NAME: 'harvesterhci.io/host-custom-name',
@@ -81,4 +82,6 @@ export const HCI = {
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',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
};
+54 -44
View File
@@ -1,48 +1,50 @@
export const HCI_SETTING = {
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'vlan',
UI_SOURCE: 'ui-source',
UI_PL: 'ui-pl',
HTTP_PROXY: 'http-proxy',
ADDITIONAL_CA: 'additional-ca',
OVERCOMMIT_CONFIG: 'overcommit-config',
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'vlan',
UI_SOURCE: 'ui-source',
UI_PL: 'ui-pl',
HTTP_PROXY: 'http-proxy',
ADDITIONAL_CA: 'additional-ca',
OVERCOMMIT_CONFIG: 'overcommit-config',
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED: 'longhorn-v2-data-engine-hugepage-enabled',
LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE: 'longhorn-v2-data-engine-memory-size',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
};
export const HCI_ALLOWED_SETTINGS = {
@@ -112,6 +114,14 @@ export const HCI_ALLOWED_SETTINGS = {
experimental: true,
featureFlag: 'longhornV2LVMSupport'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: {
kind: 'boolean',
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: {
kind: 'number',
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
[HCI_SETTING.UPGRADE_CONFIG]: {
kind: 'json',
+67
View File
@@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
sort: 'status.status',
align: 'left',
};
// ========================================
// Forklift table headers
// ========================================
// Provider type column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_TYPE = {
name: 'providerType',
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
value: 'spec.type',
sort: 'spec.type',
align: 'left',
};
// Provider URL column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_URL = {
name: 'providerUrl',
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
};
// Source provider column in forklift network/storage map list page
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
name: 'sourceProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
value: 'spec.provider.source.name',
sort: 'spec.provider.source.name',
align: 'left',
};
// Destination provider column in forklift network/storage map list page
export const FORKLIFT_MAP_DEST_PROVIDER = {
name: 'destProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
value: 'spec.provider.destination.name',
sort: 'spec.provider.destination.name',
align: 'left',
};
// Target namespace column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_TARGET_NS = {
name: 'targetNamespace',
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
value: 'spec.targetNamespace',
sort: 'spec.targetNamespace',
align: 'left',
};
// VM count column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_VM_COUNT = {
name: 'vmCount',
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'left',
};
// Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan',
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
value: 'spec.plan.name',
sort: 'spec.plan.name',
align: 'left',
};
+6
View File
@@ -46,3 +46,9 @@ export const CDI_POPULATOR_KIND = {
VOLUME_IMPORT_SOURCE: 'VolumeImportSource',
VOLUME_CLONE_SOURCE: 'VolumeCloneSource',
};
export const FILESYSTEM_SOURCE_TYPE = {
CONFIGMAP: 'configmap',
SECRET: 'secret',
SERVICEACCOUNT: 'serviceaccount',
};
@@ -27,15 +27,11 @@ export default {
await allHash({
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
vmis: this.$store.dispatch('harvester/findAll', { type: HCI.VMI }),
allClusterNetwork: this.$store.dispatch('harvester/findAll', { type: HCI.CLUSTER_NETWORK }),
vmims: this.$store.dispatch('harvester/findAll', { type: HCI.VMIM }),
});
},
computed: {
allClusterNetwork() {
return this.$store.getters['harvester/all'](HCI.CLUSTER_NETWORK);
},
rows() {
const vms = this.$store.getters['harvester/all'](HCI.VM);
@@ -108,7 +104,6 @@ export default {
<HarvesterVmState
class="vmstate"
:row="scope.row"
:all-cluster-network="allClusterNetwork"
/>
</div>
</template>
@@ -98,9 +98,9 @@ export default {
methods: {
escapeHtml,
close() {
close(data) {
this.errors = [];
this.$emit('close');
this.$emit('close', data);
},
async apply(buttonDone) {
@@ -109,7 +109,7 @@ export default {
await resource.doActionGrowl(this.modalData.action, {});
}
buttonDone(true);
this.close();
this.close({ performCallback: true, clearTableSelection: true });
} catch (e) {
this.errors = exceptionToErrorsArray(e);
buttonDone(false);
+29 -6
View File
@@ -1,6 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import { Card } from '@components/Card';
import { Checkbox } from '@components/Form/Checkbox';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
@@ -13,7 +15,9 @@ export default {
components: {
AsyncButton,
Banner,
Card,
Checkbox,
},
props: {
@@ -24,10 +28,16 @@ export default {
},
data() {
return {};
return { disableResourcePooling: false };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
computed: {
...mapGetters({ t: 'i18n/t' }),
disableResourcePoolingEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('disableResourcePooling');
},
},
methods: {
close() {
@@ -52,9 +62,10 @@ export default {
}]
},
spec: {
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName,
disableResourcePooling: this.disableResourcePooling,
}
} );
@@ -85,7 +96,19 @@ export default {
</template>
<template #body>
{{ t('harvester.pci.enablePassthroughWarning') }}
<p class="mb-20">
{{ t('harvester.pci.enablePassthroughWarning') }}
</p>
<template v-if="disableResourcePoolingEnabled">
<Checkbox
v-model:value="disableResourcePooling"
label-key="harvester.pci.disableResourcePooling"
/>
<Banner
color="info"
:label="t('harvester.pci.disableResourcePoolingDescription')"
/>
</template>
</template>
<template #actions>
@@ -1,15 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import { NODE } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI } from '../types';
export default {
emits: ['close'],
@@ -62,28 +59,46 @@ export default {
return this.resources[0];
},
vmi() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vmiResources = this.$store.getters[`${ inStore }/all`](HCI.VMI);
const resource = vmiResources.find((VMI) => VMI.id === this.actionResource?.id) || null;
anyCpuPinning() {
return this.resources.some((r) => r.isCpuPinning);
},
return resource;
vmsByNode() {
const groups = {};
for (const r of this.resources) {
const node = r.nodeName || '';
const name = r.nameDisplay || r.name || r.id;
if (!groups[node]) {
groups[node] = [];
}
groups[node].push(name);
}
return Object.entries(groups).map(([node, vms]) => ({ node, vms })).sort((a, b) => a.node.localeCompare(b.node));
},
cpuPinningAlertMessage() {
return this.t('harvester.virtualMachine.cpuPinning.migrationMessage');
},
allVmsOnTargetNode() {
if (!this.nodeName) {
return false;
}
return this.resources.every((r) => r.nodeName === this.nodeName);
},
nodeNameList() {
const nodes = this.$store.getters['harvester/all'](NODE);
return nodes.filter((n) => {
const isNotSelfNode = !!this.availableNodes.includes(n.id);
const isNotWitnessNode = n.isEtcd !== 'true'; // do not allow to migrate to self node and witness node
const isCpuPinning = this.actionResource?.isCpuPinning;
const matchingCpuManagerConfig = !isCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
const matchingCpuManagerConfig = !this.anyCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
return isNotSelfNode && isNotWitnessNode && matchingCpuManagerConfig;
return isNotWitnessNode && matchingCpuManagerConfig;
}).map((n) => {
let label = n?.metadata?.name;
const value = n?.metadata?.name;
@@ -102,10 +117,10 @@ export default {
},
methods: {
close() {
close(data) {
this.nodeName = '';
this.errors = [];
this.$emit('close');
this.$emit('close', data);
},
async apply(buttonDone) {
@@ -126,10 +141,32 @@ export default {
}
try {
await this.actionResource.doAction('migrate', { nodeName: this.nodeName }, {}, false);
// Filter out VMs already running on the selected node
const toMigrate = this.resources.filter((r) => r.nodeName !== this.nodeName);
// await Promise.allSettled(toMigrate.map((r) => r.doAction('migrate', { nodeName: this.nodeName }, {}, false)));
// We want to show all migration errors if there are multiple VMs, so we use allSettled here and handle the results accordingly.
const results = await Promise.allSettled(toMigrate.map((r) => r.doAction('migrate', { nodeName: this.nodeName }, {}, false)));
const failedMigrations = results
.map((result, index) => ({ resource: toMigrate[index], result }))
.filter(({ result }) => result.status === 'rejected');
if (failedMigrations.length) {
this['errors'] = failedMigrations.flatMap(({ resource, result }) => {
const vmName = resource?.nameDisplay || resource?.name || resource?.metadata?.name || this.$store.getters['i18n/t']('generic.unknown');
const error = result.reason?.data || result.reason;
const messages = exceptionToErrorsArray(error);
return messages.map((message) => `${ vmName }: ${ message }`);
});
buttonDone(false);
return;
}
buttonDone(true);
this.close();
this.close({ performCallback: true, clearTableSelection: true });
} catch (err) {
const error = err?.data || err;
const message = exceptionToErrorsArray(error);
@@ -146,17 +183,35 @@ export default {
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.migration.title') }}
{{ t('harvester.modal.migration.vmMigrationTitle', { count: resources.length }) }}
</template>
<template #body>
<Banner
v-if="actionResource?.isCpuPinning"
v-if="anyCpuPinning"
color="warning"
:label="cpuPinningAlertMessage"
/>
<p>
{{ t('harvester.modal.migration.selectedVMs') }}
</p>
<ul class="vm-list">
<li
v-for="group in vmsByNode"
:key="group.node"
>
{{ group.node || t('harvester.modal.migration.unknownNode') }}: {{ group.vms.join(', ') }}
<span
v-if="nodeName && group.node === nodeName"
class="already-on-target"
>
({{ t('harvester.modal.migration.alreadyOnTarget') }})
</span>
</li>
</ul>
<LabeledSelect
v-model:value="nodeName"
class="mt-15"
:label="t('harvester.modal.migration.fields.nodeName.label')"
:placeholder="t('harvester.modal.migration.fields.nodeName.placeholder')"
:options="nodeNameList"
@@ -183,7 +238,7 @@ export default {
<AsyncButton
mode="apply"
:disabled="!nodeName"
:disabled="!nodeName || allVmsOnTargetNode"
@click="apply"
/>
</div>
@@ -201,4 +256,16 @@ export default {
justify-content: flex-end;
width: 100%;
}
.already-on-target {
color: var(--warning);
font-style: italic;
}
.vm-list {
list-style: disc;
padding-left: 1.5em;
margin-bottom: 10px;
margin-top: 10px;
}
</style>
@@ -0,0 +1,24 @@
<script>
import { h } from 'vue';
import { PRODUCT_NAME } from '../config/harvester';
export default {
name: 'ForkliftProviderEdit',
created() {
const { namespace, id } = this.$route.params;
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
this.$router.replace({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$route.params.product,
cluster: this.$route.params.cluster,
},
query,
});
},
render: () => h('div'),
};
</script>
@@ -108,6 +108,7 @@ export default {
<YamlEditor
ref="yamlUser"
v-model:value="config"
:mode="mode"
class="yaml-editor"
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
@onChanges="update"
@@ -9,6 +9,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { NAMESPACE, NODE } from '@shell/config/types';
import { HCI } from '../types';
import { NETWORK_TYPE, L2VLAN_MODE } from '../config/types';
import { removeObject } from '@shell/utils/array';
@@ -20,6 +21,7 @@ const { ACCESS, TRUNK } = L2VLAN_MODE;
const AUTO = 'auto';
const MANUAL = 'manual';
const KUBE_SYSTEM = 'kube-system';
export default {
emits: ['update:value'],
@@ -70,7 +72,12 @@ export default {
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }) });
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
});
},
created() {
@@ -199,6 +206,80 @@ export default {
}
return this.type === UNTAGGED;
},
showNicsTab() {
return this.isOverlayNetwork && this.value.metadata.namespace === KUBE_SYSTEM;
},
namespaceOptions() {
const ns = this.$store.getters['harvester/all'](NAMESPACE) || [];
// Allow users to select the "kube-system" namespace as the external subnet from Kube-OVN.
// This expects the provider network to be in the "kube-system" namespace for VPC NAT gateway functionality.
return ns
.filter((ns) => !ns.isSystem || ns.id === KUBE_SYSTEM)
.map((ns) => ({ name: ns.id }))
.sort((a, b) => a.name.localeCompare(b.name));
},
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.filter((n) => n.isEtcd !== 'true');
},
nics() {
const inStore = this.$store.getters['currentProduct'].inStore;
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
const linkStatus = linkMonitor?.status?.linkStatus || {};
const nodes = this.nodes.map((n) => n.id);
const out = [];
// Collect all nics from all nodes (for overlay, we select from all nodes)
Object.keys(linkStatus).map((nodeName) => {
if (nodes.includes(nodeName)) {
const nics = linkStatus[nodeName] || [];
nics.map((nic) => {
out.push({
...nic,
nodeName,
});
});
}
});
return out;
},
nicOptions() {
const out = [];
const seen = new Set();
(this.nics || []).forEach((nic) => {
if (!seen.has(nic.name)) {
seen.add(nic.name);
out.push({
label: nic.name,
value: nic.name,
});
}
});
return out.sort((a, b) => a.label.localeCompare(b.label));
},
master: {
get() {
return this.config?.master || '';
},
set(value) {
this.config.master = value;
}
}
},
@@ -214,6 +295,7 @@ export default {
this.config.ipam = {};
this.config.bridge = '';
delete this.config.provider;
delete this.config.master;
delete this.config.server_socket;
}
},
@@ -230,6 +312,13 @@ export default {
this.config.vlan = '';
}
},
'value.metadata.namespace'(newNamespace) {
// NIC selection is only valid for overlay in kube-system namespace.
if (newNamespace !== KUBE_SYSTEM) {
delete this.config.master;
this.value.spec.config = JSON.stringify({ ...this.config });
}
},
},
methods: {
@@ -324,6 +413,10 @@ export default {
delete this.config.promiscMode;
delete this.config.vlan;
delete this.config.ipam;
if (this.value.metadata.namespace !== KUBE_SYSTEM) {
delete this.config.master;
}
}
if (this.isUntaggedNetwork) {
@@ -350,6 +443,7 @@ export default {
ref="nd"
:value="value"
:mode="mode"
:namespace-options="namespaceOptions"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
@@ -521,6 +615,25 @@ export default {
</div>
</div>
</Tab>
<Tab
v-if="showNicsTab"
name="nics"
:label="t('harvester.network.tabs.nic')"
:weight="97"
class="bordered-table"
>
<div class="row mt-10">
<div class="col span-12">
<LabeledSelect
v-model:value="master"
:label="t('harvester.vlanConfig.uplink.nics.label')"
:placeholder="t('harvester.vlanConfig.uplink.nics.overlayWarning')"
:mode="mode"
:options="nicOptions"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -3,8 +3,9 @@ import KeyValue from '@shell/components/form/KeyValue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import RadioGroup from '@components/Form/Radio/RadioGroup';
import Checkbox from '@components/Form/Checkbox/Checkbox';
import { SECRET, LONGHORN } from '@shell/config/types';
import { _CREATE, _VIEW } from '@shell/config/query-params';
import { _CREATE, _VIEW, _EDIT } from '@shell/config/query-params';
import { CSI_SECRETS } from '@pkg/harvester/config/harvester-map';
import { clone } from '@shell/utils/object';
import { uniq } from '@shell/utils/array';
@@ -16,7 +17,9 @@ const {
CSI_NODE_PUBLISH_SECRET_NAME,
CSI_NODE_PUBLISH_SECRET_NAMESPACE,
CSI_NODE_STAGE_SECRET_NAME,
CSI_NODE_STAGE_SECRET_NAMESPACE
CSI_NODE_STAGE_SECRET_NAMESPACE,
CSI_NODE_EXPAND_SECRET_NAME,
CSI_NODE_EXPAND_SECRET_NAMESPACE
} = CSI_SECRETS;
export default {
@@ -27,6 +30,7 @@ export default {
LabeledSelect,
LabeledInput,
RadioGroup,
Checkbox,
},
props: {
@@ -57,7 +61,10 @@ export default {
};
}
return { };
const hasExpandSecret = !!(this.value.parameters?.[CSI_NODE_EXPAND_SECRET_NAME] && this.value.parameters?.[CSI_NODE_EXPAND_SECRET_NAMESPACE]);
const volumeExpansionCheckBoxEnabled = this.realMode === _CREATE ? true : hasExpandSecret;
return { volumeExpansionCheckBoxEnabled };
},
computed: {
@@ -98,8 +105,11 @@ export default {
}, []);
},
isEdit() {
return this.realMode === _EDIT;
},
isView() {
return this.mode === _VIEW;
return this.realMode === _VIEW;
},
migratableOptions() {
@@ -152,6 +162,10 @@ export default {
}
},
enableOnlineExpansionVolumeEncryption() {
return this.value.expandOnlineEncryptedVolumeFeatureEnabled;
},
volumeEncryption: {
set(neu) {
this.value['parameters'] = {
@@ -180,6 +194,11 @@ export default {
set(selectedSecret) {
const [namespace, name] = selectedSecret.split('/');
const expandSecretParams = (this.enableOnlineExpansionVolumeEncryption && this.volumeExpansionCheckBoxEnabled) ? {
[CSI_NODE_EXPAND_SECRET_NAME]: name,
[CSI_NODE_EXPAND_SECRET_NAMESPACE]: namespace,
} : {};
this.value['parameters'] = {
...this.value.parameters,
[CSI_PROVISIONER_SECRET_NAME]: name,
@@ -187,7 +206,8 @@ export default {
[CSI_NODE_STAGE_SECRET_NAME]: name,
[CSI_PROVISIONER_SECRET_NAMESPACE]: namespace,
[CSI_NODE_PUBLISH_SECRET_NAMESPACE]: namespace,
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace,
...expandSecretParams,
};
}
},
@@ -240,6 +260,32 @@ export default {
}
},
},
watch: {
volumeExpansionCheckBoxEnabled(enabled) {
const currentSecret = this.secret;
if (!currentSecret) {
return;
}
const [namespace, name] = currentSecret.split('/');
if (enabled && this.enableOnlineExpansionVolumeEncryption) {
this.value['parameters'] = {
...this.value.parameters,
[CSI_NODE_EXPAND_SECRET_NAME]: name,
[CSI_NODE_EXPAND_SECRET_NAMESPACE]: namespace,
};
} else {
const params = { ...this.value.parameters };
delete params[CSI_NODE_EXPAND_SECRET_NAME];
delete params[CSI_NODE_EXPAND_SECRET_NAMESPACE];
this.value['parameters'] = params;
}
},
},
};
</script>
<template>
@@ -337,6 +383,16 @@ export default {
:mode="mode"
/>
</div>
<div
v-if="enableOnlineExpansionVolumeEncryption"
class="col span-6 flex items-center mt-20"
>
<Checkbox
v-model:value="volumeExpansionCheckBoxEnabled"
:label="t('harvester.storage.volumeExpansionCheckbox')"
:disabled="isEdit || isView"
/>
</div>
</div>
</template>
<KeyValue
@@ -254,15 +254,24 @@ export default {
v-if="restoreNewVm"
v-model:value="restoreResource.spec.keepMacAddress"
type="checkbox"
class="check mb-20"
:label="t('harvester.backup.restore.keepMacAddress')"
/>
<LabeledSelect
v-if="!restoreNewVm"
v-model:value="deletionPolicy"
class="mb-20"
:label="t('harvester.backup.restore.deletePreviousVolumes')"
:options="deletionPolicyOption"
/>
<Checkbox
v-model:value="restoreResource.spec.haltAfterRestore"
type="checkbox"
class="check mb-20"
:label="t('harvester.backup.restore.haltAfterRestore')"
/>
</div>
<Footer
@@ -26,6 +26,7 @@ import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem';
export default {
name: 'HarvesterEditVMTemplate',
@@ -51,6 +52,7 @@ export default {
UnitInput,
Banner,
KeyValue,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
@@ -95,6 +97,10 @@ export default {
secretNamePrefix() {
return this.templateValue?.metadata?.name;
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@@ -154,6 +160,7 @@ export default {
mounted() {
this.imageId = this.diskRows[0]?.image || '';
this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm);
},
methods: {
@@ -349,6 +356,19 @@ export default {
</template>
</Tab>
<Tab
v-if="filesystemEnabled"
name="filesystem"
:label="t('harvester.tab.filesystem')"
:weight="-8"
>
<Filesystem
v-model:value="filesystemRows"
:mode="mode"
:namespace="templateValue.metadata.namespace"
/>
</Tab>
<Tab
name="labels"
:label="t('generic.labels')"
@@ -2,6 +2,7 @@
import Footer from '@shell/components/form/Footer';
import { RadioGroup } from '@components/Form/Radio';
import { LabeledInput } from '@components/Form/LabeledInput';
import Checkbox from '@components/Form/Checkbox/Checkbox';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
@@ -32,6 +33,7 @@ const createObject = {
export default {
name: 'CreateRestore',
components: {
Checkbox,
Footer,
RadioGroup,
LabeledInput,
@@ -249,9 +251,17 @@ export default {
<LabeledSelect
v-if="!restoreNewVm"
v-model:value="deletionPolicy"
class="mb-20"
:label="t('harvester.backup.restore.deletePreviousVolumes')"
:options="deletionPolicyOption"
/>
<Checkbox
v-model:value="restoreResource.spec.haltAfterRestore"
type="checkbox"
class="check mb-20"
:label="t('harvester.backup.restore.haltAfterRestore')"
/>
</div>
<Footer
@@ -0,0 +1,190 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
eip: this.value?.spec?.eip || '',
externalPort: this.value?.spec?.externalPort || '',
internalIp: this.value?.spec?.internalIp || '',
internalPort: this.value?.spec?.internalPort || '',
protocol: this.value?.spec?.protocol || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
eipOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
return eips.map((eip) => ({
label: eip.id,
value: eip.id,
}));
},
protocolOptions() {
return [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
];
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.eip = this.eip;
this.value.spec.externalPort = this.externalPort;
this.value.spec.internalIp = this.internalIp;
this.value.spec.internalPort = this.internalPort;
this.value.spec.protocol = this.protocol;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="eip"
class="mb-20"
:options="eipOptions"
:mode="mode"
:label="t('harvester.dnat.eip.label')"
:placeholder="t('harvester.dnat.eip.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="externalPort"
class="mb-20"
:mode="mode"
:label="t('harvester.dnat.externalPort.label')"
:placeholder="t('harvester.dnat.externalPort.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalIp"
class="mb-20"
:mode="mode"
:label="t('harvester.dnat.internalIp.label')"
:placeholder="t('harvester.dnat.internalIp.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalPort"
class="mb-20"
:mode="mode"
:label="t('harvester.dnat.internalPort.label')"
:placeholder="t('harvester.dnat.internalPort.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="protocol"
class="mb-20"
:options="protocolOptions"
:mode="mode"
:label="t('harvester.dnat.protocol.label')"
:placeholder="t('harvester.dnat.protocol.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -0,0 +1,168 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
natGwDp: this.value?.spec?.natGwDp || '',
externalSubnet: this.value?.spec?.externalSubnet || '',
v4ip: this.value?.spec?.v4ip || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
natGateways: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC_NAT_GATEWAY }),
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
natGatewayOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const natGateways = this.$store.getters[`${ inStore }/all`](HCI.VPC_NAT_GATEWAY) || [];
return natGateways.map((gw) => ({
label: gw.id,
value: gw.id,
}));
},
subnetOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const subnets = this.$store.getters[`${ inStore }/all`](HCI.SUBNET) || [];
return subnets.map((subnet) => ({
label: subnet.id,
value: subnet.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.natGwDp = this.natGwDp;
this.value.spec.externalSubnet = this.externalSubnet;
this.value.spec.v4ip = this.v4ip;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="natGwDp"
class="mb-20"
:options="natGatewayOptions"
:mode="mode"
:label="t('harvester.externalIP.natGateway.label')"
:placeholder="t('harvester.externalIP.natGateway.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="externalSubnet"
class="mb-20"
:options="subnetOptions"
:label="t('harvester.externalIP.externalSubnet.label')"
:placeholder="t('harvester.externalIP.externalSubnet.placeholder')"
:mode="mode"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="v4ip"
class="mb-20"
:label="t('harvester.externalIP.v4ip.label')"
:placeholder="t('harvester.externalIP.v4ip.placeholder')"
:mode="mode"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -0,0 +1,140 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
eip: this.value?.spec?.eip || '',
internalCIDR: this.value?.spec?.internalCIDR || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
eipOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
return eips.map((eip) => ({
label: eip.id,
value: eip.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.eip = this.eip;
this.value.spec.internalCIDR = this.internalCIDR;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="eip"
class="mb-20"
:options="eipOptions"
:mode="mode"
:label="t('harvester.snat.eip.label')"
:placeholder="t('harvester.snat.eip.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalCIDR"
class="mb-20"
:mode="mode"
:label="t('harvester.snat.internalCIDR.label')"
:placeholder="t('harvester.snat.internalCIDR.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -0,0 +1,384 @@
<script>
import CruResource from '@shell/components/CruResource';
import ArrayList from '@shell/components/form/ArrayList';
import InfoBox from '@shell/components/InfoBox';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { NODE } from '@shell/config/types';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
ArrayList,
InfoBox,
NameNsDescription,
ResourceTabs,
Tab,
LabeledSelect,
ArrayListSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
defaultInterface: this.value?.spec?.defaultInterface || '',
excludedNodes: this.value?.spec?.excludeNodes || [],
customInterfaces: this.value?.spec?.customInterfaces || [],
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.filter((n) => n.isEtcd !== 'true');
},
nics() {
const inStore = this.$store.getters['currentProduct'].inStore;
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
const linkStatus = linkMonitor?.status?.linkStatus || {};
const nodes = this.nodes.map((n) => n.id);
const out = [];
// Collect all nics from all nodes
Object.keys(linkStatus).map((nodeName) => {
if (nodes.includes(nodeName)) {
const nics = linkStatus[nodeName] || [];
nics.map((nic) => {
out.push({
...nic,
nodeName,
});
});
}
});
return out;
},
nicOptions() {
const out = [];
const seen = new Set();
(this.nics || []).forEach((nic) => {
if (!seen.has(nic.name)) {
seen.add(nic.name);
out.push({
label: nic.name,
value: nic.name,
});
}
});
return out.sort((a, b) => a.label.localeCompare(b.label));
},
nodeOptions() {
return this.nodes.map((node) => ({
label: node.id,
value: node.id,
}));
},
},
methods: {
removeCustomInterface(index) {
this.customInterfaces.splice(index, 1);
},
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.defaultInterface = this.defaultInterface;
this.value.spec.excludeNodes = this.excludedNodes;
this.value.spec.customInterfaces = (this.customInterfaces || [])
.filter((item) => item?.interface || (item?.nodes || []).length)
.map((item) => ({
interface: item.interface || '',
nodes: (item.nodes || []).filter((node) => !!node),
}))
.filter((item) => item.interface && item.nodes.length > 0);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="Interfaces"
label="Interfaces"
:weight="99"
>
<LabeledSelect
v-model:value="defaultInterface"
class="mb-20"
required
:options="nicOptions"
:mode="mode"
:label="t('harvester.providerNetwork.defaultInterface.label')"
:placeholder="t('harvester.providerNetwork.defaultInterface.placeholder')"
/>
<hr class="section-divider" />
<ArrayList
v-model:value="customInterfaces"
class="mb-20 custom-interface-list"
:mode="mode"
:title="t('harvester.providerNetwork.customInterfaces.label')"
:protip="false"
:remove-allowed="false"
:initial-empty-row="true"
:default-add-value="{ interface: '', nodes: [] }"
>
<template #add="{ add }">
<div class="custom-interface-primary-add">
<button
type="button"
class="btn role-primary"
:disabled="mode === 'view'"
@click="add"
>
{{ t('harvester.providerNetwork.customInterfaces.addLabel') }}
</button>
</div>
</template>
<template #column-headers>
<div class="row custom-interface-header">
<div class="col span-6">
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
</div>
<div class="col span-6">
{{ t('harvester.providerNetwork.customInterfaces.nodes.label') }}
</div>
</div>
</template>
<template #columns="scope">
<InfoBox class="custom-interface-box">
<button
v-if="mode !== 'view'"
type="button"
class="role-link btn btn-sm remove"
@click="removeCustomInterface(scope.i)"
>
<i class="icon icon-x" />
</button>
<div class="custom-interface-content">
<div class="row custom-interface-row interface-row">
<div class="col span-12 interface-col">
<h3 class="mb-10">
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
</h3>
<LabeledSelect
v-model:value="scope.row.value.interface"
class="mb-20"
:label="''"
:options="nicOptions"
:mode="mode"
:placeholder="t('harvester.providerNetwork.customInterfaces.interface.placeholder')"
/>
</div>
</div>
<div class="row custom-interface-row nodes-row">
<div class="col span-12">
<ArrayListSelect
v-model:value="scope.row.value.nodes"
:options="nodeOptions"
:mode="mode"
:disabled="mode === 'view'"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.providerNetwork.customInterfaces.nodes.addLabel'),
initialEmptyRow: true,
title: t('harvester.providerNetwork.customInterfaces.nodes.label'),
required: false,
protip: false,
}"
:select-props="{
placeholder: t('harvester.providerNetwork.customInterfaces.nodes.placeholder'),
disabled: mode === 'view',
}"
>
<template #add="{ add }">
<div class="custom-interface-add">
<button
type="button"
class="btn role-tertiary add"
:disabled="mode === 'view'"
@click="add"
>
{{ t('harvester.providerNetwork.customInterfaces.nodes.addLabel') }}
</button>
</div>
</template>
</ArrayListSelect>
</div>
</div>
</div>
</InfoBox>
</template>
</ArrayList>
</Tab>
<Tab
name="excludedNodes"
:label="t('harvester.providerNetwork.excludedNodes.label')"
:weight="98"
>
<div class="row">
<div class="col span-12">
<ArrayListSelect
v-model:value="excludedNodes"
:options="nodeOptions"
:disabled="mode === 'view'"
:mode="mode"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.providerNetwork.excludedNodes.addLabel'),
initialEmptyRow: true,
required: false,
protip: false,
}"
:select-props="{
placeholder: t('harvester.providerNetwork.excludedNodes.placeholder'),
disabled: mode === 'view',
}"
/>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
<style lang="scss" scoped>
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0 20px;
}
.custom-interface-header {
margin-bottom: 10px;
font-weight: 600;
}
.custom-interface-row {
align-items: flex-start;
}
.interface-row {
width: calc(100% - 90px);
margin-bottom: 10px;
}
.nodes-row {
align-items: flex-start;
}
.custom-interface-add {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
:deep(.nodes-row .array-list-select .box) {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
:deep(.nodes-row .array-list-select .box .remove) {
align-self: center;
}
.custom-interface-primary-add {
max-width: 100%;
}
.custom-interface-box {
position: relative;
width: 100%;
padding: 20px;
margin-bottom: 5px;
}
:deep(.custom-interface-list .box) {
grid-template-columns: 1fr;
}
.remove {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
padding: 0;
}
</style>
+50 -9
View File
@@ -43,18 +43,20 @@ export default {
created() {
const vpc = this.$route.query.vpc || '';
const enableDHCP = this.value?.spec?.enableDHCP || false;
const natOutgoing = this.value?.spec?.natOutgoing || false;
set(this.value.spec, 'enableDHCP', enableDHCP);
set(this.value, 'spec', this.value.spec || {
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
vpc,
gatewayIP: '',
excludeIps: [],
private: false,
gateway: '',
excludeIps: [],
private: false,
enableDHCP,
acls: []
natOutgoing,
acls: []
});
},
@@ -62,8 +64,9 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vlans: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN }),
};
await allHash(hash);
@@ -129,6 +132,20 @@ export default {
label: n.id,
value: n.id,
}));
},
natOutgoingDisabled() {
// Disable the NAT Outgoing option when the subnet belongs to the ovn-cluster VPC and its name is join or ovn-default.
return this.value?.spec?.vpc === 'ovn-cluster' && ['join', 'ovn-default'].includes(this.value?.metadata?.name);
},
vlanOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
return vlans.map((vlan) => ({
label: vlan.id,
value: vlan.id,
}));
}
},
@@ -264,6 +281,16 @@ export default {
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.vlan"
class="mb-20"
:options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')"
:mode="mode"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
@@ -304,6 +331,20 @@ export default {
</Banner>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
v-model:value="value.spec.natOutgoing"
name="enableExternalConnectivity"
:disabled="natOutgoingDisabled"
:options="[true, false]"
:label="t('harvester.subnet.externalConnectivity.label')"
:labels="[t('generic.enabled'), t('generic.disabled')]"
:mode="mode"
:tooltip="t('harvester.subnet.externalConnectivity.tooltip')"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
+146
View File
@@ -0,0 +1,146 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
vlanId: this.value?.spec?.id || '',
provider: this.value?.spec?.provider || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ providerNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.PROVIDER_NETWORK }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
providerNetworks() {
const inStore = this.$store.getters['currentProduct'].inStore;
const providerNetworks = this.$store.getters[`${ inStore }/all`](HCI.PROVIDER_NETWORK) || [];
return providerNetworks.map((pn) => ({
label: pn.id,
value: pn.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
if (this.vlanId !== '') {
this.value.spec.id = Number(this.vlanId);
}
this.value.spec.provider = this.provider;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value.number="vlanId"
class="mb-20"
type="number"
:min="1"
:max="4094"
:label="t('harvester.vlan.id.label')"
:placeholder="t('harvester.vlan.id.placeholder')"
:mode="mode"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="provider"
class="mb-20"
:options="providerNetworks"
:mode="mode"
:label="t('harvester.vlan.provider.label')"
:placeholder="t('harvester.vlan.provider.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -0,0 +1,246 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI as HCI_ANNOTATIONS } from '@pkg/config/labels-annotations';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
ArrayListSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
const internalTenantNetwork = this.value?.metadata?.annotations?.[HCI_ANNOTATIONS.CNI_NETWORKS] || '';
return {
internalTenantNetwork,
vpc: this.value?.spec?.vpc || '',
subnet: this.value?.spec?.subnet || '',
lanIp: this.value?.spec?.lanIp || '',
externalSubnets: this.value?.spec?.externalSubnets || [],
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
vpcs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
vmNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.NETWORK_ATTACHMENT }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
vpcOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vpcs = this.$store.getters[`${ inStore }/all`](HCI.VPC) || [];
return vpcs.map((vpc) => ({
label: vpc.id,
value: vpc.id,
}));
},
subnetOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const subnets = this.$store.getters[`${ inStore }/all`](HCI.SUBNET) || [];
return subnets.map((subnet) => ({
label: subnet.id,
value: subnet.id,
}));
},
vmNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vmNetworks = this.$store.getters[`${ inStore }/all`](HCI.NETWORK_ATTACHMENT) || [];
return vmNetworks.map((network) => ({
label: network.id,
value: network.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
if (!this.value.metadata) {
this.value.metadata = {};
}
if (!this.value.metadata.annotations) {
this.value.metadata.annotations = {};
}
this.value.spec.vpc = this.vpc;
this.value.spec.subnet = this.subnet;
this.value.spec.lanIp = this.lanIp;
this.value.spec.externalSubnets = (this.externalSubnets || []).filter((subnet) => !!subnet);
if (this.internalTenantNetwork) {
this.value.metadata.annotations[HCI_ANNOTATIONS.CNI_NETWORKS] = this.internalTenantNetwork;
} else {
delete this.value.metadata.annotations[HCI_ANNOTATIONS.CNI_NETWORKS];
}
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="internalTenantNetwork"
class="mb-20"
required
:options="vmNetworkOptions"
:mode="mode"
:label="t('harvester.natGateway.internalTenantNetwork.label')"
:placeholder="t('harvester.natGateway.internalTenantNetwork.placeholder')"
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="vpc"
class="mb-20"
:options="vpcOptions"
:mode="mode"
:label="t('harvester.natGateway.vpc.label')"
:placeholder="t('harvester.natGateway.vpc.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="subnet"
class="mb-20"
:options="subnetOptions"
:mode="mode"
:label="t('harvester.natGateway.subnet.label')"
:placeholder="t('harvester.natGateway.subnet.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="lanIp"
class="mb-20"
:mode="mode"
:label="t('harvester.natGateway.lanIp.label')"
:placeholder="t('harvester.natGateway.lanIp.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
<Tab
name="externalSubnets"
:label="t('harvester.natGateway.externalSubnets.label')"
:weight="98"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<ArrayListSelect
v-model:value="externalSubnets"
:mode="mode"
:disabled="mode === 'view'"
required
:options="subnetOptions"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.natGateway.externalSubnets.addLabel'),
title: t('harvester.natGateway.subnet.label'),
initialEmptyRow: true,
required: true,
protip: false,
}"
:select-props="{
placeholder: t('harvester.natGateway.externalSubnets.placeholder'),
disabled: mode === 'view',
}"
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
@@ -143,6 +143,7 @@ export default {
<YamlEditor
ref="yaml"
v-model:value="yamlScript"
:mode="mode"
class="yaml-editor"
:editor-mode="editorMode"
/>
@@ -0,0 +1,310 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { CONFIG_MAP, SECRET, SERVICE_ACCOUNT } from '@shell/config/types';
import { _VIEW } from '@shell/config/query-params';
import CopyToClipboard from '@shell/components/CopyToClipboard';
import MessageLink from '@shell/components/MessageLink';
import { FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const MAX_FILESYSTEMS = 3;
const { CONFIGMAP: FS_TYPE_CONFIGMAP, SECRET: FS_TYPE_SECRET, SERVICEACCOUNT: FS_TYPE_SERVICEACCOUNT } = FILESYSTEM_SOURCE_TYPE;
const DEFAULT_VOLUME_NAMES = {
[FS_TYPE_CONFIGMAP]: 'appconfigfs',
[FS_TYPE_SECRET]: 'appsecretfs',
[FS_TYPE_SERVICEACCOUNT]: 'appserviceaccountfs',
};
const FS_TYPE_OPTIONS = [
{ label: 'ConfigMap', value: FS_TYPE_CONFIGMAP },
{ label: 'Secret', value: FS_TYPE_SECRET },
{ label: 'ServiceAccount', value: FS_TYPE_SERVICEACCOUNT },
];
function emptyRow() {
return {
fsType: FS_TYPE_CONFIGMAP,
volumeName: DEFAULT_VOLUME_NAMES[FS_TYPE_CONFIGMAP],
resourceName: '',
};
}
export default {
name: 'VirtualMachineFilesystem',
emits: ['update:value'],
components: {
Banner,
LabeledSelect,
LabeledInput,
CopyToClipboard,
MessageLink,
},
props: {
mode: {
type: String,
default: 'create',
},
namespace: {
type: String,
default: '',
},
value: {
type: Array,
default: () => [],
},
},
data() {
return { rows: this.value.length > 0 ? this.value.map((r) => ({ ...r })) : [emptyRow()] };
},
watch: {
value(newVal) {
if (newVal) {
const incoming = JSON.stringify(newVal);
const current = JSON.stringify(this.rows);
if (incoming !== current) {
this.rows = newVal.map((r) => ({ ...r }));
}
}
},
rows: {
deep: true,
handler(val) {
this.$emit('update:value', val.map((r) => ({ ...r })));
},
},
},
computed: {
...mapGetters({ t: 'i18n/t' }),
inStore() {
return this.$store.getters['currentProduct'].inStore;
},
configMaps() {
return this.$store.getters[`${ this.inStore }/all`](CONFIG_MAP)
.filter((cm) => !this.namespace || cm.metadata.namespace === this.namespace)
.map((cm) => ({ label: cm.metadata.name, value: cm.metadata.name }));
},
secrets() {
return this.$store.getters[`${ this.inStore }/all`](SECRET)
.filter((s) => !this.namespace || s.metadata.namespace === this.namespace)
.map((s) => ({ label: s.metadata.name, value: s.metadata.name }));
},
serviceAccounts() {
return this.$store.getters[`${ this.inStore }/all`](SERVICE_ACCOUNT)
.filter((sa) => !this.namespace || sa.metadata.namespace === this.namespace)
.map((sa) => ({ label: sa.metadata.name, value: sa.metadata.name }));
},
canAddRow() {
return this.rows.length < MAX_FILESYSTEMS;
},
isView() {
return this.mode === _VIEW;
},
completedRows() {
return this.rows.filter((r) => r.fsType && r.volumeName && r.resourceName);
},
allMountCommands() {
return this.completedRows.map((r) => this.mountCommands(r)).join('\n');
},
},
methods: {
fsTypeOptions(currentIndex) {
const usedTypes = this.rows
.filter((_, i) => i !== currentIndex)
.map((r) => r.fsType);
return FS_TYPE_OPTIONS.filter((opt) => !usedTypes.includes(opt.value));
},
resourceOptions(fsType) {
if (fsType === FS_TYPE_CONFIGMAP) return this.configMaps;
if (fsType === FS_TYPE_SECRET) return this.secrets;
if (fsType === FS_TYPE_SERVICEACCOUNT) return this.serviceAccounts;
return [];
},
onFsTypeChange(row, newType) {
row.fsType = newType;
row.volumeName = DEFAULT_VOLUME_NAMES[newType] || '';
row.resourceName = '';
},
addRow() {
if (this.canAddRow) {
const usedTypes = this.rows.map((r) => r.fsType);
const nextType = FS_TYPE_OPTIONS.find((opt) => !usedTypes.includes(opt.value))?.value || FS_TYPE_CONFIGMAP;
this.rows.push({
fsType: nextType,
volumeName: DEFAULT_VOLUME_NAMES[nextType] || '',
resourceName: '',
});
}
},
removeRow(index) {
this.rows.splice(index, 1);
},
mountCommands(row) {
const vol = row.volumeName || '<volume-name>';
return `- mkdir -p /mnt/${ vol }\n- mount -t virtiofs ${ vol } /mnt/${ vol }`;
},
},
};
</script>
<template>
<div class="vm-filesystem">
<p class="mb-20">
{{ t('harvester.virtualMachine.filesystem.description') }}
</p>
<div
v-for="(row, index) in rows"
:key="index"
class="filesystem-row mb-15"
>
<div class="row">
<div class="col span-3">
<LabeledSelect
:value="row.fsType"
:label="t('harvester.virtualMachine.filesystem.type')"
:options="fsTypeOptions(index)"
:mode="mode"
required
@update:value="onFsTypeChange(row, $event)"
/>
</div>
<div class="col span-3">
<LabeledInput
v-model:value="row.volumeName"
:label="t('harvester.virtualMachine.filesystem.volume')"
:mode="mode"
required
/>
</div>
<div class="col span-5">
<LabeledSelect
v-model:value="row.resourceName"
:label="t('harvester.virtualMachine.filesystem.resource')"
:options="resourceOptions(row.fsType)"
:mode="mode"
required
/>
</div>
<div
v-if="!isView"
class="col span-1 remove-col"
>
<button
type="button"
class="btn role-link remove-btn"
@click="removeRow(index)"
>
{{ t('generic.remove') }}
</button>
</div>
</div>
</div>
<Banner
v-if="completedRows.length > 0 && mode === 'create'"
color="warning"
class="mt-10"
>
<div>
<MessageLink
:to="{ hash: '#advanced' }"
prefix-label="harvester.virtualMachine.filesystem.mountBannerHint"
middle-label="harvester.virtualMachine.filesystem.mountBannerHintLink"
suffix-label="harvester.virtualMachine.filesystem.mountBannerHintSuffix"
/>
<div class="pre-wrapper mt-10">
<pre class="mt-5 mb-0">{{ allMountCommands }}</pre>
<CopyToClipboard
:text="allMountCommands"
:show-label="false"
class="icon-btn"
action-color="bg-transparent"
/>
</div>
</div>
</Banner>
<button
v-if="!isView && canAddRow"
type="button"
class="btn role-tertiary add"
@click="addRow"
>
{{ t('harvester.virtualMachine.filesystem.add') }}
</button>
</div>
</template>
<style lang="scss" scoped>
.vm-filesystem {
padding: 10px 0;
}
.filesystem-row {
border-bottom: 1px solid var(--border);
padding-bottom: 15px;
}
.remove-col {
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn {
padding: 0;
}
.pre-wrapper {
position: relative;
pre {
padding-right: 36px;
}
.icon-btn {
position: absolute;
top: 0px;
right: 5px;
padding: 2px 4px;
line-height: 1;
&:active {
background-color: var(--success) !important;
}
}
}
</style>
@@ -152,6 +152,10 @@ export default {
return !rows.find((device) => !device.passthroughClaim);
},
canManageGroup(rows = []) {
return rows.length > 0 && rows.every((row) => row.canUpdate === true);
},
changeRows(filterRows, parentSriov) {
this['filterRows'] = filterRows;
this['parentSriov'] = parentSriov;
@@ -185,22 +189,27 @@ export default {
v-trim-whitespace
class="group-tab"
>
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
<div
v-if="canManageGroup(group.rows)"
class="group-actions"
>
{{ t('harvester.pci.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.enableGroup') }}
</button>
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.pci.enableGroup') }}
</button>
</div>
<span v-clean-html="group.key" />
</div>
</template>
@@ -232,3 +241,9 @@ export default {
</template>
</ResourceTable>
</template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
</style>
@@ -52,31 +52,21 @@ export default {
}
const selectedDevices = [];
const oldFormatDevices = [];
const vmDevices = this.value?.domain?.devices?.hostDevices || [];
const otherDevices = this.otherDevices(vmDevices).map(({ name }) => name);
const vmDeviceNames = vmDevices.map(({ name }) => name);
this.pciDevices.forEach((row) => {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
});
vmDevices.forEach(({ name, deviceName }) => {
const checkName = (deviceName || '').split('/')?.[1];
if (checkName && name.includes(checkName) && !otherDevices.includes(name)) {
oldFormatDevices.push(name);
} else if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
vmDevices.forEach(({ name }) => {
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
selectedDevices.push(name);
}
});
if (oldFormatDevices.length > 0) {
this.oldFormatDevices = oldFormatDevices;
} else {
this.selectedDevices = selectedDevices;
}
this.selectedDevices = selectedDevices;
},
data() {
@@ -87,7 +77,6 @@ export default {
selectedDevices: [],
pciDeviceSchema: this.$store.getters['harvester/schemaFor'](HCI.PCI_DEVICE),
showMatrix: false,
oldFormatDevices: [],
};
},
@@ -199,11 +188,6 @@ export default {
});
},
oldFormatDevicesHTML() {
return this.oldFormatDevices.map((device) => {
return `<li>${ device }</li>`;
}).join('');
},
},
methods: {
@@ -227,101 +211,89 @@ export default {
<template>
<div>
<div
v-if="oldFormatDevices.length > 0"
class="row"
>
<div class="row">
<div class="col span-12">
<Banner color="warning">
<p v-clean-html="t('harvester.pci.oldFormatDevices.help', {oldFormatDevicesHTML}, true)" />
<Banner color="info">
<MessageLink
:to="toVGpuDevicesPage"
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
/>
</Banner>
<Banner
v-if="selectedDevices.length > 0"
color="info"
>
<t k="harvester.pci.deviceInTheSameHost" />
</Banner>
</div>
</div>
<div v-else>
<template v-if="enabledDevices.length">
<div class="row">
<div class="col span-12">
<Banner color="info">
<MessageLink
:to="toVGpuDevicesPage"
prefix-label="harvester.pci.howToUseDeviceInVMCreation.prefix"
middle-label="harvester.pci.howToUseDeviceInVMCreation.middle"
suffix-label="harvester.pci.howToUseDeviceInVMCreation.suffix"
/>
</Banner>
<Banner
v-if="selectedDevices.length > 0"
color="info"
<div class="col span-6">
<LabeledSelect
v-model:value="selectedDevices"
label="Available PCI Devices"
searchable
multiple
taggable
:options="deviceOpts"
:mode="mode"
>
<t k="harvester.pci.deviceInTheSameHost" />
</Banner>
<template #option="option">
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
</template>
</LabeledSelect>
</div>
</div>
<template v-if="enabledDevices.length">
<div class="row">
<div class="col span-6">
<LabeledSelect
v-model:value="selectedDevices"
label="Available PCI Devices"
searchable
multiple
taggable
:options="deviceOpts"
:mode="mode"
>
<template #option="option">
<span>{{ option.value }} <span class="text-label">({{ option.displayLabel }})</span></span>
</template>
</LabeledSelect>
</div>
<div
v-if="compatibleNodes.length && selectedDevices.length"
class="row"
>
<div class="col span-12 text-muted">
Compatible hosts:
<!-- eslint-disable-next-line vue/no-parsing-error -->
<span
v-for="(node, idx) in compatibleNodes"
:key="idx"
>{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
</div>
<div
v-if="compatibleNodes.length && selectedDevices.length"
class="row"
>
<div class="col span-12 text-muted">
Compatible hosts:
<!-- eslint-disable-next-line vue/no-parsing-error -->
<span
v-for="(node, idx) in compatibleNodes"
:key="idx"
>{{ node }}{{ idx < compatibleNodes.length-1 ? ', ' : '' }}</span>
</div>
</div>
<div
v-else-if="selectedDevices.length"
class="text-error"
>
{{ t('harvester.pci.impossibleSelection') }}
</div>
<button
type="button"
class="btn btn-sm role-link pl-0"
@click="e=>{showMatrix = !showMatrix; e.target.blur()}"
>
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
</button>
<div
v-if="showMatrix"
class="row mt-20"
>
<div class="col span-12">
<CompatibilityMatrix
:enabled-devices="enabledDevices"
:devices-by-node="devicesByNode"
:devices-in-use="devicesInUse"
/>
</div>
</div>
</template>
<div class="row mt-20">
</div>
<div
v-else-if="selectedDevices.length"
class="text-error"
>
{{ t('harvester.pci.impossibleSelection') }}
</div>
<button
type="button"
class="btn btn-sm role-link pl-0"
@click="e=>{showMatrix = !showMatrix; e.target.blur()}"
>
{{ showMatrix ? t('harvester.pci.hideCompatibility') : t('harvester.pci.showCompatibility') }}
</button>
<div
v-if="showMatrix"
class="row mt-20"
>
<div class="col span-12">
<DeviceList
:schema="pciDeviceSchema"
:devices="pciDevices"
@submit.prevent
<CompatibilityMatrix
:enabled-devices="enabledDevices"
:devices-by-node="devicesByNode"
:devices-in-use="devicesInUse"
/>
</div>
</div>
</template>
<div class="row mt-20">
<div class="col span-12">
<DeviceList
:schema="pciDeviceSchema"
:devices="pciDevices"
@submit.prevent
/>
</div>
</div>
</div>
</template>
@@ -113,6 +113,11 @@ export default {
}
});
},
canManageGroup(rows = []) {
return rows.every((row) => row.canUpdate === true);
},
groupIsAllEnabled(rows = []) {
return !rows.find((device) => !device.passthroughClaim);
},
@@ -153,22 +158,27 @@ export default {
v-trim-whitespace
class="group-tab"
>
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
<div
v-if="canManageGroup(group.rows)"
class="group-actions"
>
{{ t('harvester.usb.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.enableGroup') }}
</button>
<button
v-if="groupIsAllEnabled(group.rows)"
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{disableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.disableGroup') }}
</button>
<button
v-else
type="button"
class="btn btn-sm role-secondary mr-5"
@click="e=>{enableGroup(group.rows); e.target.blur()}"
>
{{ t('harvester.usb.enableGroup') }}
</button>
</div>
<span v-clean-html="group.key" />
</div>
</template>
@@ -181,3 +191,9 @@ export default {
</template>
</ResourceTable>
</template>
<style lang="scss" scoped>
.group-actions {
display: inline;
}
</style>
@@ -475,7 +475,6 @@ export default {
<button
type="button"
class="btn btn-sm bg-primary mr-15 mb-10"
:disabled="rows.length === 0"
@click="addVolume(SOURCE_TYPE.NEW)"
>
{{ t('harvester.virtualMachine.volume.addVolume') }}
@@ -0,0 +1,346 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
import ModalWithCard from '@shell/components/ModalWithCard';
import { SECRET } from '@shell/config/types';
const _AUTOUNATTENDXML = 'autounattend.xml';
const _NEW = '_NEW';
export default {
name: 'VirtualMachineWindowsSysprep',
components: {
Banner,
LabeledInput,
LabeledSelect,
ModalWithCard,
YamlEditor,
},
props: {
mode: {
type: String,
default: 'create',
},
namespace: {
type: String,
required: true,
},
value: {
type: Object,
default: () => ({
secretName: '',
xmlContent: '',
}),
},
},
data() {
return {
secretOptions: [],
errors: [],
secretName: '',
xmlContent: '',
newSecretName: '',
newXmlContent: '',
isOpen: false,
};
},
async fetch() {
await this.loadSecretsOptions();
},
mounted() {
// Force the loading of the XML content.
this.secretName = this.value?.secretName || '';
},
watch: {
secretName() {
if (this.secretName === _NEW) {
this.isOpen = true;
return;
}
// Load the content from the given secret, or set empty value.
this.xmlContent = this.secretName === '' ? '' : this.loadXmlContentFromSecret(this.secretName);
// Update the editor form field.
this.updateValue();
// Update the property.
this.update();
},
xmlContent() {
this.update();
}
},
computed: {
...mapGetters({ t: 'i18n/t' }),
editorMode() {
return this.mode === 'view' ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
},
isView() {
return this.mode === 'view';
},
xmlContentValidationError() {
return this.validateXmlContent(this.xmlContent);
},
},
methods: {
update() {
this.$emit('update:value', {
secretName: this.secretName,
xmlContent: this.xmlContent,
});
},
updateValue() {
this.$refs?.xmlEditor?.updateValue?.(this.xmlContent);
},
refresh() {
this.$refs?.xmlEditor?.refresh?.();
},
cancel() {
if (this.secretName === _NEW) {
this.secretName = '';
}
this.newSecretName = '';
this.newXmlContent = '';
this.errors = [];
this.isOpen = false;
},
async loadSecretsOptions() {
const secrets = await this.$store.dispatch('harvester/findAll', { type: SECRET });
const templates = secrets
.filter(
(s) => s.metadata?.namespace === this.namespace &&
s.decodedData?.[_AUTOUNATTENDXML]
)
.map((s) => ({
label: s.metadata.name,
value: `${ s.metadata.namespace }/${ s.metadata.name }`,
}));
this.secretOptions = [
{
label: this.t('generic.none'),
value: '',
},
{
label: this.t('harvester.virtualMachine.sysprep.createNew'),
value: _NEW,
},
...templates,
];
},
async save(buttonCb) {
this.errors = [];
if (!this.newSecretName?.trim()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.input.name') }, true));
buttonCb(false);
return;
}
if (!this.newXmlContent?.trim()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
buttonCb(false);
return;
}
const xmlError = this.validateXmlContent(this.newXmlContent);
if (xmlError) {
this.errors.push(xmlError);
buttonCb(false);
return;
}
try {
const secret = await this.$store.dispatch('harvester/create', {
type: SECRET,
metadata: {
name: this.newSecretName,
namespace: this.namespace,
},
});
secret.setData(_AUTOUNATTENDXML, this.newXmlContent);
const res = await secret.save();
if (res?.metadata?.name) {
await this.loadSecretsOptions();
this.secretName = `${ this.namespace }/${ res.metadata.name }`;
}
buttonCb(true);
this.cancel();
} catch (err) {
this.errors = [err.message];
buttonCb(false);
}
},
validateXmlContent(content) {
if (!content?.trim()) {
return null;
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/xml');
const parseError = doc.querySelector('parsererror');
if (parseError) {
return this.t('harvester.virtualMachine.sysprep.validation.invalidXml');
}
// See https://learn.microsoft.com/en-us/windows-hardware/drivers/gpiobtn/implement-the-unattended-windows-setup-setting
if (doc.documentElement.namespaceURI !== 'urn:schemas-microsoft-com:unattend') {
return this.t('harvester.virtualMachine.sysprep.validation.invalidNamespace');
}
return null;
} catch (e) {
return e.message;
}
},
loadXmlContentFromSecret(id) {
const secret = this.$store.getters['harvester/byId'](SECRET, id);
return secret?.decodedData?.[_AUTOUNATTENDXML] || '';
}
},
};
</script>
<template>
<div>
<h2 v-if="!isView">
{{ t('harvester.virtualMachine.sysprep.title') }}
</h2>
<p
v-if="!isView"
class="text-muted mb-20"
>
<t
k="harvester.virtualMachine.sysprep.description"
:raw="true"
/>
</p>
<!-- Secret Selection -->
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="secretName"
:options="secretOptions"
:label="t('harvester.virtualMachine.sysprep.secret.label')"
:mode="mode"
/>
</div>
</div>
<!-- XML Editor -->
<div class="mb-20">
<Banner
v-if="xmlContentValidationError"
color="error"
class="mb-10"
>
<i class="icon icon-error" />
{{ xmlContentValidationError }}
</Banner>
<div class="resource-xml">
<YamlEditor
ref="xmlEditor"
v-model:value="xmlContent"
:editor-mode="editorMode"
class="xml-editor"
/>
</div>
</div>
<ModalWithCard
v-if="isOpen"
name="createSysprepSecret"
width="40%"
:errors="errors"
@finish="save"
@close="cancel"
>
<template #title>
{{ t('harvester.virtualMachine.sysprep.createNewTitle') }}
</template>
<template #content>
<LabeledInput
v-model:value="newSecretName"
:label="t('harvester.virtualMachine.input.name')"
class="mb-20"
required
@keydown.native.enter.prevent="()=>{}"
/>
<div class="xml">
<div class="resource-xml">
<YamlEditor
ref="createTemplate"
v-model:value="newXmlContent"
:editor-mode="editorMode"
class="xml-editor"
/>
</div>
</div>
</template>
</ModalWithCard>
</div>
</template>
<style lang="scss" scoped>
$xml-height: 350px;
:deep() .resource-xml {
flex: 1;
display: flex;
flex-direction: column;
& .xml-editor {
flex: 1;
min-height: $xml-height;
font-family: monospace;
& .code-mirror .CodeMirror {
min-height: $xml-height;
}
}
}
.xml {
height: $xml-height;
overflow: auto;
}
</style>
@@ -21,9 +21,7 @@ import { saferDump } from '@shell/utils/create-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
import CreateEditView from '@shell/mixins/create-edit-view';
import { parseVolumeClaimTemplates } from '@pkg/utils/vm';
import VM_MIXIN from '../../mixins/harvester-vm';
import { HCI } from '../../types';
@@ -31,12 +29,14 @@ import RestartVMDialog from '../../dialog/RestartVMDialog';
import PciDevices from './VirtualMachinePciDevices/index';
import AccessCredentials from './VirtualMachineAccessCredentials';
import CloudConfig from './VirtualMachineCloudConfig';
import WindowsSysprep from './VirtualMachineWindowsSysprep';
import CpuMemory from './VirtualMachineCpuMemory';
import CpuModel from './VirtualMachineCpuModel';
import Network from './VirtualMachineNetwork';
import Volume from './VirtualMachineVolume';
import SSHKey from './VirtualMachineSSHKey';
import Reserved from './VirtualMachineReserved';
import Filesystem from './VirtualMachineFilesystem';
import { Banner } from '@components/Banner';
import MessageLink from '@shell/components/MessageLink';
@@ -60,6 +60,7 @@ export default {
CpuMemory,
CpuModel,
CloudConfig,
WindowsSysprep,
NodeScheduling,
PodAffinity,
AccessCredentials,
@@ -72,6 +73,7 @@ export default {
Banner,
MessageLink,
UsbDevices,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
@@ -91,22 +93,36 @@ export default {
const hostname = this.value.spec.template.spec.hostname || '';
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return {
cloneVM,
count: 2,
templateId: '',
templateVersionId: '',
namePrefix: '',
isSingle: true,
isOpen: false,
count: 2,
templateId: '',
templateVersionId: '',
namePrefix: '',
isSingle: true,
isOpen: false,
hostname,
isRestartImmediately,
customizeDisplayName,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
// VM display name is stored as an annotation; bind a dedicated input to it
// so we don't have to mutate metadata.name (which would break the k8s PUT).
displayName: {
get() {
return this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME] || '';
},
set(val) {
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, val);
},
},
to() {
return {
name: 'harvester-c-cluster-resource',
@@ -218,6 +234,9 @@ export default {
usbPassthroughEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough');
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@@ -289,6 +308,12 @@ export default {
this.getInitConfig({ value: this.value, init: this.isCreate });
}
},
customizeDisplayName(neu) {
if (!neu) {
this.value.setAnnotation(HCI_ANNOTATIONS.VM_DISPLAY_NAME, '');
}
},
},
created() {
@@ -301,6 +326,10 @@ export default {
try {
await this.saveSecret(res);
await this.saveAccessCredentials(res);
if (this.isWindows) {
await this.saveSysprepConfig(res);
}
} catch (e) {
this.errors.push(...exceptionToErrorsArray(e));
}
@@ -321,6 +350,7 @@ export default {
const diskRows = this.getDiskRows(this.value);
this['diskRows'] = diskRows;
this['filesystemRows'] = this.getFilesystemRows(this.value);
const templateId = this.$route.query.templateId;
const templateVersionId = this.$route.query.versionId;
@@ -345,6 +375,7 @@ export default {
clear(this.errors);
this.validateCPUMemory();
this.validateWindowsSysprep();
// block create VM flow if has validation errors
if (this.errors.length) {
@@ -372,6 +403,24 @@ export default {
}
},
validateWindowsSysprep() {
if (!this.isWindows) {
return;
}
if (this.sysprep?.secretName?.trim?.() && !this.sysprep?.xmlContent?.trim?.()) {
this.errors.push(this.t('validation.required', { key: this.t('harvester.virtualMachine.sysprep.xmlContent') }, true));
return;
}
const sysprepValidationError = this.$refs.sysprepConfig?.xmlContentValidationError;
if (sysprepValidationError) {
this.errors.push(sysprepValidationError);
}
},
async saveSingle(buttonCb) {
this.parseVM();
this.value.spec.template.spec.hostname = this.hostname ? this.hostname : this.value.metadata.name;
@@ -522,6 +571,7 @@ export default {
onTabChanged({ tab }) {
if (tab.name === 'advanced') {
this.$refs.yamlEditor?.refresh();
this.$refs.sysprepConfig?.refresh();
}
},
@@ -610,6 +660,33 @@ export default {
</template>
</NameNsDescription>
<div v-if="isSingle">
<div class="row mb-20">
<div class="col span-12">
<Checkbox
v-model:value="customizeDisplayName"
class="check"
type="checkbox"
:label="t('harvester.virtualMachine.input.customizeDisplayName')"
:mode="mode"
/>
</div>
</div>
<div
v-if="customizeDisplayName"
class="row mb-20"
>
<div class="col span-6">
<LabeledInput
v-model:value="displayName"
:mode="mode"
:label="t('harvester.virtualMachine.input.displayName')"
:placeholder="t('harvester.virtualMachine.input.displayNamePlaceholder')"
/>
</div>
</div>
</div>
<Checkbox
v-if="isCreate"
v-model:value="useTemplate"
@@ -783,10 +860,23 @@ export default {
/>
</Tab>
<Tab
v-if="filesystemEnabled"
name="filesystem"
:label="t('harvester.tab.filesystem')"
:weight="-9"
>
<Filesystem
v-model:value="filesystemRows"
:mode="isCreate ? mode : 'view'"
:namespace="value.metadata.namespace"
/>
</Tab>
<Tab
name="labels"
:label="t('generic.labels')"
:weight="-9"
:weight="-10"
>
<Banner color="info">
<t k="harvester.virtualMachine.labels.banner" />
@@ -805,7 +895,7 @@ export default {
<Tab
name="instanceLabel"
:label="t('harvester.tab.instanceLabel')"
:weight="-10"
:weight="-11"
>
<Banner color="info">
<t k="harvester.virtualMachine.instanceLabels.banner" />
@@ -826,7 +916,7 @@ export default {
<Tab
name="annotations"
:label="t('harvester.tab.annotations')"
:weight="-11"
:weight="-12"
>
<Banner color="info">
<t k="harvester.virtualMachine.annotations.banner" />
@@ -847,7 +937,7 @@ export default {
<Tab
name="advanced"
:label="t('harvester.tab.advanced')"
:weight="-12"
:weight="-13"
>
<div class="row mb-20">
<div class="col span-6">
@@ -955,11 +1045,11 @@ export default {
</div>
<CloudConfig
v-if="!isWindows"
ref="yamlEditor"
:user-script="userScript"
:mode="mode"
:os-type="osType"
:view-code="isWindows"
:namespace="value.metadata.namespace"
:network-script="networkScript"
@updateUserData="updateUserData"
@@ -967,6 +1057,14 @@ export default {
@updateDataTemplateId="updateDataTemplateId"
/>
<WindowsSysprep
v-if="isWindows"
ref="sysprepConfig"
v-model:value="sysprep"
:mode="mode"
:namespace="value.metadata.namespace"
/>
<Checkbox
v-if="value.cpuPinningFeatureEnabled"
v-model:value="cpuPinning"
@@ -0,0 +1,419 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import InfoBox from '@shell/components/InfoBox';
import MessageLink from '@shell/components/MessageLink';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
import { Checkbox } from '@components/Form/Checkbox';
import HarvesterNodeSelector from '../components/HarvesterNodeSelector';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { set } from '@shell/utils/object';
import { NODE } from '@shell/config/types';
import { HCI } from '../types';
import { ADD_ONS } from '../config/harvester-map';
const MODE_DHCP = 'dhcp';
const MODE_STATIC = 'static';
export default {
name: 'HarvesterHostNetworkConfigEditPage',
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
InfoBox,
MessageLink,
LabeledSelect,
LabeledInput,
RadioGroup,
Checkbox,
HarvesterNodeSelector,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
hostNetworkConfigs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HOST_NETWORK_CONFIG }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
});
},
data() {
const networkMode = this.value?.spec?.mode || MODE_DHCP;
const ips = { ...(this.value?.spec?.ips || {}) };
if (!this.value.spec) {
set(this.value, 'spec', {});
}
return {
networkMode,
ips,
hasNodeSelector: !!this.value?.spec?.nodeSelector,
};
},
computed: {
modeOptions() {
return [
{ label: 'DHCP', value: MODE_DHCP },
{ label: 'Static', value: MODE_STATIC },
];
},
clusterNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
return clusterNetworks.map((n) => {
const disabled = !n.isReady;
return {
label: disabled ? `${ n.id } (${ this.t('generic.notReady') })` : n.id,
value: n.id,
disabled,
};
});
},
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/all`](NODE) || [];
},
isStaticMode() {
return this.networkMode === MODE_STATIC;
},
underlay: {
get() {
return !!this.value?.spec?.underlay;
},
set(val) {
set(this.value, 'spec.underlay', val);
},
},
vlanID: {
get() {
return this.value?.spec?.vlanID;
},
set(val) {
set(this.value, 'spec.vlanID', val);
},
},
clusterNetwork: {
get() {
return this.value?.spec?.clusterNetwork;
},
set(val) {
set(this.value, 'spec.clusterNetwork', val);
},
},
underlayConflict() {
const inStore = this.$store.getters['currentProduct'].inStore;
const all = this.$store.getters[`${ inStore }/all`](HCI.HOST_NETWORK_CONFIG) || [];
const currentId = this.value?.id;
return all.find((c) => c.id !== currentId && c.spec?.underlay === true) || null;
},
kubeovnEnabled() {
const inStore = this.$store.getters['currentProduct'].inStore;
const addons = this.$store.getters[`${ inStore }/all`](HCI.ADD_ONS) || [];
return addons.find((a) => a.name === ADD_ONS.KUBEOVN_OPERATOR)?.spec?.enabled === true;
},
underlayDisabled() {
return !this.kubeovnEnabled || !!this.underlayConflict;
},
kubeovnAddonTo() {
return {
name: 'c-cluster-product-resource-namespace-id',
params: {
cluster: this.$route.params.cluster,
product: this.$store.getters['productId'],
resource: HCI.ADD_ONS,
namespace: 'kube-system',
id: ADD_ONS.KUBEOVN_OPERATOR,
},
query: { mode: 'edit' },
hash: '#basic',
};
},
},
watch: {
networkMode(neu) {
set(this.value, 'spec.mode', neu);
if (neu !== MODE_STATIC) {
if (this.value?.spec?.ips !== undefined) {
delete this.value.spec.ips;
}
this.ips = {};
}
},
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
methods: {
updateBeforeSave() {
set(this.value, 'spec.mode', this.networkMode);
if (this.isStaticMode) {
set(this.value, 'spec.ips', { ...this.ips });
}
},
updateIp(nodeName, val) {
this.ips = { ...this.ips, [nodeName]: val };
},
addNodeSelector() {
set(this.value.spec, 'nodeSelector', {
matchExpressions: [{
key: '', operator: 'In', values: []
}]
});
this.hasNodeSelector = true;
},
removeNodeSelector() {
delete this.value.spec.nodeSelector;
this.hasNodeSelector = false;
},
},
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:mode="mode"
:resource="value"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@cancel="done"
@error="e => errors = e"
>
<NameNsDescription
:value="value"
:mode="mode"
:namespaced="false"
description-key="spec.description"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('harvester.hostNetworkConfig.tabs.mode')"
:weight="99"
class="bordered-table"
>
<div class="row mb-20">
<div class="col span-6">
<RadioGroup
v-model:value="networkMode"
name="hostNetworkConfigMode"
:options="modeOptions"
:mode="mode"
:row="true"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="clusterNetwork"
:label="t('harvester.network.clusterNetwork.label')"
:options="clusterNetworkOptions"
:mode="mode"
required
:placeholder="t('harvester.network.clusterNetwork.selectPlaceholder')"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value.number="vlanID"
type="number"
required
:min="2"
:max="4094"
placeholder="e.g. 2 ~ 4094"
:label="t('harvester.hostNetworkConfig.vlanID.label')"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<Checkbox
v-model:value="underlay"
:label="t('harvester.hostNetworkConfig.underlay.label')"
:tooltip="t('harvester.hostNetworkConfig.underlay.tooltip')"
:mode="mode"
:disabled="underlayDisabled"
/>
<span
v-if="!kubeovnEnabled"
class="underlay-conflict-warning"
>
<i class="icon icon-warning" />
<MessageLink
:to="kubeovnAddonTo"
prefix-label="harvester.hostNetworkConfig.underlay.noKubeovn.prefix"
middle-label="harvester.hostNetworkConfig.underlay.noKubeovn.middle"
suffix-label="harvester.hostNetworkConfig.underlay.noKubeovn.suffix"
/>
</span>
<span
v-else-if="underlayConflict"
class="underlay-conflict-warning"
>
<i class="icon icon-warning" />
{{ t('harvester.hostNetworkConfig.underlay.conflict', { name: underlayConflict.nameDisplay || underlayConflict.id }) }}
</span>
</div>
</div>
<template v-if="isStaticMode">
<hr class="section-divider" />
<div
v-for="node in nodes"
:key="node.id"
class="row mb-10 ips-row"
>
<div class="col span-3">
<LabeledInput
:value="node.nameDisplay || node.id"
:label="t('harvester.hostNetworkConfig.ips.nodeLabel')"
mode="view"
:disabled="true"
/>
</div>
<div class="col span-5">
<LabeledInput
:value="ips[node.id]"
:label="t('harvester.hostNetworkConfig.ips.label')"
:placeholder="t('harvester.hostNetworkConfig.ips.placeholder')"
:mode="mode"
required
@update:value="updateIp(node.id, $event)"
/>
</div>
</div>
</template>
</Tab>
<Tab
name="nodeSelector"
:label="t('harvester.hostNetworkConfig.tabs.nodeSelector')"
:weight="98"
>
<template v-if="hasNodeSelector">
<InfoBox class="node-selector-box">
<button
v-if="!isView"
type="button"
class="role-link btn btn-sm remove"
:aria-label="t('generic.remove')"
@click="removeNodeSelector"
>
<i class="icon icon-x" />
</button>
<HarvesterNodeSelector
class="mt-20"
:value="value.spec.nodeSelector"
:mode="mode"
/>
</InfoBox>
</template>
<template v-else>
<button
type="button"
class="btn role-secondary"
:disabled="isView"
@click="addNodeSelector"
>
{{ t('harvester.hostNetworkConfig.nodeSelector.addButton') }}
</button>
</template>
</Tab>
</ResourceTabs>
</CruResource>
</template>
<style lang="scss" scoped>
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0 20px;
}
.node-selector-box {
position: relative;
.remove {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
padding: 0;
}
}
.underlay-conflict-warning {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: var(--warning);
font-size: 13px;
}
</style>
@@ -31,6 +31,10 @@ export default {
to() {
return this.vm?.detailLocation;
},
attachVMName() {
return this.vm?.nameDisplay || this.vm?.metadata?.name || this.value;
}
}
};
</script>
@@ -40,10 +44,10 @@ export default {
v-if="to"
:to="to"
>
{{ value }}
{{ attachVMName }}
</router-link>
<span v-else>
{{ value }}
{{ attachVMName }}
</span>
</template>
@@ -0,0 +1,23 @@
<script>
export default {
name: 'HarvesterBooleanFormatter',
props: {
value: {
type: Boolean,
default: false,
},
},
};
</script>
<template>
<span v-if="value">
<i class="icon icon-checkmark" />
</span>
<span
v-else
class="text-muted"
>
&mdash;
</span>
</template>
@@ -0,0 +1,29 @@
<script>
export default {
name: 'HarvesterHostNetworkConfigModeFormatter',
props: {
value: {
type: String,
default: '',
},
},
computed: {
displayMode() {
if (this.value?.toLowerCase() === 'dhcp') {
return 'DHCP';
}
if (this.value?.toLowerCase() === 'static') {
return 'Static';
}
return this.value;
},
},
};
</script>
<template>
<span>{{ displayMode }}</span>
</template>
@@ -20,12 +20,7 @@ export default {
computed: {
vmiResource() {
const vmiList = this.$store.getters['harvester/all'](HCI.VMI) || [];
const vmi = vmiList.find( (VMI) => {
return VMI?.metadata?.ownerReferences?.[0]?.uid === this.vmResource?.metadata?.uid;
});
return vmi;
return this.$store.getters['harvester/byId'](HCI.VMI, this.vmResource?.id) || null;
},
migrationState() {
return this.vmiResource?.migrationState?.status || '';
@@ -0,0 +1,24 @@
<script>
export default {
name: 'HarvesterVlanFormatter',
props: {
value: {
type: String,
default: '',
},
},
};
</script>
<template>
<span v-if="value">
{{ value }}
</span>
<span
v-else
class="text-muted"
>
&mdash;
</span>
</template>
@@ -14,20 +14,6 @@ export default {
type: Object,
required: true
},
allNodeNetwork: {
type: Array,
default: () => {
return [];
}
},
allClusterNetwork: {
type: Array,
default: () => {
return [];
}
}
},
data() {
+416 -24
View File
@@ -1,3 +1,8 @@
wizard:
create: Create
next: Proceed
previous: Back
generic:
tip: Tip
resourceExternalLinkTips: 'External Link'
@@ -7,6 +12,7 @@ generic:
inProgress: In Progress
basic: Basic
loading: Loading...
none: None
unsupported:
serverVersion: 'Current version: <code>{serverVersion}</code>'
@@ -21,6 +27,7 @@ nav:
Logging: Logging
'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports
vmMigration: VM Migration
resourceTable:
groupBy:
@@ -34,6 +41,8 @@ members:
projectMembership: Project Membership
asyncButton:
createAndStart:
action: Create and Start
restart:
action: Save and Restart
success: Restarted
@@ -144,6 +153,10 @@ harvester:
migration:
failedMessage: Latest migration failed!
title: Migration
vmMigrationTitle: '{count, plural, one {Migrating # VM} other {Migrating # VMs}}'
selectedVMs: "The following virtual machine(s) will be migrated to the target node"
unknownNode: (unknown node)
alreadyOnTarget: Already on Target
fields:
nodeName:
label: Target Node
@@ -248,6 +261,7 @@ harvester:
suspendSchedule: Suspend
restoreExistingVM: Replace Existing
migrate: Migrate
vmMigrate: Virtual Machine Migration
cpuAndMemoryHotplug: Edit CPU and Memory
abortMigration: Abort Migration
storageMigration: Storage Migration
@@ -290,6 +304,12 @@ harvester:
harvesterIpAddress:
customIpTooltip: "Custom IP (set via annotation)"
tableHeaders:
hostNetworkConfig:
underlay: Underlay
underlayTooltip: Allow this interface to act as the underlay for VM overlay networks.
vlanID: VLAN ID
mode: Mode
clusterNetwork: Cluster Network
imageEncryption: Encryption
size: Size
virtualSize: Virtual Size
@@ -298,6 +318,7 @@ harvester:
phase: Phase
attachedVM: Attached Virtual Machine
cpuManager: CPU Manager
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
fingerprint: Fingerprint
value: Value
actions: Actions
@@ -336,6 +357,16 @@ harvester:
vmImportSourceOClusterStatus: Cluster Status
vmImportSourceOVAUrl: URL
vmImportSourceOVAStatus: Status
v4ip: V4 IP
v6ip: V6 IP
eipName: EIP Name
vmMigrationProviderType: Type
vmMigrationProviderUrl: URL
vmMigrationMapSourceProvider: Source Provider
vmMigrationMapDestProvider: Destination Provider
vmMigrationPlanTargetNs: Target Namespace
vmMigrationPlanVmCount: VMs
vmMigrationMigrationPlan: Plan
tab:
volume: Volumes
network: Networks
@@ -349,6 +380,7 @@ harvester:
snapshots: Snapshots
instanceLabel: Instance Labels
annotations: Annotations
filesystem: Filesystem Volume
fields:
version: Version
name: Name
@@ -385,21 +417,6 @@ harvester:
middle: vGPU Devices
suffix: page.
deviceInTheSameHost: 'You can only select devices on the same host.'
oldFormatDevices:
help: |-
<p>
The following PCI devices are using the old naming convention and need to be updated in the YAML file:
</p>
<ul>
{oldFormatDevicesHTML}
</ul>
<p>
Please use the following instructions to update the virtual machine:
</p>
<ol>
<li>Stop the virtual machine, edit the virtual machine YAML, and remove the <Code>hostDevices</Code> section, and save virtual machine the changes to the YAML file.</li>
<li>Edit the virtual machine, and add the already enabled PCI Device from the list of available PCIDevices, and save and start VM.</li>
</ol>
showCompatibility: Show device compatibility matrix
hideCompatibility: Hide device compatibility matrix
claimError: Error enabling passthrough on {name}
@@ -417,6 +434,8 @@ harvester:
suffix: to enable the add-on to successfully manage your PCI devices.
noPCIPermission: Please contact your system administrator to enable the PCI devices first.
enablePassthroughWarning: Please be careful not to use host-owned PCI devices (e.g., management and VLAN NICs). Incorrect device allocation may cause damage to your cluster, including node failure.
disableResourcePooling: Disable Resource Pooling
disableResourcePoolingDescription: Assigns this device a unique resource name so it can be pinned to a specific VM, instead of sharing the pool with other identical devices.
devices:
matrixHostName: Host Name
@@ -781,7 +800,7 @@ harvester:
addPort: Add Port
cloudConfig:
title: Cloud Configuration
createTemplateTitle: 'Create {name}.'
createTemplateTitle: 'Create {name}'
createNew: Create new...
cloudInit:
label: Cloud Init
@@ -794,6 +813,17 @@ harvester:
label: Network Data Template
title: "Network Data:"
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
sysprep:
title: Windows Sysprep Configuration
description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
createNew: Create new...
createNewTitle: Create Windows Sysprep Template
xmlContent: autounattend.xml content
secret:
label: Windows Sysprep Template
validation:
invalidXml: Invalid XML syntax
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
scheduling:
affinity:
anyNode: 'Run virtual machine on any available node'
@@ -824,6 +854,18 @@ harvester:
username: Username
password: Password
reservedMemory: Reserved Memory
customizeDisplayName: Customize virtual machine display name
displayNamePlaceholder: Virtual machine alias name
displayName: Display Name
filesystem:
description: Harvester supports filesystem volumes for VM via virtiofs.
type: Filesystem Type
volume: Volume
resource: Resource
add: Add
mountBannerHint: "Please update the mount path (e.g. /mnt/appconfigfs) to your preferred location, then add the corresponding commands to the"
mountBannerHintLink: "runcmd"
mountBannerHintSuffix: "in Advanced tab User Data."
machineTypeTip: 'Specify a processor architecture to emulate. To see a list of supported architectures, run: qemu-system-x86_64 -cpu ?'
detail:
tabs:
@@ -1112,6 +1154,7 @@ harvester:
replaceExisting: Replace existing
virtualMachineName: Virtual Machine Name
keepMacAddress: Keep MAC Address
haltAfterRestore: Keep powered off after restore
matchTarget: The current backup target does not match the existing one.
progress:
details: Volume details
@@ -1142,6 +1185,9 @@ harvester:
gateway:
label: Gateway IP
placeholder: e.g. 172.20.0.1
vlan:
label: VLAN
placeholder: Select a VLAN
dhcp:
label: Dynamic Host Configuration Protocol (DHCP)
v4Options: DHCPV4Options
@@ -1149,6 +1195,9 @@ harvester:
placeholder: key1=value1, key2=value2
dhcpOptionBanner: DHCP options is a key/value string concatenate with comma. For more detail, please refer to <a href="https://kubeovn.github.io/docs/v1.13.x/en/kubevirt/dhcp/" target="_blank">KubOVN document</a>
tooltip: Enable DHCP server for this subnet. When enabled, VMs can automatically obtain IP addresses from this subnet.
externalConnectivity:
label: NAT Outgoing
tooltip: Enable NAT for VMs using this subnet to access external networks.
private:
label: Private Subnet
tooltip: Enable network isolation for this Subnet. When enabled, VMs can only communicate within this subnet, even if other subnets exist under the same VPC.
@@ -1224,14 +1273,97 @@ harvester:
remoteVpc:
label: Remote VPC
infoBanner: The static route destination CIDR must cover all subnets CIDR from remote VPC Peer. Read <a href="{url}" target="_blank">VPC Peering Configuration Examples</a> for more information.
natGateway:
label: Gateways
internalTenantNetwork:
label: Internal Tenant Network
placeholder: Select a Virtual Machine Network
vpc:
label: VPC
placeholder: Select a VPC
subnet:
label: Subnet
placeholder: Select a subnet
lanIp:
label: LAN IP
placeholder: Enter LAN IP
externalSubnets:
label: External Subnets
addLabel: Add
placeholder: Select a subnet
externalIP:
label: External IPs
natGateway:
label: VpcNatGateway
placeholder: Select a VpcNatGateway
externalSubnet:
label: External Subnet
placeholder: Select an external subnet
v4ip:
label: V4 IP
placeholder: public ip from external subnet
snat:
label: Source Rules
eip:
label: EIP
placeholder: Select an external IP
internalCIDR:
label: Internal CIDR
placeholder: internal subnet CIDR
dnat:
label: Destination Rules
eip:
label: EIP
placeholder: Select an external IP
externalPort:
label: External Port
placeholder: port number
internalIp:
label: Internal IP
placeholder: internal IP address
internalPort:
label: Internal Port
placeholder: port number
protocol:
label: Protocol
placeholder: Select protocol (tcp or udp)
providerNetwork:
label: Provider Networks
defaultInterface:
label: Default Interface
placeholder: Select the interface the same as master interface of external overlay network
customInterfaces:
label: Custom Interfaces
addLabel: Add Custom Interface
interface:
label: Network Interface
placeholder: e.g. eth2
nodes:
label: Nodes
addLabel: Add Node
placeholder: Select a node
excludedNodes:
label: Excluded Nodes
addLabel: Add Excluded Node
placeholder: Select node to exclude from this provider network
vlanNetwork:
label: VLANs
vlan:
id:
label: VLAN ID
placeholder: "e.g. 1-4094"
provider:
label: Provider Network
placeholder: Select a provider network
networkPolicy:
label: Network Policies
label: Policies
banner: The network policies must be used for VMs attached to overlay networks. Please read the <a href="{url}" target="_blank">harvester document</a> how the network policy works.
network:
label: Virtual Machine Networks
tabs:
basics: Basics
layer3Network: Route
nic: Network Interface Card
clusterNetwork:
label: Cluster Network
create: Create a new cluster network
@@ -1300,6 +1432,7 @@ harvester:
invalid: '"Range" is invalid.'
clusterNetwork: Cluster Network
vlan: VLAN ID
exclusiveVlan: Exclusive VLAN
exclude:
label: Exclude IPs
placeholder: CIDR format, e.g. 172.16.0.10/32
@@ -1516,6 +1649,7 @@ harvester:
vmSnapshot:
label: Virtual Machine Snapshots
createText: Restore Snapshot
title: Restore Virtual Machine
snapshot: Snapshot
storage:
@@ -1523,6 +1657,7 @@ harvester:
useDefault: Use the default storage
volumeEncryption: Volume Encryption
secret: Secret
volumeExpansionCheckbox: Enable Expansion
migratable:
label: Migratable
numberOfReplicas:
@@ -1615,8 +1750,9 @@ harvester:
schedulingRules: Select node(s) matching rules
uplink:
nics:
label: NICs
label: NIC
addLabel: Add NIC
overlayWarning: The NIC selected here must match the NIC provided in the provider network.
placeholder: Select a NIC that is available on all the selected nodes
validate:
available: NIC "{nic}" is not available on the selected nodes
@@ -1678,6 +1814,178 @@ harvester:
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
vmMigration:
generic:
unknown: Unknown
identifier: Identifier
podNetwork: Pod Network
usedBy: "Used by:"
vlan: "VLAN {id}"
vCpu: "{count} vCPU"
memoryGb: "{value} GB"
memoryTb: "{value} TB"
elapsed:
hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}"
hours: "{hours} {hours, plural, one {hour} other {hours}}"
minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}"
errors:
failedLoadPlans: Failed to load migration plans
failedLoadVms: Failed to load virtual machines
failedRefreshVms: Failed to refresh virtual machines
failedLoadProviders: Failed to load providers
failedLoadProvider: "Failed to load provider: {error}"
failedResolveDetails: Failed to resolve network and datastore details
failedDecodeCredentials: Failed to decode provider credentials
connectionFailed: Connection failed
providerNotReady: Provider not ready
labels:
dashboard: Migrations
provider: Providers
networkMap: Network Maps
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
providerWizard:
title: Create Provider
editTitle: Edit Provider
wizard:
title: VM Migration
steps:
configureProvider:
label: Provider
description: Configure Provider
selectVms:
label: VMs
description: Select VMs
configureMappings:
label: Mappings
description: Define Mappings
reviewMigration:
label: Migration Plan
description: Review Migration Plan
fields:
username: Username
password: Password
configureProvider:
title: Configure Provider
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
connectionDetails: Connection Details
providerSelect: Provider
createNew: Create new provider
requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher."
name: Name
urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https://
skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments
testConnection:
action: Test Connection
success: Connection successful
error: Connection failed
waiting: Testing connection...
testSuccess: Connection test passed
testFailed: Connection test failed
testMissingFields: Please fill in all required fields before testing
testTimeout: Connection test timed out — the provider did not report a status in time
save: Save Provider and Continue
saveExisting: Check Provider and Continue
selectVms:
title: Select Virtual Machines
description: Select the virtual machines you want to migrate from the source provider
discovered: "{count} VMs discovered from"
lastSynced: "Last synced {time} ago • "
refreshNow: Refresh now
availableVms: Available Virtual Machines
selected: selected
saveSelection: Save Selection and Continue
selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page."
selectAll: "Select all {count} VMs from this provider"
allSelected: "Your current selection includes all VMs."
clearSelection: "Clear Selection"
manualEntry:
title: Add Virtual Machines
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
vmId: VM ID
vmIdPlaceholder: "e.g. vm-60527"
add: Add
added: VMs added
columns:
vmName: VM Name
vmId: VM ID
os: Operating System
resources: Resources
powerState: Power State
network: Network
datastore: Datastore
dashboard:
title: VMware to Harvester Migration
description: Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester.
createPlan: Create Migration
tableTitle: Migrations
columns:
status: Status
plan: Migration Plan
progress: Progress
mappings: Mappings
progress:
failed: Failed
migration: Migration
initializingMigration: Initializing migration
finishedSuccessfully: Finished Successfully
step: "Step {index}"
vmCount: "{count} VMs"
vmId: "id: {id}"
configureMappings:
title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue
noTemplate: Start from scratch
removeMap: Remove Map
networkMapping:
title: Network Mapping
description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network
template: Use existing network mapping as template
options:
podNetworking: Pod Networking
ignored: Ignored
storageMapping:
title: Storage Mapping
description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Longhorn Storage Volume
template: Use existing storage mapping as template
reviewMigration:
title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name
planNamePlaceholder: "e.g. my-migration-plan"
migrationDetails: Migration Details
totalVms: Total VMs
vcpu: vCPU
memory: Memory
storage: Storage
source: Source
targetNamespace: Target Namespace
migrationMode: Migration Mode
coldMigration: Cold Migration
virtualMachines: Virtual Machines
warningTitle: "Cold Migration Warning"
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
startMigration: Start Migration Plan
plan:
states:
error: Error
canceled: Canceled
inProgress: In Progress
active: Succeeded
actions:
start: Start
restart: Restart
stop: Stop
vmImport:
titles:
basic: Basic
@@ -1858,6 +2166,32 @@ harvester:
addLabel: Add CIDR
range:
addLabel: Add Range
hostNetworkConfig:
label: Host Networks
mode:
label: Mode
tabs:
mode: Mode
nodeSelector: Node Selector
nodeSelector:
addButton: Add Node Selector
underlay:
label: Underlay
tooltip: Allow this interface to act as the underlay for VM overlay networks.
conflict: '`{name}` host network config already has underlay enabled. Only one underlay is allowed in the cluster.'
noKubeovn:
prefix: The kubeovn-operator add-on is not enabled. Click
middle: here
suffix: to enable the add-on for overlay networking.
vlanID:
label: VLAN ID
ipRange:
label: IP Range ({node})
placeholder: e.g. 192.168.1.10/24
ips:
nodeLabel: Node
label: IP
placeholder: 'e.g. 192.168.1.10/24'
service:
healthCheckPort:
@@ -1903,6 +2237,8 @@ harvester:
migconfiguration:
label: vGPU MIG Configurations
status:
outOfSync: Out of Sync
infoBanner: To configure the MIG configuration, please disable it first and re-enable after editing the configuration.
profileSpec: Profile Specs
profileStatus: Profile Status
@@ -2043,6 +2379,8 @@ advancedSettings:
'harv-auto-rotate-rke2-certs': The certificate rotation mechanism relies on Rancher. Harvester will automatically update certificates generation to trigger rotation.
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on Harvester administration kubeconfig files. Default is 0, which means to never expire.'
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
'harv-longhorn-v2-data-engine-hugepage-enabled': 'Enable hugepages when using the Longhorn V2 data engine. Default is true. Disabling hugepages reduces memory pressure on low-spec nodes and increases deployment flexibility. However, performance may be lower compared to running with hugepages.'
'harv-longhorn-v2-data-engine-memory-size': 'Configure the amount of memory allocated to the SPDK target daemon when using the Longhorn V2 data engine. Default is 2048 MiB.'
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
'harv-upgrade-config': 'Configure image preloading and VM restore options for upgrades. See related fields in <a href="{url}" target="_blank" rel="noopener">settings/upgrade-config</a>'
'harv-vm-migration-network': 'Segregated network for VM migration traffic.'
@@ -2113,6 +2451,36 @@ typeLabel:
one { Virtual Private Cloud }
other { Virtual Private Clouds }
}
kubeovn.io.vlan: |-
{count, plural,
one { VLAN Network }
other { VLAN Networks }
}
kubeovn.io.providernetwork: |-
{count, plural,
one { Provider Network }
other { Provider Networks }
}
kubeovn.io.vpcnatgateway: |-
{count, plural,
one { NAT Gateway }
other { NAT Gateways }
}
kubeovn.io.iptablessnatrule: |-
{count, plural,
one { Source Rule }
other { Source Rules }
}
kubeovn.io.iptablesdnatrule: |-
{count, plural,
one { Destination Rule }
other { Destination Rules }
}
kubeovn.io.iptableseip: |-
{count, plural,
one { External IP }
other { External IPs }
}
networking.k8s.io.networkpolicy: |-
{count, plural,
one { Network Policy }
@@ -2174,17 +2542,16 @@ typeLabel:
other { PCI Devices }
}
persistentvolumeclaim: |-
{count, plural,
one { Volume }
other { Volumes }
}
network.harvesterhci.io.clusternetwork: |-
{count, plural,
one { Cluster Network }
other { Cluster Networks }
}
network.harvesterhci.io.hostnetworkconfig: |-
{count, plural,
one { Host Network }
other { Host Networks }
}
harvesterhci.io.addon: |-
{count, plural,
one { Add-on }
@@ -2254,3 +2621,28 @@ typeLabel:
one { Virtual Machine Import }
other { Virtual Machine Imports }
}
forklift.konveyor.io.provider: |-
{count, plural,
one { Provider }
other { Providers }
}
forklift.konveyor.io.networkmap: |-
{count, plural,
one { Network Map }
other { Network Maps }
}
forklift.konveyor.io.storagemap: |-
{count, plural,
one { Storage Map }
other { Storage Maps }
}
forklift.konveyor.io.plan: |-
{count, plural,
one { Migration Plan }
other { Migration Plans }
}
forklift.konveyor.io.migration: |-
{count, plural,
one { Migration }
other { Migrations }
}
@@ -112,6 +112,7 @@ export default {
value: 'connectivity',
labelKey: 'tableHeaders.routeConnectivity',
formatter: 'NetworkRouteConnectivity',
tooltip: 'harvester.tableHeaders.routeConnectivityTooltip',
formatterOpts: { arbitrary: true },
width: 130,
},
@@ -165,6 +166,7 @@ export default {
:schema="schema"
:groupable="true"
:rows="filterRows"
:ignore-filter="true"
key-field="_key"
>
<template #cell:state="{row}">
@@ -106,8 +106,8 @@ export default {
name: 'AttachedVM',
labelKey: 'tableHeaders.attachedVM',
type: 'attached',
value: 'spec.claimRef',
sort: 'name',
value: 'attachVMName',
sort: 'attachVMName',
},
{
name: 'VolumeSnapshotCounts',
@@ -134,8 +134,8 @@ export default {
return row?.attachVM?.detailLocation;
},
getVMName(row) {
return row.attachVM?.metadata?.name || '';
getAttachedVMName(row) {
return row.attachVMName || '';
},
isInternalStorageClass(storageClassName) {
@@ -173,10 +173,10 @@ export default {
<template #cell:AttachedVM="{row}">
<div>
<router-link
v-if="getVMName(row)"
v-if="getAttachedVMName(row)"
:to="goTo(row)"
>
{{ getVMName(row) }}
{{ getAttachedVMName(row) }}
</router-link>
</div>
</template>
+7
View File
@@ -67,6 +67,13 @@ export default {
NAMESPACE,
CIDR_BLOCK,
PROTOCOL,
{
name: 'vlan',
labelKey: 'harvester.subnet.vlan.label',
value: 'spec.vlan',
sort: 'spec.vlan',
formatter: 'HarvesterVlan',
},
PROVIDER,
AGE
];
@@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import ResourceTable from '@shell/components/ResourceTable';
import { STATE, AGE, NAME, NAMESPACE } from '@shell/config/table-headers';
import {
@@ -22,6 +23,8 @@ export const VM_HEADERS = [
{
...NAME,
width: 350,
value: 'nameDisplay',
sort: ['nameDisplay'],
},
NAMESPACE,
{
@@ -98,19 +101,9 @@ export default {
this.hasNode = true;
}
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.NODE_NETWORK)) {
_hash.nodeNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.NODE_NETWORK });
}
if (this.$store.getters[`${ inStore }/schemaFor`](HCI.CLUSTER_NETWORK)) {
_hash.clusterNetworks = this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK });
}
const hash = await allHash(_hash);
this.allVMs = hash.vms;
this.allNodeNetworks = hash.nodeNetworks || [];
this.allClusterNetworks = hash.clusterNetworks || [];
},
data() {
@@ -118,14 +111,14 @@ export default {
hasNode: false,
allVMs: [],
allVMIs: [],
allNodeNetworks: [],
allClusterNetworks: [],
restartNotificationDisplayed: false,
HCI
};
},
computed: {
...mapGetters({ actionCb: 'action-menu/performCallbackData' }),
headers() {
const restoreCol = {
name: 'restoreProgress',
@@ -192,6 +185,13 @@ export default {
},
watch: {
actionCb(neu) {
if (neu?.clearTableSelection) {
this.$refs.resourceTable.clearSelection();
this.$store.dispatch('action-menu/clearCallbackData');
}
},
vmRestartRequiredNames(vmNames) {
const count = vmNames.length;
@@ -230,6 +230,7 @@ export default {
<Loading v-if="$fetchState.pending" />
<div v-else>
<ResourceTable
ref="resourceTable"
v-bind="$attrs"
:headers="headers"
default-sort-by="age"
@@ -246,8 +247,6 @@ export default {
<HarvesterVmState
class="vmstate"
:row="scope.row"
:all-node-network="allNodeNetworks"
:all-cluster-network="allClusterNetworks"
/>
</div>
</template>
@@ -258,7 +257,7 @@ export default {
v-if="scope.row.type !== HCI.VMI"
:to="scope.row.detailLocation"
>
{{ scope.row.metadata.name }}
{{ scope.row.nameDisplay }}
<i
v-if="scope.row.encryptedVolumeType !== 'none'"
v-tooltip="lockIconTooltipMessage(scope.row)"
@@ -267,7 +266,7 @@ export default {
/>
</router-link>
<span v-else>
{{ scope.row.metadata.name }}
{{ scope.row.nameDisplay }}
</span>
<ConsoleBar
:resource-type="scope.row"
@@ -0,0 +1,86 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
import { HCI } from '../types';
const UNDERLAY = {
name: 'underlay',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.underlay',
tooltip: 'harvester.tableHeaders.hostNetworkConfig.underlayTooltip',
value: 'spec.underlay',
sort: 'spec.underlay',
formatter: 'HarvesterBoolean',
};
const VLAN_ID = {
name: 'vlanID',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.vlanID',
value: 'spec.vlanID',
sort: 'spec.vlanID',
};
const MODE = {
name: 'mode',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.mode',
value: 'spec.mode',
sort: 'spec.mode',
formatter: 'HarvesterHostNetworkConfigMode',
};
const CLUSTER_NETWORK = {
name: 'clusterNetwork',
labelKey: 'harvester.tableHeaders.hostNetworkConfig.clusterNetwork',
value: 'spec.clusterNetwork',
sort: 'spec.clusterNetwork',
align: 'center',
};
export default {
name: 'HarvesterListHostNetworkConfig',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.HOST_NETWORK_CONFIG });
},
data() {
return { rows: [] };
},
computed: {
schema() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/schemaFor`](HCI.HOST_NETWORK_CONFIG);
},
headers() {
return [
STATE,
NAME_COL,
UNDERLAY,
VLAN_ID,
MODE,
CLUSTER_NETWORK,
AGE,
];
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:headers="headers"
:rows="rows"
:schema="schema"
key-field="_key"
/>
</template>
+23
View File
@@ -255,6 +255,29 @@ export default {
return this.convertToJson(userData)?.ssh_authorized_keys || [];
},
getSysprepConfig(spec) {
const sysprepVolume = spec?.template?.spec?.volumes?.find(
(v) => v.name === 'sysprep' && v.sysprep?.secret
);
if (!sysprepVolume) {
return { secretName: '', xmlContent: '' };
}
const inStore = this.$store.getters['currentProduct'].inStore;
const namespace = this.value.metadata.namespace;
const secretName = sysprepVolume.sysprep.secret.name;
const secret = this.$store.getters[`${ inStore }/byId`](
SECRET,
`${ namespace }/${ secretName }`
);
return {
secretName: `${ namespace }/${ secretName }`,
xmlContent: secret?.decodedData?.['autounattend.xml'] || ''
};
},
compareSSHValue(a = '', b = '') {
const r = /(\r\n\t|\n|\r\t)|(\s*)/gm;
+169 -9
View File
@@ -12,7 +12,7 @@ import { base64Decode } from '@shell/utils/crypto';
import { formatSi, parseSi } from '@shell/utils/units';
import { _CLONE, _CREATE, _VIEW } from '@shell/config/query-params';
import {
PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN
PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, SERVICE_ACCOUNT, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN
} from '@shell/config/types';
import { HOSTNAME } from '@shell/config/labels-annotations';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
@@ -25,7 +25,7 @@ import { HCI } from '../../types';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
@@ -102,6 +102,8 @@ export default {
vmims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIM }),
vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }),
secrets: this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }),
configMaps: this.$store.dispatch(`${ inStore }/findAll`, { type: CONFIG_MAP }),
serviceAccounts: this.$store.dispatch(`${ inStore }/findAll`, { type: SERVICE_ACCOUNT }),
addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }),
longhornV2Engine: this.$store.dispatch(`${ inStore }/find`, { type: LONGHORN.SETTINGS, id: LONGHORN_V2_DATA_ENGINE }),
};
@@ -156,6 +158,7 @@ export default {
imageId: '',
diskRows: [],
networkRows: [],
filesystemRows: [],
machineType: '',
machineTypes: [],
secretName: '',
@@ -183,6 +186,7 @@ export default {
terminationGracePeriodSeconds: '',
cpuPinning: false,
cpuModel: '',
sysprep: { secretName: '', xmlContent: '' },
};
},
@@ -440,6 +444,18 @@ export default {
this['imageId'] = imageId;
this['diskRows'] = diskRows;
this['filesystemRows'] = this.getFilesystemRows(vm);
let sysprepConfig = { secretName: '', xmlContent: '' };
if (osType === 'windows') {
sysprepConfig = this.getSysprepConfig(spec);
}
this['sysprep'] = {
secretName: sysprepConfig.secretName || this.sysprep?.secretName || '',
xmlContent: sysprepConfig.xmlContent || this.sysprep?.xmlContent || '',
};
this.refreshYamlEditor();
},
@@ -605,7 +621,8 @@ export default {
out = sortBy(out, 'bootOrder');
return out.filter( (O) => O.name !== 'cloudinitdisk');
// Filter out cloudinitdisk and sysprep disk from UI display.
return out.filter( (O) => O.name !== 'cloudinitdisk' && O.name !== 'sysprep');
},
getNetworkRows(vm, config) {
@@ -641,6 +658,80 @@ export default {
this.parseAccessCredentials();
this.parseNetworkRows(this.networkRows);
this.parseDiskRows(this.diskRows);
this.parseFilesystemRows();
},
getFilesystemRows(vm) {
const _filesystems = vm.spec.template.spec.domain.devices?.filesystems || [];
const _volumes = vm.spec.template.spec.volumes || [];
return _filesystems.map((fs) => {
const volume = _volumes.find((v) => v.name === fs.name);
let fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP;
let resourceName = '';
if (volume?.configMap) {
fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP;
resourceName = volume.configMap.name;
} else if (volume?.secret) {
fsType = FILESYSTEM_SOURCE_TYPE.SECRET;
resourceName = volume.secret.secretName;
} else if (volume?.serviceAccount) {
fsType = FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT;
resourceName = volume.serviceAccount.serviceAccountName;
}
return {
fsType,
volumeName: fs.name,
resourceName,
};
});
},
parseFilesystemRows() {
const completedRows = this.filesystemRows.filter(
(r) => r.fsType && r.volumeName && r.resourceName
);
const filesystems = completedRows.map((r) => ({
name: r.volumeName,
virtiofs: {},
}));
const fsVolumes = completedRows.map((r) => {
if (r.fsType === FILESYSTEM_SOURCE_TYPE.CONFIGMAP) {
return {
name: r.volumeName,
configMap: { name: r.resourceName },
};
} else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SECRET) {
return {
name: r.volumeName,
secret: { secretName: r.resourceName },
};
} else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT) {
return {
name: r.volumeName,
serviceAccount: { serviceAccountName: r.resourceName },
};
}
return null;
}).filter(Boolean);
if (filesystems.length > 0) {
this.spec.template.spec.domain.devices['filesystems'] = filesystems;
} else {
delete this.spec.template.spec.domain.devices['filesystems'];
}
if (fsVolumes.length > 0) {
if (!this.spec.template.spec.volumes) {
this.spec.template.spec['volumes'] = [];
}
this.spec.template.spec.volumes.push(...fsVolumes);
}
},
parseOther() {
@@ -750,6 +841,32 @@ export default {
this.secretName = this.generateSecretName(this.secretNamePrefix);
}
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
// If we have content but no secret name, it's a new secret that needs a name.
if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) {
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
}
// Preserve/attach sysprep whenever a secret is selected/known.
if (this.sysprep.secretName) {
disks.push({
name: 'sysprep',
cdrom: { bus: 'sata' }
});
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
volumes.push({
name: 'sysprep',
sysprep: { secret: { name: secretName } }
});
}
}
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) {
if (!this.isWindows) {
disks.push({
@@ -758,7 +875,6 @@ export default {
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
@@ -907,14 +1023,22 @@ export default {
const specInterfaces = this.spec?.template?.spec?.domain?.devices?.interfaces;
const mergedInterfaces = this.mergeInterfaceList(specInterfaces, interfaces);
const devices = {
...this.spec.template.spec.domain.devices,
interfaces: mergedInterfaces,
};
if (this.isEdit && networkRow.length === 0) {
devices.autoattachPodInterface = false;
} else {
delete devices.autoattachPodInterface;
}
const spec = {
...this.spec.template.spec,
domain: {
...this.spec.template.spec.domain,
devices: {
...this.spec.template.spec.domain.devices,
interfaces: mergedInterfaces,
},
devices,
},
networks
};
@@ -1396,6 +1520,42 @@ export default {
}
},
async saveSysprepConfig(vm) {
if (!this.isWindows) {
return;
}
if (!this.sysprep.secretName?.trim?.() && !this.sysprep.xmlContent?.trim?.()) {
return;
}
const secretName = this.sysprep.secretName.split('/')[1] || this.sysprep.secretName;
const namespace = vm.metadata.namespace;
const namespacedName = `${ namespace }/${ secretName }`;
let secret;
try {
secret = await this.$store.dispatch('harvester/find', { type: SECRET, id: namespacedName });
} catch (e) {
if (e?.status !== 404) {
throw e;
}
secret = await this.$store.dispatch('harvester/create', {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { [HCI_ANNOTATIONS.WINDOWS_SYSPREP]: 'true' },
},
});
}
secret.setData('autounattend.xml', this.sysprep.xmlContent);
await secret.save();
},
getAccessCredentialsValidation() {
const errors = [];
@@ -1656,7 +1816,7 @@ export default {
const oldImageId = old[0]?.image;
if (this.isCreate && oldImageId === imageId && imageId) {
if (this.isCreate && oldImageId !== imageId && imageId && osType) {
this.osType = osType;
}
}
@@ -9,6 +9,7 @@ import HarvesterResource from './harvester';
export default class MIGCONFIGURATION extends HarvesterResource {
get _availableActions() {
let out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out = out.map((action) => {
if (action.action === 'showConfiguration') {
@@ -26,13 +27,13 @@ export default class MIGCONFIGURATION extends HarvesterResource {
out.push(
{
action: 'enableConfig',
enabled: !this.isEnabled,
enabled: !this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableConfig',
enabled: this.isEnabled,
enabled: this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
},
@@ -62,13 +63,27 @@ export default class MIGCONFIGURATION extends HarvesterResource {
}
get stateDisplay() {
if (this.configStatus === 'out-of-sync') {
return this.t('harvester.migconfiguration.status.outOfSync');
}
return this.actualState;
}
get stateColor() {
const state = this.actualState;
get stateDescription() {
if (this.status?.message) {
return this.status.message;
}
return colorForState(state);
return super.stateDescription;
}
get stateColor() {
if (this.configStatus === 'out-of-sync') {
return 'text-warning';
}
return colorForState(this.actualState);
}
get isEnabled() {
@@ -30,12 +30,11 @@ const STATUS_DISPLAY = {
export default class PCIDevice extends SteveModel {
get _availableActions() {
const out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out.push(
{
action: 'enablePassthroughBulk',
enabled: !this.isEnabling && !this.isvGPUDevice && canUpdate,
enabled: !this.isEnabling && !this.isvGPUDevice && this.canUpdate,
icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough',
bulkable: true,
@@ -44,7 +43,7 @@ export default class PCIDevice extends SteveModel {
},
{
action: 'disablePassthrough',
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice && canUpdate,
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice && this.canUpdate,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough',
bulkable: true,
@@ -55,6 +54,10 @@ export default class PCIDevice extends SteveModel {
return out;
}
get canUpdate() {
return !!this.linkFor('update');
}
get isvGPUDevice() {
if (!this.vGPUAsPCIDeviceFeatureEnabled) {
return false;
@@ -16,13 +16,13 @@ export default class SRIOVDevice extends SteveModel {
out.push(
{
action: 'enableDevice',
enabled: !this.isEnabled,
enabled: !this.isEnabled && this.canUpdate,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableDevice',
enabled: this.isEnabled,
enabled: this.isEnabled && this.canUpdate,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
},
@@ -31,6 +31,10 @@ export default class SRIOVDevice extends SteveModel {
return out;
}
get canUpdate() {
return !!this.linkFor('update');
}
get canYaml() {
return false;
}
@@ -33,7 +33,7 @@ export default class USBDevice extends SteveModel {
out.push(
{
action: 'enablePassthroughBulk',
enabled: !this.passthroughClaim && !this.status.enabled,
enabled: !this.passthroughClaim && !this.status.enabled && this.canUpdate,
icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough',
bulkable: true,
@@ -42,7 +42,7 @@ export default class USBDevice extends SteveModel {
},
{
action: 'disablePassthrough',
enabled: this.status.enabled,
enabled: this.status.enabled && this.canUpdate,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough',
bulkable: true,
@@ -53,6 +53,10 @@ export default class USBDevice extends SteveModel {
return out;
}
get canUpdate() {
return !!this.linkFor('update');
}
get canYaml() {
return false;
}
@@ -27,17 +27,18 @@ const STATUS_DISPLAY = {
export default class VGpuDevice extends SteveModel {
get _availableActions() {
const out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out.push(
{
action: 'enableVGpu',
enabled: !this.isEnabled,
enabled: !this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot',
label: 'Enable',
},
{
action: 'disableVGpu',
enabled: this.isEnabled,
enabled: this.isEnabled && canUpdate,
icon: 'icon icon-fw icon-dot-open',
label: 'Disable',
bulkable: true,
@@ -0,0 +1,258 @@
import HarvesterResource from './harvester';
import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester';
import { randomStr } from '@shell/utils/string';
export default class ForkliftPlan extends HarvesterResource {
get listLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
};
}
get planFailed() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
}
get planCritical() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.category === 'Critical' && c.status === 'True');
}
get criticalMessages() {
const conditions = this.status?.conditions || [];
return conditions
.filter((c) => c.category === 'Critical' && c.status === 'True')
.map((c) => c.message);
}
get stateDescription() {
const messages = [];
const conditions = this.status?.conditions || [];
if (this.planFailed) {
const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message;
if (failedMsg) {
messages.push(failedMsg);
}
}
if (this.planCritical) {
messages.push(...this.criticalMessages);
}
return messages.length > 0 ? messages.join(' ') : null;
}
get stateObj() {
return {
error: this.planFailed || this.planCritical,
transitioning: this.isMigrating,
message: this.stateDescription,
};
}
get isMigrating() {
const migration = this.status?.migration;
return !!migration?.started && !migration?.completed;
}
get planCanceled() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return false;
}
const latest = history[history.length - 1];
const conditions = latest.conditions || [];
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
}
get planSucceeded() {
const migration = this.status?.migration;
return !!migration?.completed && !this.planFailed && !this.planCanceled;
}
get stateDisplay() {
if (this.planFailed) {
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCritical) {
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCanceled) {
return this.t('harvester.addons.vmMigration.plan.states.canceled');
}
if (this.isMigrating) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
return this.t('harvester.addons.vmMigration.plan.states.active');
}
get stateBackground() {
if (this.planFailed) {
return 'bg-error';
}
if (this.planCritical) {
return 'bg-error';
}
if (this.planCanceled) {
return 'bg-warning';
}
if (this.isMigrating) {
return 'bg-info';
}
return 'bg-success';
}
get isForkliftDashboard() {
const route = this.currentRouter()?.currentRoute?.value;
return route?.name?.endsWith('-vm-migration');
}
get _availableActions() {
const canStop = this.isMigrating && !this.planCanceled;
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
const out = [];
if (canStart) {
out.push({
action: 'startMigration',
enabled: true,
icon: 'icon icon-play',
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
});
}
if (canStop) {
out.push({
action: 'stopMigration',
enabled: true,
icon: 'icon icon-pause',
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
});
}
if (this.isForkliftDashboard) {
out.push({
action: 'promptRemove',
altAction: 'remove',
label: this.t('action.remove'),
icon: 'icon icon-trash',
bulkable: true,
enabled: this.canDelete,
bulkAction: 'promptRemove',
weight: -10,
});
return out;
} else {
out.push(...super._availableActions);
return out;
}
}
async stopMigration() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return;
}
const latest = history[history.length - 1];
const migrationName = latest.migration?.name;
if (migrationName) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
await this.$dispatch('request', { url, method: 'DELETE' });
}
}
async startMigration() {
const namespace = this.metadata.namespace;
const history = this.status?.migration?.history || [];
// Delete previous migrations before starting a new one
for (const entry of history) {
const name = entry.migration?.name;
if (name) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
try {
await this.$dispatch('request', { url, method: 'DELETE' });
} catch (e) {
// Migration may already be gone — ignore 404s
}
}
}
const migration = await this.$dispatch('create', {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
namespace,
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
uid: this.metadata.uid,
blockOwnerDeletion: true,
},
],
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
namespace,
},
},
});
await migration.save();
}
async deletePlan() {
await this.remove();
}
/**
* Deleting a Plan cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider (→ Secret).
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove(opt = {}) {
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
return this._remove(opt);
}
}
@@ -0,0 +1,36 @@
import HarvesterResource from './harvester';
import { PRODUCT_NAME } from '../config/harvester';
export default class ForkliftProvider extends HarvesterResource {
get _createLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
}
};
}
get _editLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
};
}
/**
* Deleting a Provider cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove(opt = {}) {
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
return this._remove(opt);
}
}
@@ -235,6 +235,10 @@ export default class HciPv extends HarvesterResource {
return allVMs.find(findAttachVM);
}
get attachVMName() {
return this.attachVM?.nameDisplay || this.attachVM?.metadata?.name || '';
}
get isAvailable() {
const unAvailable = ['Resizing', 'Not Ready'];
@@ -96,6 +96,10 @@ export default class HciStorageClass extends StorageClass {
return this.$rootGetters['harvester-common/getFeatureEnabled']('volumeEncryption');
}
get expandOnlineEncryptedVolumeFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('expandOnlineEncryptedVolume');
}
get thirdPartyStorageFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
}
@@ -23,17 +23,17 @@ export default class HciVmTemplateVersion extends HarvesterResource {
});
const schema = this.$getters['schemaFor'](HCI.VM);
let canCreateVM = true;
let canCreateVM = false;
if ( schema && !schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase())) ) {
canCreateVM = false;
if (schema?.collectionMethods.find((x) => ['post'].includes(x.toLowerCase())) ) {
canCreateVM = true;
}
return [
{
action: 'launchFromTemplate',
icon: 'icon icon-spinner',
disabled: !canCreateVM || !this.isReady,
enabled: canCreateVM && this.isReady,
label: this.t('harvester.action.launchFormTemplate'),
},
{
@@ -123,7 +123,12 @@ const IgnoreMessages = ['pod has unbound immediate PersistentVolumeClaims'];
export default class VirtVm extends HarvesterResource {
get availableActions() {
const out = super._availableActions;
let out = super._availableActions;
if (this.isCloneBackendStorageCloning || this.isCloneBackendStorageFailed) {
out = out.filter(({ action }) => action !== 'goToClone');
}
const clone = out.find((action) => action.action === 'goToClone');
if (clone) {
@@ -164,6 +169,7 @@ export default class VirtVm extends HarvesterResource {
},
{
action: 'restartVM',
altAction: 'altRestartVM',
enabled: !!this.actions?.restart,
icon: 'icon icon-refresh',
label: this.t('harvester.action.restart'),
@@ -171,17 +177,19 @@ export default class VirtVm extends HarvesterResource {
bulkAction: 'restartVM'
},
{
action: 'softrebootVM',
enabled: !!this.actions?.softreboot,
icon: 'icon icon-pipeline',
label: this.t('harvester.action.softreboot')
action: 'softrebootVM',
altAction: 'doSoftReboot',
enabled: !!this.actions?.softreboot,
icon: 'icon icon-pipeline',
label: this.t('harvester.action.softreboot')
},
{
action: 'startVM',
enabled: !!this.actions?.start,
icon: 'icon icon-play',
label: this.t('harvester.action.start'),
bulkable: true
action: 'startVM',
enabled: !!this.actions?.start,
icon: 'icon icon-play',
label: this.t('harvester.action.start'),
bulkable: true,
bulkAction: 'startVM'
},
{
action: 'backupVM',
@@ -226,10 +234,12 @@ export default class VirtVm extends HarvesterResource {
label: this.t('harvester.action.ejectCDROM')
},
{
action: 'migrateVM',
enabled: !!this.actions?.migrate,
icon: 'icon icon-copy',
label: this.t('harvester.action.migrate')
action: 'migrateVM',
enabled: !!this.actions?.migrate,
icon: 'icon icon-copy',
label: this.t('harvester.action.vmMigrate'),
bulkable: true,
bulkAction: 'migrateVM'
},
{
action: 'abortMigrationVM',
@@ -540,16 +550,38 @@ export default class VirtVm extends HarvesterResource {
});
}
altStopVM() {
this.doActionGrowl('stop', {});
async altRestartVM() {
await this.doActionGrowl('restart', {});
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
forceStop() {
this.doActionGrowl('forceStop', {});
async altStopVM() {
await this.doActionGrowl('stop', {});
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
startVM() {
this.doActionGrowl('start', {});
async forceStop() {
await this.doActionGrowl('forceStop', {});
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
async startVM(resources = this) {
const list = Array.isArray(resources) ? resources : [resources];
for (const r of list) {
await r.doActionGrowl('start', {});
}
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
async download() {
await super.download();
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
async downloadBulk(items) {
await super.downloadBulk(items);
this.$dispatch('promptModal', { performCallback: true, clearTableSelection: true });
}
migrateVM(resources = this) {
@@ -762,6 +794,18 @@ export default class VirtVm extends HarvesterResource {
return this.volumes.filter((volume) => volume?.isLonghornV2);
}
get cloneBackendStorageStatus() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS]?.toLowerCase() || '';
}
get isCloneBackendStorageCloning() {
return this.cloneBackendStorageStatus === 'cloning';
}
get isCloneBackendStorageFailed() {
return this.cloneBackendStorageStatus === 'failed';
}
get encryptedVolumeType() {
if (!this.volumes || this.volumes.length === 0) {
return 'none';
@@ -817,13 +861,21 @@ export default class VirtVm extends HarvesterResource {
!this.isVMExpectedRunning &&
this.isVMCreated &&
this.vmi?.status?.phase === VMIPhase.Pending
) || (this.metadata?.annotations?.[HCI_ANNOTATIONS.CLONE_BACKEND_STORAGE_STATUS] === 'cloning')) {
) || this.isCloneBackendStorageCloning) {
return { status: VMIPhase.Pending };
}
return null;
}
get isCloneFailed() {
if (this.isCloneBackendStorageFailed) {
return { status: VMIPhase.Failed };
}
return null;
}
get isStopping() {
if (this &&
!this.isVMExpectedRunning &&
@@ -980,6 +1032,7 @@ export default class VirtVm extends HarvesterResource {
this.isUnschedulable?.status ||
this.isPaused?.status ||
this.isVMError?.status ||
this.isCloneFailed?.status ||
this.isPending?.status ||
this.isStopping?.status ||
this.isOff?.status ||
@@ -987,7 +1040,7 @@ export default class VirtVm extends HarvesterResource {
this.isRunning?.status ||
this.isNotReady?.status ||
this.isStarting?.status ||
this.isWaitingForVMI?.state ||
this.isWaitingForVMI?.status ||
this.otherState?.status;
return state;
@@ -1266,6 +1319,10 @@ export default class VirtVm extends HarvesterResource {
return this.$rootGetters['harvester-common/getFeatureEnabled']('schedulingVMBackup');
}
get nameDisplay() {
return this.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME] || this.metadata?.name || this.id;
}
get volumeEncryptionFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('volumeEncryption');
}
@@ -0,0 +1,366 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable';
import PercentageBar from '@shell/components/PercentageBar';
import { Banner } from '@components/Banner';
import MappingsCell from '../../../../components/MappingsCell';
import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
const store = useStore();
const { t } = useI18n(store);
const loading = ref(true);
const errors = ref([]);
const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
const rows = computed(() => {
return allPlans.value.map((plan) => {
const netMapName = plan.spec?.map?.network?.name;
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
const storMapName = plan.spec?.map?.storage?.name;
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
const vms = plan.status?.migration?.vms || [];
plan.vmProgress = vms.map((vm) => {
const pipeline = vm.pipeline || [];
const totalSteps = pipeline.length || 1;
let overallProgress = 0;
let currentStep = '';
let errorMsg = '';
pipeline.forEach((step, idx) => {
const stepWeight = 100 / totalSteps;
if (step.phase === 'Completed') {
overallProgress += stepWeight;
} else {
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
overallProgress += (stepPct / 100) * stepWeight;
if (!currentStep) {
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
}
if (step.error && !errorMsg) {
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
}
if (step.phase === 'Failed' && !errorMsg) {
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
}
});
// Fallback to VM-level error if no step-level error found
if (!errorMsg && vm.error) {
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
}
overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it
if (!errorMsg && plan.planFailed) {
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
return {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: overallProgress,
currentStep,
errorMsg,
canceled: plan.planCanceled,
};
});
// If no migration progress but we have VMs in the spec, show them at 0%
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
plan.vmProgress = plan.spec.vms.map((vm) => ({
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: 0,
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
errorMsg: '',
canceled: false,
}));
}
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
return plan;
});
});
const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
}));
const headers = [
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
{
...NAME_COL,
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
},
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
{
name: 'progress',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
value: 'progress',
width: 500,
},
{
name: 'mappings',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
},
{ ...AGE },
];
const init = async() => {
try {
await Promise.all([
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally {
loading.value = false;
}
};
init();
</script>
<template>
<Loading v-if="loading" />
<div v-else>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
>
<template #subHeader>
<div class="mmt-5">
<p class="text-muted">
{{ t('harvester.addons.vmMigration.dashboard.description') }}
</p>
</div>
</template>
<template #createButton>
<router-link
:to="createLocation"
class="btn role-primary"
>
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
</router-link>
</template>
</Masthead>
<ResourceTable
:schema="schema"
:rows="rows"
:headers="headers"
:groupable="false"
:table-actions="false"
:search="false"
key-field="_key"
>
<template #header-left>
<h3 class="table-title m-0">
{{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
</h3>
</template>
<template #cell:name="{ row }">
<div class="plan-name-cell">
<div class="plan-name">
{{ row.metadata.name }}
</div>
</div>
</template>
<template #cell:vmCount="{ row }">
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
</template>
<template #cell:progress="{ row }">
<div
v-if="row.vmProgress && row.vmProgress.length"
class="progress-cells"
>
<div
v-for="vm in row.vmProgress"
:key="vm.vmId"
class="vm-progress"
>
<div class="vm-progress-header">
<div class="vm-name-block">
<span class="vm-name">{{ vm.vmName }}</span>
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
</div>
</div>
<div class="vm-pct-block">
<PercentageBar
:model-value="vm.progress"
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
preferred-direction="MORE"
class="vm-bar"
/>
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
</div>
<div
v-if="vm.progress >= 100"
class="step-label text-muted"
>
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
</div>
<div
v-else-if="vm.errorMsg"
class="step-label text-error"
>
{{ vm.errorMsg }}
</div>
<div
v-else-if="vm.canceled"
class="step-label text-muted"
>
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
</div>
<div
v-else-if="vm.currentStep"
class="step-label text-muted"
>
{{ vm.currentStep }}
</div>
</div>
</div>
<span v-if="!row.vmProgress.length">-</span>
</template>
<template #cell:mappings="{ row }">
<MappingsCell
:network-entries="row.networkEntries"
:storage-entries="row.storageEntries"
/>
</template>
</ResourceTable>
</div>
</template>
<style lang="scss" scoped>
.plan-name-cell {
.plan-name {
font-weight: 500;
line-height: 20px;
}
.plan-vms {
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
}
.progress-cells {
display: flex;
flex-direction: column;
gap: 16px;
}
.vm-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.vm-name {
font-size: 14px;
}
.vm-name-block {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.vm-id {
font-size: 11px;
}
.vm-pct {
font-size: 14px;
min-width: 40px;
text-align: left;
}
.vm-pct-block {
display: flex;
align-items: center;
gap: 14px;
justify-content: space-between;
}
.vm-bar {
width: 100%;
}
.step-label {
font-size: 13px;
margin-top: 4px;
line-height: 20px;
&.text-error {
color: var(--error);
}
}
.table-title {
font-weight: 600;
}
</style>
@@ -0,0 +1,286 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
import { HCI } from '@pkg/harvester/types';
const store = useStore();
const route = currentRoute();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const mappingsStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const mappingsReady = ref(false);
const existingNetworkMap = ref(null);
const existingStorageMap = ref(null);
const initialLoading = ref(true);
const dummyResource = ref({ save: () => Promise.resolve() });
const providerId = route.query?.providerId || null;
const isEditMode = !!providerId;
const stepData = reactive({
provider: {
selectedProvider: isEditMode ? '' : '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
mappings: {
networkEntries: [],
storageEntries: [],
},
});
const steps = reactive([
{
name: 'configure-provider',
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
ready: false,
},
{
name: 'configure-mappings',
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
ready: false,
},
]);
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch(mappingsReady, (val) => {
steps[1].ready = val;
});
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady`, mark the step ready and advance
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
watch(
() => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => {
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
wizardComponent.value.goToStep(1);
pendingProceed.value = true;
providerStepRef.value.clickTestButton();
}
}
);
watch(providerReady, (val) => {
if (val && pendingProceed.value) {
pendingProceed.value = false;
steps[0].ready = true;
wizardComponent.value.goToStep(2);
}
});
const wizardTitle = computed(() => {
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
// Clear mappings when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
}
});
const providerListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.FORKLIFT_PROVIDER,
}
};
const onFinish = async(buttonCb) => {
try {
await mappingsStepRef.value.saveMappings();
buttonCb(true);
currentRouter().push(providerListLocation);
} catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)];
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(providerListLocation);
};
const init = async() => {
if (!isEditMode) {
initialLoading.value = false;
return;
}
const inStore = store.getters['currentProduct'].inStore;
try {
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: providerId,
opt: { force: true },
});
provider.value = fetchedProvider;
providerName.value = fetchedProvider.metadata.name;
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret;
if (secretRef) {
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
});
const secret = allSecrets.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
stepData.provider.username = decodeSecretValue(secret.data.user);
stepData.provider.password = decodeSecretValue(secret.data.password);
stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
stepData.provider.createdSecret = secret;
}
}
// Fetch existing default maps for this provider
try {
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
existingNetworkMap.value = allNetworkMaps.find(
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
) || null;
existingStorageMap.value = allStorageMaps.find(
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
) || null;
} catch (e) {
// Maps may not exist yet
}
} catch (err) {
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
}
initialLoading.value = false;
};
init();
</script>
<template>
<Loading v-if="initialLoading" />
<CruResource
v-else
ref="cruRef"
:resource="dummyResource"
:mode="isEditMode ? 'edit' : 'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
:title="wizardTitle"
finish-mode="finish"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
:create-only="true"
:edit-mode="isEditMode"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
ref="mappingsStepRef"
:provider-name="providerName"
:provider="provider"
:step-data="stepData.mappings"
:use-all-provider-data="true"
:existing-network-map="existingNetworkMap"
:existing-storage-map="existingStorageMap"
@ready="onMappingsReady"
/>
</template>
</CruResource>
</template>
@@ -0,0 +1,289 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import CruResource from '@shell/components/CruResource';
import { useI18n } from '@shell/composables/useI18n';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import SelectVmsStep from '@pkg/harvester/components/vm-migration/SelectVmsStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter } from '@pkg/harvester/utils/router';
const store = useStore();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const reviewStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const selectedVMs = ref([]);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const vmsReady = ref(false);
const vmsLoading = ref(true);
const mappingsReady = ref(false);
const reviewReady = ref(false);
const dummyResource = ref({ save: () => Promise.resolve() });
const stepData = reactive({
provider: {
selectedProvider: '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
vms: {
discoveredVMs: [],
selectedVMIds: new Set(),
tableRows: [],
},
mappings: {
networkEntries: [],
storageEntries: [],
},
review: { planName: '' },
});
const steps = reactive([
{
name: 'configure-provider',
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
ready: false,
},
{
name: 'select-vms',
label: t('harvester.addons.vmMigration.wizard.steps.selectVms.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.selectVms.description'),
ready: false,
},
{
name: 'configure-mappings',
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
ready: false,
},
{
name: 'review-migration',
label: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description'),
ready: false,
},
]);
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch([vmsReady, vmsLoading], () => {
steps[1].ready = vmsReady.value && !vmsLoading.value;
});
watch(mappingsReady, (val) => {
steps[2].ready = val;
});
watch(reviewReady, (val) => {
steps[3].ready = val;
});
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
// watcher clears `pendingProceed` and the user stays put.
watch(
() => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => {
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
wizardComponent.value.goToStep(1);
pendingProceed.value = true;
providerStepRef.value.clickTestButton();
}
}
);
watch(providerReady, (val) => {
if (val && pendingProceed.value) {
pendingProceed.value = false;
steps[0].ready = true;
wizardComponent.value.goToStep(2);
}
});
watch(providerTesting, (testing) => {
if (!testing && pendingProceed.value && !providerReady.value) {
pendingProceed.value = false;
}
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onVmsComplete = (data) => {
selectedVMs.value = data.selectedVMs;
vmsReady.value = data.selectedVMs.length > 0;
};
const onVmsLoading = (val) => {
vmsLoading.value = val;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
const onReviewReady = (ready) => {
reviewReady.value = ready;
};
// Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.vms.discoveredVMs = [];
stepData.vms.selectedVMIds = new Set();
stepData.vms.tableRows = [];
selectedVMs.value = [];
vmsReady.value = false;
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
// Clear mappings and review when VM selection changes
watch(selectedVMs, (newVal, oldVal) => {
const newIds = new Set(newVal.map((v) => v.id));
const oldIds = new Set(oldVal.map((v) => v.id));
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id));
if (oldVal.length > 0 && changed) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
const migrationListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
};
const onFinish = async(buttonCb) => {
try {
await reviewStepRef.value.startMigration();
buttonCb(true);
currentRouter().push(migrationListLocation);
} catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)];
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(migrationListLocation);
};
</script>
<template>
<CruResource
ref="cruRef"
:resource="dummyResource"
:mode="'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
finish-mode="finish"
finish-button-mode="createAndStart"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #select-vms>
<SelectVmsStep
:provider-name="providerName"
:provider="provider"
:step-data="stepData.vms"
@complete="onVmsComplete"
@loading="onVmsLoading"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
:provider-name="providerName"
:provider="provider"
:selected-vms="selectedVMs"
:step-data="stepData.mappings"
@ready="onMappingsReady"
/>
</template>
<template #review-migration>
<ReviewMigrationStep
ref="reviewStepRef"
:provider-name="providerName"
:provider="provider"
:selected-vms="selectedVMs"
:mapping-entries="stepData.mappings"
:step-data="stepData.review"
@ready="onReviewReady"
/>
</template>
</CruResource>
</template>
@@ -123,6 +123,7 @@ export default {
this.value?.[0]?.currentRouter().push(goTo);
}
this.close();
this.$store.commit('action-menu/togglePromptModal', { performCallback: true, clearTableSelection: true });
}).catch((err) => {
this.$emit('errors', err);
});
@@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue';
const routes = [
{
@@ -83,6 +86,18 @@ const routes = [
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
path: `/:product/c/:cluster/projectsnamespaces`,
component: ProjectNamespaces,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
path: `/:product/c/:cluster/vm-migration`,
component: ForkliftDashboard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
path: `/:product/c/:cluster/vm-migration/wizard`,
component: ForkliftVmMigrationWizard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
path: `/:product/c/:cluster/vm-migration/provider-wizard`,
component: ForkliftProviderWizard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`,
@@ -3,7 +3,8 @@ import { ClusterNotFoundError } from '@shell/utils/error';
import { SETTING } from '@shell/config/settings';
import { COUNT, NAMESPACE, MANAGEMENT } from '@shell/config/types';
import { allHash } from '@shell/utils/promise';
import { DEV } from '@shell/store/prefs';
import { DEV, NAMESPACE_FILTERS } from '@shell/store/prefs';
import { createNamespaceFilterKeyWithId } from '@shell/utils/namespace-filter';
import { HCI } from '../../types';
export default {
@@ -121,8 +122,11 @@ export default {
await dispatch('cleanNamespaces', null, { root: true });
const namespaceFilterKey = createNamespaceFilterKeyWithId(id, 'harvester');
const savedFilters = rootGetters['prefs/get'](NAMESPACE_FILTERS)?.[namespaceFilterKey];
commit('updateNamespaces', {
filters: [],
filters: savedFilters || [],
all: getters.filterNamespace(),
getters
}, { root: true });
+82 -63
View File
@@ -1,67 +1,86 @@
// To find the CRD name, you can run `kubectl api-resources` and look for the `NAME` column.
// The CRD name is usually in the format of `<plural>.<group>`, where `<plural>` is the plural form of the resource and `<group>` is the API group it belongs to.
// e.g
// 1. `virtualmachines.kubevirt.io` -> kubevirt.io.virtualmachine, the CRD name for the `VirtualMachine` resource in the `kubevirt.io` API group
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
export const HCI = {
VM: 'kubevirt.io.virtualmachine',
VMI: 'kubevirt.io.virtualmachineinstance',
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
IMAGE: 'harvesterhci.io.virtualmachineimage',
SSH: 'harvesterhci.io.keypair',
VOLUME: 'harvesterhci.io.volume',
USER: 'harvesterhci.io.user',
SETTING: 'harvesterhci.io.setting',
UPGRADE: 'harvesterhci.io.upgrade',
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
BACKUP: 'harvesterhci.io.virtualmachinebackup',
RESTORE: 'harvesterhci.io.virtualmachinerestore',
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
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',
CLUSTER: 'harvesterhci.io.management.cluster',
DASHBOARD: 'harvesterhci.io.dashboard',
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
HOST: 'harvesterhci.io.host',
VERSION: 'harvesterhci.io.version',
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
FLOW: 'harvesterhci.io.logging.flow',
OUTPUT: 'harvesterhci.io.logging.output',
STORAGE: 'harvesterhci.io.storage',
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
KSTUNED: 'node.harvesterhci.io.ksmtuned',
HUGEPAGES: 'node.harvesterhci.io.hugepage',
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
ADD_ONS: 'harvesterhci.io.addon',
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
SECRET: 'harvesterhci.io.secret',
INVENTORY: 'metal.harvesterhci.io.inventory',
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
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',
VM: 'kubevirt.io.virtualmachine',
VMI: 'kubevirt.io.virtualmachineinstance',
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
IMAGE: 'harvesterhci.io.virtualmachineimage',
SSH: 'harvesterhci.io.keypair',
VOLUME: 'harvesterhci.io.volume',
USER: 'harvesterhci.io.user',
SETTING: 'harvesterhci.io.setting',
UPGRADE: 'harvesterhci.io.upgrade',
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
BACKUP: 'harvesterhci.io.virtualmachinebackup',
RESTORE: 'harvesterhci.io.virtualmachinerestore',
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
SUBNET: 'kubeovn.io.subnet',
VPC: 'kubeovn.io.vpc',
IP: 'kubeovn.io.ip',
VLAN: 'kubeovn.io.vlan',
IPTABLES_EIP: 'kubeovn.io.iptableseip',
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
CLUSTER: 'harvesterhci.io.management.cluster',
DASHBOARD: 'harvesterhci.io.dashboard',
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
HOST: 'harvesterhci.io.host',
VERSION: 'harvesterhci.io.version',
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
FLOW: 'harvesterhci.io.logging.flow',
OUTPUT: 'harvesterhci.io.logging.output',
STORAGE: 'harvesterhci.io.storage',
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
KSTUNED: 'node.harvesterhci.io.ksmtuned',
HUGEPAGES: 'node.harvesterhci.io.hugepage',
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
ADD_ONS: 'harvesterhci.io.addon',
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
SECRET: 'harvesterhci.io.secret',
INVENTORY: 'metal.harvesterhci.io.inventory',
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
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',
// Forklift CRDs
FORKLIFT_PROVIDER: 'forklift.konveyor.io.provider',
FORKLIFT_NETWORK_MAP: 'forklift.konveyor.io.networkmap',
FORKLIFT_STORAGE_MAP: 'forklift.konveyor.io.storagemap',
FORKLIFT_PLAN: 'forklift.konveyor.io.plan',
FORKLIFT_MIGRATION: 'forklift.konveyor.io.migration',
};
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
+32 -12
View File
@@ -31,22 +31,42 @@ export function registerAddonSideNav(store, productName, {
}, 600);
};
const hasAccessibleSchema = (t) => {
try {
return !!store.getters[`${ productName }/schemaFor`]?.(t);
} catch (e) {
return false;
}
};
const showTypes = (visibleTypes) => {
store.commit('type-map/basicType', {
product: productName,
group: navGroup,
types: visibleTypes
});
};
const hideTypes = () => {
const basicTypes = store.state['type-map'].basicTypes[productName];
if (basicTypes) {
types.forEach((t) => delete basicTypes[t]);
}
};
// 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];
const accessibleTypes = visible ? types.filter(hasAccessibleSchema) : [];
if (basicTypes) {
types.forEach((t) => delete basicTypes[t]);
}
// Always clear first to remove any previously-registered types that are
// no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped).
hideTypes();
if (accessibleTypes.length > 0) {
showTypes(accessibleTypes);
}
kickSideNav();
};
+80
View File
@@ -0,0 +1,80 @@
// Shared helpers for the Forklift VM-migration feature.
// Keep the map-spec builders here so the wizard step and the review step
// (and any future edit flow) stay in sync instead of drifting apart.
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
/**
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
*/
export function decodeSecretValue(val) {
try {
return atob(val || '');
} catch (e) {
return '';
}
}
/**
* Bytes → whole GiB (rounded). Returns 0 for falsy input.
*/
export function bytesToGB(bytes) {
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
}
/**
* MiB → whole GiB (rounded). Returns 0 for falsy input.
*/
export function mbToGB(mb) {
return Math.round((mb || 0) / 1024);
}
/**
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
* Entries without a chosen target are dropped so we never emit an invalid
* multus destination with an empty name.
*
* @param {Array} entries network mapping entries ({ name, id, target })
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
*/
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => {
const source = { name: entry.name, id: entry.id };
if (entry.target === 'pod') {
return { source, destination: { type: 'pod' } };
}
if (entry.target === 'ignored') {
return { source, destination: { type: 'ignored' } };
}
const parts = entry.target.split('/');
const name = parts.length > 1 ? parts[1] : parts[0];
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
return {
source,
destination: {
type: 'multus', name, namespace
},
};
});
}
/**
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
* Entries without a chosen target are dropped.
*
* @param {Array} entries storage mapping entries ({ name, id, target })
*/
export function buildStorageMapEntries(entries = []) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
}));
}
+11
View File
@@ -0,0 +1,11 @@
// Mirrors Resource class methods from @shell/plugins/dashboard-store/resource-class.js
// useRouter()/useRoute() from vue-router don't work in extension mode,
// so we use the same global access pattern the shell uses internally.
export function currentRouter() {
return window.$globalApp.$router;
}
export function currentRoute() {
return window.$globalApp.$route;
}
+907 -2170
View File
File diff suppressed because it is too large Load Diff