Compare commits

..
Author SHA1 Message Date
Harvester Bot 9b78eac733 chore: bump version to 1.9.0-rc5
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-12 05:03:54 +00:00
mergify[bot] d1367fc9a9 fix: remove map ownerref writeback and delete maps via plan refs (#1105) (#1106) 2026-08-10 20:58:35 +08:00
mergify[bot]andrenovate[bot] 18c973c8c4 deps: update dependency @types/lodash to v4.17.25 (#1099) (#1104)
(cherry picked from commit 72282ae50e)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 10:14:21 +08:00
mergify[bot]andVolker Theile c9b26ab4bb fix: Rename to Windows Unattended & Sysprep (#1096) (#1098)
Rename title and other mentions of Sysprep to prevent misunderstandings regarding functionality. For the average Windows administrator who simply wants to automate the installation of a Windows ISO, it isn't immediately intuitive when the term “Sysprep” is used on its own.

Related to: https://github.com/harvester/harvester/issues/1836
Related to: https://github.com/harvester/harvester-ui-extension/pull/954


(cherry picked from commit 8fba4e7050)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-08-07 11:29:43 +08:00
Harvester Bot 2b9d093b38 chore: bump version to 1.9.0-rc4 (#1094)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-05 21:06:57 +08:00
mergify[bot]andAndy Lee 1b3666e15c fix(subnet): support None option for VLAN dropdown (#1085) (#1090)
(cherry picked from commit be6e9b5f06)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-05 14:01:35 +08:00
mergify[bot]andAndy Lee 0bccb9a71f fix: clear keyword should clear highlight (#1088) (#1089)
(cherry picked from commit a88bed9eb4)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-05 00:26:04 +08:00
mergify[bot]andAndy Lee 6d4cd09568 deps: bump @rancher/shell to 3.0.12-rc.7 (#1084) (#1087)
* deps: update rancher/shell to 3.0.12-rc.7



* fix: move filter labels/schedule buttons position



* feat: improve serial console



* feat(serial-console): add terminal search toolbar and highlight tuning



* refactor: clear searach result after close



* refactor: fine tune serial console



---------


(cherry picked from commit f97f14c811)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-04 19:57:02 +08:00
mergify[bot]andAndy Lee 44078bf7cb fix: project resource quota not set in POST API payload (#1082) (#1086)
* fix: project resource quota not set in POST API payload



* fix: guard onQuotasInput against undefined payload



---------


(cherry picked from commit f8a29a1c54)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-04 13:35:47 +08:00
mergify[bot]andfreeze 1d55319aba feat: support attaching volumes as shareable disks (#1026) (#1083)
Show a Shareable checkbox when attaching an existing volume whose
    PVC is RWX Block and not provisioned by Longhorn. Checking it sets
    disk.shareable and shows a data-corruption warning banner.

    Related issue: harvester/harvester#9650


(cherry picked from commit 9a935434c2)

Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-08-03 16:53:57 +08:00
mergify[bot]andrenovate[bot] 3b7a0c30a7 deps: update patch dependencies (#1075) (#1080)
(cherry picked from commit d050f21f9a)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 16:46:22 +08:00
mergify[bot]andAndy Lee d4562a91d1 feat: add access/volume mode support and improvements for VM migration (#1074) (#1081)
* fix: VM migration tab not appear when first login



* feat: add access/ volume mode model



* feat: add selected all | clear all actions for selected VM



* fix: bugs in VM selection table



* refactor: copilot review



---------


(cherry picked from commit afe632b723)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-03 13:33:26 +08:00
mergify[bot]andAndy Lee 080f9cdb29 fix: prevent VM detail page hang when static IP assigned (#1072) (#1073)
Make syncStaticIpAnnotations idempotent so it no longer mutates VM
annotations when already in sync, breaking a reactive update loop that
froze the browser on the VM detail page. Also stack multiple IP
addresses vertically in the IP Address column.


(cherry picked from commit b87f367583)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-31 16:59:16 +08:00
mergify[bot]andAndy Lee b19da16b83 ci: disable footer-max-line-length rule (#1069) (#1070)
(cherry picked from commit 92aa8ec603)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-30 10:13:51 +08:00
16a92c5e96 feat: allow Cloud Config for Windows guests (Cloudbase-Init) (backport #984) (#1068)
Allow users to author and persist Cloud Config (cloud-init) on Windows VMs. Today the Cloud Config editor is hidden entirely when OS Type is set to Windows, and even if user-data/network-data has been supplied elsewhere the cloudinitdisk volume and its Secret are silently dropped from the VM spec. This is more restrictive than what Windows guests actually support: Cloudbase-Init reads cloudinit-style NoCloud datasources on Windows and is a common way to bootstrap Windows guests (install VMDP + qemu-guest-agent, enable OpenSSH server, drop authorized_keys, run first-boot runcmd scripts, join a domain, etc.).

Windows guests today have to hand-edit the VM YAML after the UI creates the VM — a workflow that is fragile, undiscoverable, and defeats the point of a UI. See the linked upstream issue for the same request from the operator side.

Backport of https://github.com/harvester/harvester-ui-extension/issues/984




(cherry picked from commit e013534373)

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-28 13:57:34 +02:00
Andy Lee 51a76eefe2 revert: PR build artifact (#1067)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 15:46:54 +08:00
mergify[bot]andAndy Lee 690652f204 ci: trigger standalone build when PR labeled with build-artifact (backport #1058) (#1059)
* ci: trigger standalone build when PR labeled with build-artifact (#1058)

* ci: trigger standalone build when PR labeled with build-artifact

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

* ci: skip upload gate for PR-triggered standalone builds

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
(cherry picked from commit ac26fe2f0e)

* fix: CI_BRANCH

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:54:41 +08:00
mergify[bot]andAndy Lee a0f21d5902 feat: add VMware VDDK image input field to VM migration provider (#1057) (#1060)
* feat: add VMware VDDK image option to VM migration provider

Add an optional VMware VDDK Image field to the Configure Provider step of the VM migration wizard, bound to spec.settings.vddkInitImage when set and left unset when empty. Supports create and edit flows and populates from the existing provider when editing.



* refactor: copilot review



---------


(cherry picked from commit a6bd7ec00e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:33:31 +08:00
mergify[bot]andAndy Lee f0929100f0 feat: validate migration plan name against existing providers and plans (#1053) (#1054)
Show an error banner and disable the Create and Start action when the migration plan name matches an existing provider or migration plan name.


(cherry picked from commit 790cade04d)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-24 16:46:52 +08:00
mergify[bot]andAndy Lee 78ca4bb663 refactor: new design for review page (#1051) (#1052)
* refactor: new design for review page



* refactor: customize empty table rows message



* refactor: fixed the table column



* style: update header tag and align fixed header actions in review migration step



* style: remove unneeded overflow css



---------


(cherry picked from commit 2903e86c02)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-24 10:30:00 +08:00
mergify[bot]andAndy Lee eb917c9d18 fix: forklift mapping UX and migration progress fixes (#1049) (#1050)
* refactor: modify relatedResources using ownerReferences uid



* refactor: wordings



* fix(vm-migration): keep completed VMs green and linkable in failed plans

When a plan fails overall, VMs that already reached 100% were marked with an error: their progress bar turned red while the label still read "Finished Successfully", and the migrated-VM detail link was hidden.

Skip the plan-level failure fallback for VMs at 100% and drop the !planFailed guard from the detail-link condition so successfully migrated VMs stay green and stay navigable.



---------


(cherry picked from commit 2d747e435a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-23 14:41:01 +08:00
Harvester Bot 8815c70361 chore: bump version to 1.9.0-rc3 (#1048)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-22 20:01:38 +08:00
mergify[bot]andVolker Theile eb4c0504c8 fix: Creating a VM with no cloud init user data but network data only fails (#1044) (#1045)
Related to: https://github.com/harvester/harvester/issues/11213


(cherry picked from commit a178302f73)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-21 11:09:55 +08:00
mergify[bot]andAndy Lee 82e7b0da19 docs: require scope-less conventional commit format in guides (#1042) (#1043)
(cherry picked from commit d376bfb3a5)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-21 11:06:37 +08:00
mergify[bot]andAndy Lee 92f34fa64f fix(vm-migration): show elapsed duration for in-progress plans (#1037) (#1038)
formatDuration returned an empty value when a migration was in progress (started set but completed missing), causing the duration column to show an em-dash for all running plans. It now computes elapsed time up to Date.now() when completed is absent, only returning the placeholder when started is unset or timestamps are invalid.

Related issue: harvester/harvester#10417


(cherry picked from commit 7a84e08f99)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 17:07:09 +08:00
mergify[bot]andAndy Lee 6039f21320 docs(agents): clarify agent guide and share it across AI assistants (#1035) (#1036)
* ci: disable header-max-length commit rule



* docs(agents): clarify agent guide and share it across AI assistants

- Symlink CLAUDE.md and GEMINI.md to AGENTS.md so Claude Code, Copilot
  and Gemini read the same guidance
- Use the full conventional-commit format with scope in the guide
- Clarify Vue SFC block order and the feature-flag development steps
- Bump the documented Node.js requirement to v24



---------


(cherry picked from commit 3d39626b93)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:55:10 +08:00
mergify[bot]andAndy Lee 3cbaf97399 refactor(host): update resource usage gauges and table column headers to fit small screen (#1031) (#1034)
* refactor(host): update resource usage gauges and table column headers

- Replace ConsumptionGauge with PercentageBar in CPU/Memory/Storage
  formatters to show the percentage next to the progress bar
- Rename the Disk State column header to Disk (via i18n)
- Display the CPU Manager column header on two lines



* refactor: add noopener noreferrer rel



---------


(cherry picked from commit 1c3b40b631)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:52:16 +08:00
mergify[bot]andAndy Lee bcde1744e1 fix(ci): support scoped commit prefixes in PR label extraction (#1032) (#1033)
Strip the scope in parentheses so titles like 'refactor(host): ...'
resolve to the 'refactor' type instead of falling back to 'other'.


(cherry picked from commit 87baf75301)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:29:24 +08:00
mergify[bot]andAndy Lee e991c9bcc8 feat: improve VM migration dashboard mapping labels and VM detail link (#1029) (#1030)
* feat: improve VM migration dashboard mapping labels and VM detail link

- Display Management Network / Ignored labels via i18n in mappings column and review card
- Add clickable external-link icon to migrated VM detail page after successful migration
- Add tooltip to the VM detail external-link icon



* refactor: modify duration column width



* refactor: copilot review comment



---------


(cherry picked from commit a10d651827)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-20 14:42:53 +08:00
mergify[bot]andrenovate[bot] 6e709bc00f deps: update dependency @types/node to v25.9.5 (#1021) (#1028)
(cherry picked from commit 11ff7c8a7c)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 12:28:04 +08:00
mergify[bot]andJack Yu 20d40860ce feat: support skip MIG mode configuration in srivogpu (#1009) (#1027)
(cherry picked from commit f6dffe9816)

Signed-off-by: Jack Yu <jack.yu@suse.com>
Co-authored-by: Jack Yu <jack.yu@suse.com>
2026-07-20 12:20:09 +08:00
mergify[bot]andAndy Lee 1496c17f3d docs: add XSS safety comment for VM display name annotation (#1019) (#1020)
(cherry picked from commit b813bc578e)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-17 17:32:24 +08:00
mergify[bot]andAndy Lee e99e4ef34f fix: remove bootable volume validation in VM disk validator (#1016) (#1017)
(cherry picked from commit 7c3b2730ab)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-17 17:00:59 +08:00
73e022172b fix: stale cloud-init secret name reused after VM name change (#1012) (#1015)
* fix: regenerate stale cloud-init secret name after VM name change

When creating a VM, the cloud-init/sysprep secret name was generated
once and cached for the component's lifetime (needNewSecret is always
false during create). If a first create attempt was rejected by the
API (e.g. duplicate name) and the user corrected the name and retried,
the VM was created successfully but reused the secret name derived
from the rejected attempt, and any subsequent secret-creation failure
surfaced as a stale error after the VM already existed.

Now the secret name (and Windows sysprep secret name) is regenerated
whenever the VM name prefix used to generate it no longer matches the
current name, and is proactively reset on a failed create attempt so
retries always start fresh.

Fixes harvester/harvester#11174




* refactor: copilot review



* refactor: remove console



---------



(cherry picked from commit 429d9e18ad)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-17 15:50:51 +08:00
mergify[bot]andAndy Lee 7e86c9fae0 ci: modify PR template (#1013) (#1014)
* ci: modify PR template



* refactor: review comment



---------


(cherry picked from commit dc166d8fc9)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-17 15:50:41 +08:00
mergify[bot]andAndy Lee b4b49222c3 feat: allow user specify namespace for migrated VM (#1007) (#1011)
* feat: allow user specify namespace for migrated VM



* refactor: not pre-select target namespace



* refactor: ignore option



* refactor: copilot review



---------


(cherry picked from commit 3ff4534294)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-16 16:16:19 +08:00
mergify[bot]andAndy Lee b937a2147b feat: keep upgrade button appear (#1003) (#1008)
* feat: keep upgrade button appear



* refactor: copilot review



---------


(cherry picked from commit d4223ad2ae)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-16 11:46:21 +08:00
mergify[bot]andAndy Lee 07f2b02a9d feat: add migration plan duration time column (#1005) (#1006)
* feat: add migration plan duration time column



* feat: allow duration column to be sorted



---------


(cherry picked from commit 44297ada16)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-15 19:20:06 +08:00
Harvester Bot 35b6949cdc chore: bump version to 1.9.0-rc2 (#1002)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-15 17:46:50 +08:00
mergify[bot]andAndy Lee c577f589e7 fix: allow input vlan 1 in host network (#999) (#1001)
(cherry picked from commit 9640c87947)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-15 15:25:42 +08:00
mergify[bot]andVolker Theile b1387820ed fix: A disk entry must be either disk or cdrom, never both (backport #998) (#1000)
Related to: https://github.com/harvester/harvester/issues/11154


(cherry picked from commit 2cefd7cb78)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-15 09:15:09 +02:00
mergify[bot]andAndy Lee 45f72749f5 refactor: add customized migration plan delete model (#996) (#997)
* refactor: add customized migration plan delete model



* refactor: copilot review



* refactor: copilot review



---------


(cherry picked from commit fb6ffa3c0f)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 17:40:44 +08:00
mergify[bot]andAndy Lee a65b3f443e feat: add migration steps tooltip when mouse hover on progress bar (#994) (#995)
* feat: add progress bar tooltip



* refactor: copilot review



---------


(cherry picked from commit e0436bff28)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 14:39:59 +08:00
mergify[bot]andAndy Lee 21d415b5f3 refactor: rename pod network to management network (#992) (#993)
(cherry picked from commit 219f39ae52)

Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 14:27:26 +08:00
mergify[bot]andAndy Lee 7493fe2dfc refactor: forklift UI bug and enhancement (#989) (#991)
* fix: hide Virtul machine migration tabs if forklift addon is not enabled



* fix: rename advanced to configurations



* fix: fixed column width



* refactor: review comment



* revert: power on background color



---------


(cherry picked from commit 41cf44d231)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-14 12:07:06 +08:00
mergify[bot]andJack Yu e71d12f67f feat: prevent users from selecting mounted devices (#987) (#990)
(cherry picked from commit 7d09cb7142)

Signed-off-by: Jack Yu <jack.yu@suse.com>
Co-authored-by: Jack Yu <jack.yu@suse.com>
2026-07-14 11:58:48 +08:00
mergify[bot]andmarcelofukumoto d3b77e2e41 feat: forklift UI (#891) (#988)
* feat: forklift poc first commit



* feat: add migration and plan detail pages with progress tracking



* feat(forklift): UI based on the FIGMA



* feat(forklift): Fixed config based on new figma changes



* feat(forklift): New changes added based on new figma



* feat(forklift): Add the succeded state



* Merge the new APIs



* feat(forklift): Applied fix to select the proper data on network. Added condition to show critical



* feat(forklift): Added icons, critical state, fix grids, action button



* feat(forklift): Change to use the proper provider instead of a hardcoded



* feat(forklift): Added pagination UI



* feat(forklift): FIxed the routing for the extension



* feat(forklift): route and router doesnt work properly on extensions



* feat(forklift): fixed to fix the icon on extension



* feat(forklift): Added overflow hidden to mapping target list



* feat(forklift): Added better handling on the saving and testing



* feat(forklift): Remove test flags



* feat(forklift): Changed based on review



* feat(forklift): fixed title and button



* feat(forklift): Design changes



* feat(forklift): Changed to Wizard



* feat(forklift): added the provider wizard



* feat(forklift): Fixed some UI changes



* feat(forklift): Removed wrong configs used for testing



* feat(forklift): update size



* feat(forklift): small fixes



* feat(forklift): small fixes after Copilot



* feat(forklift): small fixes after Copilot



* feat(forklift): reverted settings.json changes



* feat(forklift): small fixes after Copilot



* feat(forklift): Fix the way it is registered



* feat(forklift): Fixed the type for VMware



* feat(forklift): small changes before rebase



* feat: Added new API for the vms



* feat: Fix some labels



* feat: Added some final fixes to structure



* feat: added loading to provider steps



* feat: added comments from copilot review



---------


(cherry picked from commit 560643d299)

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
Co-authored-by: marcelofukumoto <marceloyfukumoto@gmail.com>
2026-07-13 17:46:33 +08:00
mergify[bot]andRenuka Devi Rajendran 73126b953c feat: Display mgmt interface details under mgmt cluster network (#974) (#986)
(cherry picked from commit c3bb0ea4be)

Signed-off-by: Renuka Devi Rajendran <renuka.rajendran@suse.com>
Co-authored-by: Renuka Devi Rajendran <renuka.rajendran@suse.com>
2026-07-10 12:01:34 +08:00
mergify[bot]andAndy Lee 937afd9c9a chore: limit the rancher to 2.15.0+ for harvester v1.9.0 (#982) (#985)
(cherry picked from commit 3c37f04ef6)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-10 10:59:31 +08:00
mergify[bot]andVolker Theile 3658caf067 feat: Allow user to customize snapshot/backup deadline (#866) (#980)
* feat: Allow user to customize snapshot/backup deadline

Related to: https://github.com/harvester/harvester/issues/4098
Related to: https://github.com/harvester/harvester/pull/10545



* feat: Allow user to customize snapshot/backup deadline

Related to: https://github.com/harvester/harvester/issues/4098
Related to: https://github.com/harvester/harvester/pull/10545



---------


(cherry picked from commit d98db6ba49)

Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-09 18:34:52 +08:00
mergify[bot]andAndy Lee a067751cba feat: allow user specify static IP when VM creation (#973) (#979)
* feat: allow suer give static IP for VM



* feat: add feature flag



---------


(cherry picked from commit 531ed03796)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-09 13:20:22 +08:00
Harvester Bot 94566b2b54 chore: bump version to 1.9.0-rc1 (#978)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-08 17:47:16 +08:00
mergify[bot]andAndy Lee d47bf1d712 fix: vpc topology should consume NAD labels (#975) (#976)
(cherry picked from commit 59f260eab8)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-08 13:50:17 +08: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
Andy Lee d291a35754 feat: add PodSecurityStandard setting (#842)
* feat: add PodSecurity Standard setting

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

* refactor: unneeded change

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

* refactor: filter by isSystem namespace

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

* refactor: add fallback logic

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-07 13:15:47 +08:00
Andy Lee 5cc8b4c301 fix: some actions should be hidden for read-only user (#855)
* fix: some actions limited for virt-viewer

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

* refactor: based on schema collectionMethods

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

* fix: conditionally add migrate action to available actions

* fix: update collectionMethods check to use find for case-insensitive matching

* fix: update canEditClusterMembers method to use schema for collectionMethods

* fix: update canCreateImage check to use case-insensitive matching

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-07 10:21:26 +08:00
Andy Lee 032700293c perf: improve vm list page performance (#835)
* fix: remove unneeded persistentvolumeclaim type label translation key

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

* refactor: improve lockIconTooltipMessage call twice

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

* refactor: avoid watch allVMs

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

* perf: improve the some functions with pre-created map

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

* perf: improve the vm list page

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

* refactor: AI comment

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

* refactor: based on feedback

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-04 16:33:59 +08:00
Jack Yu 8cb793e7ad feat: add class type colume for usb device (#839)
Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-05-04 15:17:01 +08:00
Andy Lee 2c45b71d1f ci: remove brackets in PR Management (#852)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-04 14:30:02 +08:00
Andy Lee 5a301dcf55 fix: workflow not trigger (#849)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-04 13:50:24 +08:00
Andy Lee 1a92265d03 ci: replace pull_request_target with two-step workflows (#841)
* ci: update PR auto assign workflows

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

* ci: update backport label workflow

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

* ci: update backport PR via mergify workflow

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

* ci: update add PR label workflow

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

* refactor: file name

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

* refactor: limit auto-assign-check for target branches

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-03 23:11:55 +08:00
Andy Lee 8f65915bad refactor: enable edit as yaml in VM image page (#843)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-05-03 23:05:36 +08:00
Andy Lee 67bb6dfbd5 docs: add AGENTS.md for AI agent guidance (#837)
* feat: add AGENNTS.md

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

* refactor: update based on copilot feedback

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

* refactor: update AGENTS.md

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

* refactor: update based on AI suggestion

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

* refactor: based on comments

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

* refactor: some files

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

* refactor: boundaries.md

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-29 16:05:51 +08:00
renovate[bot] e74428d951 deps: update dependency @types/node to v25.6.0 (#830)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 11:44:54 +08:00
renovate[bot] 629f7df6b9 deps: update patch dependencies (#829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-26 22:38:51 +08:00
Tim Liou b7119d5c4c chore: bump sha for upstream action pinning (#827)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-04-24 16:36:43 +08:00
Andy Lee 6fdd1e3954 fix: change auth/V3user to auth/user (#824)
* fix: change auth/V3user to auth/user

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

* refactor: extract to utils/auth.js

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-22 15:39:24 +08:00
Andy Lee 7d0f33f31d feat: show generic error message for API response 40x error (#816)
* feat: add generic error for API response 40X

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

* refactor: fallback error msg

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

* refactor: update error msg

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

* refactor: based on comment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-21 17:55:30 +08:00
Andy Lee 9ce95daf76 ci: disable digest update in renovate.json (#823)
* ci: disable digest update in renovate.json

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

* refactor: keep but disable digest update auto merge

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

* refactor: add schedule for digest update

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-21 16:49:01 +08:00
Andy Lee ce72232bc3 fix: failed build-extension-charts CI (#822)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-20 16:51:45 +08:00
Andy Lee afc0e0f531 chore: bump @rancher/shell to 3.0.12-rc.1 (#810)
* chore: bump @rancher/shell to 3.0.12-rc.1

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

* ci: update node version to 24

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

* ci: update build catalog yaml

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

* fix: nav items order

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

* refactor: remove unneeded weightType

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-20 16:29:57 +08:00
renovate[bot] e941cc9a90 deps: update dependency node-forge to v1.4.0 [security] (#780)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 12:14:47 +08:00
renovate[bot] 9961523d08 deps: update dependency follow-redirects to v1.16.0 [security] (#813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 11:11:06 +08:00
Andy LeeandCopilot c4d1018388 fix: provisioner not update when deleting disk (#791)
* fix: provisioner not update when deleting disk

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

* refactor: update pkg/harvester/edit/harvesterhci.io.host/HarvesterDisk.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: based on feedback

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 10:56:42 +08:00
Andy Lee be64329776 feat: pop up dialog when enabling nvidia driver addon (#811)
* feat: add nvidia driver toolkit dialog

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

* refactor: add disable button guard

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

* refactor: based on feedback

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-17 11:00:10 +08:00
Andy Lee 35411ed87a Revert "deps: update patch digest dependencies (#804)" (#808)
This reverts commit 15eb0f07f7.
2026-04-13 14:42:16 +08:00
renovate[bot] 15eb0f07f7 deps: update patch digest dependencies (#804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 14:17:27 +08:00
Andy Lee fb78f24fdd chore: add v1.8.1 v1.9.0 feature flags (#792)
* chore: add v1.8.1 and v1.9.0 feature flags

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

* chore: bump to v1.9.0-dev

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-13 14:08:44 +08:00
renovate[bot] 81ad827829 deps: update fossas/fossa-action action to v1.9.0 (#794)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 16:10:40 +08:00
Andy Lee 6dd9b33336 ci: add add_minrelaseday to delay dep update (#798)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-04-07 15:22:11 +08:00
Tim Liou 1f9e9b336b chore: bump version to 1.8.0-rc5 (#789)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-04-01 09:07:56 +08:00
Andy Lee c5b4f6cd1e refactor: add banner in PCI Devices page (#785)
* refactor: add banner in PCI Devices page

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

* refactor: based on copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-31 17:45:06 +08:00
Andy Lee 4ce35ce075 fix: the rancher-eio/read-vault-secret sha (#786)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-31 14:02:41 +08:00
Andy Lee 27c26bd782 ci: remove unused EMBED_PKG variable (#783)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-31 13:48:43 +08:00
Tim Liou 9d698b1230 chore: bump version to 1.8.0-rc4 (#778)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-03-27 16:38:29 +08:00
freezeandAndy Lee 566e79eda5 feat: support advance option and creation from DataVolume (#776)
* feat: support advance option and creation from DataVolume

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

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>

* feat: add data migration action on volume page

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>

* refactor: use show advanced options link instead of checkbox

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

* feat: add feature flag

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

* feat: add feature flag for dataMigration action

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

---------

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-26 17:29:14 +08:00
Andy Lee 42ddcfc1fe chore: bump version to 1.8.0-rc3 (#774)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-25 17:27:39 +08:00
Andy Lee ad3decf71f fix: use default value got undefined value in payload (#772)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-25 17:08:32 +08:00
Andy Lee 8083a41df0 ci: remove the single quota for use commit hash (#767)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-25 14:08:59 +08:00
Andy Lee 46b860260a ci: add CODEOWNERS file (#768)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-25 13:37:15 +08:00
freeze 62801b3b13 chore: pin GH Actions to commit sha (#765) 2026-03-25 10:12:25 +08:00
renovate[bot] 161e3bbd97 deps: update dependency yaml to v2.8.3 (#755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 00:05:52 +08:00
Andy Lee 97e93dba0b docs: update install instruction doc link (#753)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-20 16:36:09 +08:00
Andy Lee 9a8a709e56 refactor: change rwxNetwork setting to kind json (#751)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-20 13:28:30 +08:00
Andy Lee d1949641a7 feat: introduce rwxNetwork setting (#746)
* feat: add rwxNetwork setting

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

* fix: network payload

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-19 15:55:55 +08:00
freeze 9c9f59c939 feat: add storage migration operation (#724)
- add storage migration
    - add cancel storage migration

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
2026-03-19 11:45:54 +08:00
Andy Lee ccc14c7fb9 chore: bump to v1.8.0-rc2 (#747)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-19 11:28:06 +08:00
Andy Lee 2ba471907e feat: introduce instance-manager-resources setting (#744)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-18 17:16:12 +08:00
Andy Lee 5aea476f64 refactor: remove using resourceName to determine is vGPU device (#741) 2026-03-18 10:35:47 +08:00
Andy Lee 519c7d9f1f fix: typo (#740)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-17 16:26:59 +08:00
Tim Liou a9c392c13f fix: reword the error message to focus on bootable volume (#736)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-03-12 17:43:07 +08:00
Tim Liou 888ec7a50f fix: container disks don't need volumeClaimTemplates but volumes (#735)
Signed-off-by: Tim Liou <tim.liou@suse.com>
2026-03-12 17:42:21 +08:00
Po Han Huang a2486a7d38 feat: ensure the state is pending when perform cloning the efi (#730)
* feat: ensure the state is pending when perform cloning the efi

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

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

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

---------

Signed-off-by: pohanhuang <pohan.huang@suse.com>
2026-03-11 15:30:33 +08:00
Andy Lee df3d249923 chore: bump to v1.8.0-rc1 (#731)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-03-11 15:29:27 +08:00
Andy Lee 23344e0c07 feat: add vGPU filter button and hide the enable/disable passthrough in PCIDevice page (#729)
* feat: add another filter button

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

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

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

* refactor: update with conditionally rendering

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

---------

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

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

* refactor: set insecureSkipTLSVerify default to false

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

* fix: conflict

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

* refactor: remove unneeded change

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

* fix: get the feature flag in data()

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

* refactor: make data logic simpler

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

* refactor: put tip in info banner

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

---------

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

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

* feat: add supportBundleFileNameSetting feature flag

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

---------

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

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

* refactor: update VMware term

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

---------

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

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

* feat: add provider info

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

* refactor: remove comments

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

* fix: exclude default network

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

* feat: add VPC peering

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

* refactor: remove regex

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

* refactor: adjust row height

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

* feat: introduce auto layout

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

---------

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

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

* refactor: add comment

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

---------

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

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

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

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

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

* refactor: update based on comments

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

* refactor: calculate count from vm names

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

---------

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

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

* feat: add resume button when node paused

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

* feat: add feature flag in v1.7.0

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

---------

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

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

* fix: remove unused en-us key

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

---------

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

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

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

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

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

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

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

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

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

function registerAddonSideNav introduced in utils/dynamic-nav.js

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

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

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

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

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

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

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

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

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

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

Caused routing issues for CRUD operations

Labels moved to labelTypes section to follow standards

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

* fix(vmimport): registerAddonSideNav improved and refactored

Clear comments, code refactoring, additional checks and validations

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

* fix(vmimport): show correct status for virtualmachineimport

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

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

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

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

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

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

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

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

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

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

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

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

* feat(vmimport): add edit form for VmwareSource

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

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

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

* feat(vmimport): add edit form for OpenstackSource

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

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

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

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

* feat(vmimport): add edit form for OvaSource

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

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

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

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

* chore(vmimport): align tab names on openstacksource

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

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

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

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

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

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

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

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

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

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

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

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

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

Changing the type allows labels to show up in the UI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* fix: remove maxSocket to fix bug on ARM cluster

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

---------

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

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

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

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

* refactor: change basebranches

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

---------

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

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

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

* refactor: add descheduler description

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

---------

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

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

* fix: ensure parseVM result is immutable

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

---------

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

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

* refactor: update spec.targetStorageClassName

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

* refactor: based on comment

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

---------

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

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

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

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

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

---------

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Moritz Röhrich <moritz.rohrich@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-12 17:09:56 +11:00
Andy Lee 6fedcc353c chore: bump version to v1.7.0-rc2 (#596)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-12 09:14:24 +08:00
Yiya Chen f9bff21e84 feat: support for HotPlugNICs from Kubevirt (#582)
* refactor: rename hotplug volume
* feat: add hotplug NIC
* feat: add hot unplug
* refactor: rename NIC
* feat: get latest status
* feat: disable not ready options
* feat: filter out system networks
* refactor: update wordings

---------

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
2025-11-11 11:43:46 +08:00
Andy Lee 6735826e15 chore: update yarn.lock for @rancher/shell v3.0.8-rc.8 (#591)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-10 15:59:11 +08:00
Andy Lee 9e17e239cf chore: bump shell version to v3.0.8-rc.8 (#588)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-10 14:53:49 +08:00
Tim Serong a1cf41bda9 feat: enable snapshot and clone for LHv2 (#379)
Now that Longhorn supports volume clone with the V2 data engine, we
can enable volume snapshot and clone.

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

Signed-off-by: Tim Serong <tserong@suse.com>
2025-11-06 16:18:37 +11:00
Andy Lee db58024351 ci: lint last commit if is empty string or all zero (#584)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-05 15:07:43 +08:00
Andy Lee 81bf19419c chore: bump version to v1.7.0-rc1 (#583)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2025-11-04 16:07:02 +08:00
204 changed files with 22376 additions and 5430 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ description: Setup node and install dependencies
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
- name: Setup Nodejs with yarn caching
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
-6
View File
@@ -1,6 +0,0 @@
addAssignees: author
addReviewers: true
numberOfReviewers: 0
reviewers:
- a110605
- houhoucoop
+10 -5
View File
@@ -1,14 +1,19 @@
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
### PR Checklists
### PR Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
- Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is:
- [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
### Related Issue #
<!-- Define findings related to the feature or bug issue. -->
<!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
### Test screenshot or video
### Test screenshot or video (Required)
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
+16 -5
View File
@@ -7,16 +7,24 @@
],
"baseBranches": [
"main",
"/^release-harvester-v\\d+\\.\\d+$/"
"release-harvester-v1.7",
"release-harvester-v1.8"
],
"automergeMajor": false,
"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
@@ -33,14 +41,17 @@
{
"matchUpdateTypes": ["minor"],
"groupName": "minor dependencies",
"minimumReleaseAge": "7 days",
"labels": ["minor-update"],
"reviewers": ["a110605", "houhoucoop"]
},
{
"matchUpdateTypes": ["patch", "digest"],
"automerge": true,
"groupName": "patch digest dependencies",
"labels": ["patch-update", "automerge"]
"matchUpdateTypes": ["patch"],
"automerge": false,
"minimumReleaseAge": "7 days",
"groupName": "patch dependencies",
"labels": ["patch-update"],
"reviewers": ["a110605", "houhoucoop"]
}
]
}
+39
View File
@@ -0,0 +1,39 @@
name: "PR Management Add PR Label Collect Data"
on:
pull_request:
types: [opened, reopened, edited]
branches:
- main
- 'release-harvester-v*'
jobs:
collect:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.base_ref }}
- name: Setup Nodejs and yarn install
uses: ./.github/actions/setup
- name: Extract PR label
run: |
PR_LABEL=$(node ./scripts/extract-release-label.mjs "${{ github.event.pull_request.title }}")
echo "PR_LABEL=$PR_LABEL"
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "PR_LABEL=$PR_LABEL"
} > pr-add-label-data.env
- name: Upload PR data artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-add-label-data
path: pr-add-label-data.env
+33
View File
@@ -0,0 +1,33 @@
name: "PR Management Add PR Label"
on:
workflow_run:
workflows:
- "PR Management Add PR Label Collect Data"
types: [completed]
jobs:
auto-assign-pr-label:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
pull-requests: write
steps:
- name: Download PR data artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: pr-add-label-data
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Load PR data
run: |
cat pr-add-label-data.env >> $GITHUB_ENV
- name: Set PR label
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "PR_LABEL = $PR_LABEL"
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "$PR_LABEL"
+28
View File
@@ -0,0 +1,28 @@
name: "PR Management Auto Assign Collect Data"
on:
pull_request:
types: [opened, ready_for_review]
branches:
- main
- 'release-harvester-v*'
jobs:
collect:
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- name: Save PR data to artifact
run: |
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "PR_AUTHOR=${{ github.event.pull_request.user.login }}"
} > pr-auto-assign-data.env
- name: Upload PR data artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-auto-assign-data
path: pr-auto-assign-data.env
@@ -1,17 +0,0 @@
name: "[PR Management] Auto Assign Reviewer & Assignee"
on:
pull_request_target:
types: [opened, ready_for_review]
permissions:
pull-requests: write
jobs:
auto-assign:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: rancher/gh-issue-mgr/auto-assign-action@main
with:
configuration-path: .github/auto-assign-config.yaml
+35
View File
@@ -0,0 +1,35 @@
name: "PR Management Auto Assign"
on:
workflow_run:
workflows:
- "PR Management Auto Assign Collect Data"
types: [completed]
jobs:
auto-assign:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
env:
REVIEWERS: "a110605,houhoucoop"
permissions:
actions: read
pull-requests: write
steps:
- name: Download PR data artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: pr-auto-assign-data
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Load PR data
run: |
cat pr-auto-assign-data.env >> $GITHUB_ENV
- name: Auto assign PR author
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Assigning PR author: $PR_AUTHOR"
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-assignee "$PR_AUTHOR" --add-reviewer "$REVIEWERS"
@@ -0,0 +1,30 @@
name: "PR Management Add Labels Collect Data"
on:
pull_request:
types: [opened, reopened]
branches:
- main
- 'release-harvester-v*'
jobs:
collect:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- name: Save PR data to artifact
run: |
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "PR_BASE_REF=${{ github.event.pull_request.base.ref }}"
echo "PR_USER_LOGIN=${{ github.event.pull_request.user.login }}"
} > pr-backport-label-data.env
- name: Upload PR data artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-backport-label-data
path: pr-backport-label-data.env
+44 -55
View File
@@ -1,40 +1,44 @@
name: "[PR Management] Add Labels"
name: "PR Management Add Backport Labels"
on:
pull_request_target:
types: [opened, reopened]
branches:
- main
- 'release-harvester-v*'
permissions:
pull-requests: write
workflow_run:
workflows:
- "PR Management Add Labels Collect Data"
types: [completed]
jobs:
add-require-backport-label:
if: github.event.pull_request.draft == false &&
github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download PR data artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
ref: ${{ github.base_ref }}
name: pr-backport-label-data
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Fetch release branches and PR labels
id: fetch_info
- name: Load PR data
run: |
cat pr-backport-label-data.env >> $GITHUB_ENV
- name: Add require-backport label (main branch PRs)
if: env.PR_BASE_REF == 'main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
pr_number=${{ github.event.pull_request.number }}
pr_number="$PR_NUMBER"
release_branches=$(gh api "repos/${repo}/branches" --paginate --jq '.[].name' | grep -E '^release-harvester-v[0-9]+\.[0-9]+$' || true)
if [[ -z "$release_branches" ]]; then
echo "should_label=false" >> "$GITHUB_OUTPUT"
echo "No release branches found, skipping."
exit 0
fi
@@ -44,51 +48,36 @@ jobs:
tags=$(gh api "repos/${repo}/releases" --paginate --jq '.[].tag_name')
if echo "$tags" | grep -Fxq "$release_tag"; then
echo "should_label=false" >> "$GITHUB_OUTPUT"
echo "Release $release_tag already published, skipping."
exit 0
fi
label="require backport/v${version}"
echo "should_label=true" >> "$GITHUB_OUTPUT"
echo "backport_label=$label" >> "$GITHUB_OUTPUT"
pr_labels=$(gh pr view "$pr_number" --repo "$repo" --json labels --jq '.labels[].name' || echo "")
pr_labels_csv=$(echo "$pr_labels" | tr '\n' ',' | sed 's/,$//')
echo "pr_labels=$pr_labels_csv" >> "$GITHUB_OUTPUT"
- name: Add label if needed
if: steps.fetch_info.outputs.should_label == 'true' && !contains(steps.fetch_info.outputs.pr_labels, steps.fetch_info.outputs.backport_label)
if echo "$pr_labels" | grep -Fxq "$label"; then
echo "Label '$label' already present, skipping."
exit 0
fi
echo "Adding label: $label"
gh pr edit "$pr_number" --repo "$repo" --add-label "$label"
- name: Add backport label (release branch PRs opened by Mergify)
if: startsWith(env.PR_BASE_REF, 'release-harvester-v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Adding label: ${{ steps.fetch_info.outputs.backport_label }}"
gh pr edit ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--add-label "${{ steps.fetch_info.outputs.backport_label }}"
set -euo pipefail
add-backport-label:
if: github.event.pull_request.draft == false &&
startsWith(github.event.pull_request.base.ref, 'release-harvester-v')
runs-on: ubuntu-latest
steps:
- name: Check conditions for backport label
id: check
run: |
IS_MERGIFY=$(echo '${{ github.event.pull_request.user.login }}' | grep -iq 'mergify' && echo true || echo false)
TARGET_BRANCH=${{ github.event.pull_request.base.ref }}
IS_MERGIFY=$(echo "$PR_USER_LOGIN" | grep -iq 'mergify' && echo true || echo false)
echo "IS_MERGIFY=$IS_MERGIFY" >> $GITHUB_OUTPUT
echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_OUTPUT
if [[ "$IS_MERGIFY" != "true" ]]; then
echo "PR author is not Mergify, skipping."
exit 0
fi
- name: Add label if needed
if: steps.check.outputs.IS_MERGIFY == 'true' && startsWith(steps.check.outputs.TARGET_BRANCH, 'release-harvester-v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TARGET_BRANCH="${{ steps.check.outputs.TARGET_BRANCH }}"
version="${TARGET_BRANCH#release-harvester-v}"
version="${PR_BASE_REF#release-harvester-v}"
label="backport/v${version}"
echo "Adding label $label"
gh pr edit ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--add-label "$label"
echo "Adding label: $label"
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "$label"
@@ -0,0 +1,28 @@
name: "PR Management Request Backport via Mergify Collect Data"
on:
pull_request:
types: [closed]
branches: [main]
jobs:
collect:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- name: Save PR data to artifact
run: |
labels_json='${{ toJson(github.event.pull_request.labels.*.name) }}'
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "PR_LABELS=$(echo "$labels_json" | jq -r '[.[]] | join(",")')"
} > pr-backport-request-data.env
- name: Upload PR data artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-backport-request-data
path: pr-backport-request-data.env
+22 -16
View File
@@ -1,41 +1,47 @@
name: "[PR Management] Request Backport via Mergify"
name: "PR Management Request Backport via Mergify"
on:
pull_request_target:
types: [closed]
branches: [main]
permissions:
pull-requests: write
workflow_run:
workflows:
- "PR Management Request Backport via Mergify Collect Data"
types: [completed]
jobs:
comment-backport:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download PR data artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
ref: ${{ github.base_ref }}
name: pr-backport-request-data
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Load PR data
run: |
cat pr-backport-request-data.env >> $GITHUB_ENV
- name: Post Mergify backport command
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pr_number=${{ github.event.pull_request.number }}
pr_number="$PR_NUMBER"
repo="${{ github.repository }}"
labels_json='${{ toJson(github.event.pull_request.labels.*.name) }}'
labels=$(echo "$labels_json" | jq -r '.[] // empty')
labels=$(echo "$PR_LABELS" | tr ',' '\n')
echo "Labels on PR: $labels"
matches=$(echo "$labels" | grep -oE '^require backport/v[0-9]+\.[0-9]+$' || true)
if [[ -z "$matches" ]]; then
echo "No back‑port labels found — skipping."
echo "No back-port labels found — skipping."
exit 0
fi
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check package version
env:
@@ -25,12 +25,12 @@ jobs:
name: Build & Upload Hosted
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with:
fetch-depth: 1
# Note - Cannot use the setup action here as it uses a different yarn install arg
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
@@ -45,19 +45,20 @@ jobs:
run: ./scripts/build-upload-gate
- name: Get gcs auth
uses: rancher-eio/read-vault-secrets@main
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
with:
secrets: |
secret/data/github/repo/${{ github.repository }}/google-auth/harvester/credentials token | GOOGLE_AUTH ;
- name: Apply gcs auth
# https://github.com/google-github-actions/auth
uses: 'google-github-actions/auth@v2'
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
credentials_json: "${{ env.GOOGLE_AUTH }}"
- name: Upload build
uses: 'google-github-actions/upload-cloud-storage@v2'
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
# https://github.com/google-github-actions/upload-cloud-storage
with:
path: ${{steps.build-hosted.outputs.BUILD_HOSTED_DIR}}
@@ -71,12 +72,12 @@ jobs:
name: Build & Upload Embedded
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with:
fetch-depth: 1
# Note - Cannot use the setup action here as it uses a different yarn install arg
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
@@ -89,19 +90,19 @@ jobs:
DISABLED_EMBED_PKG: https://releases.rancher.com/harvester-ui/plugin/harvester-1.0.3.tar.gz
- name: Get gcs auth
uses: rancher-eio/read-vault-secrets@main
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
with:
secrets: |
secret/data/github/repo/${{ github.repository }}/google-auth/harvester/credentials token | GOOGLE_AUTH ;
- name: Apply gcs auth
# https://github.com/google-github-actions/auth
uses: 'google-github-actions/auth@v2'
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
credentials_json: "${{ env.GOOGLE_AUTH }}"
- name: Upload tar
uses: 'google-github-actions/upload-cloud-storage@v2'
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
with:
path: ${{steps.build-embedded.outputs.BUILD_EMBEDED_TGZ}}
destination: releases.rancher.com/harvester-ui/dashboard
@@ -114,12 +115,12 @@ jobs:
name: Build & Upload Plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with:
fetch-depth: 1
# Note - Cannot use the setup action here as it uses a different yarn install arg
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
@@ -133,19 +134,19 @@ jobs:
run: ./scripts/build-upload-gate
- name: Get gcs auth
uses: rancher-eio/read-vault-secrets@main
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
with:
secrets: |
secret/data/github/repo/${{ github.repository }}/google-auth/harvester/credentials token | GOOGLE_AUTH ;
- name: Apply gcs auth
# https://github.com/google-github-actions/auth
uses: 'google-github-actions/auth@v2'
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
credentials_json: "${{ env.GOOGLE_AUTH }}"
- name: Upload plugin tar
uses: 'google-github-actions/upload-cloud-storage@v2'
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
with:
path: dist-pkg/${{steps.ci-build-pkg.outputs.PKG_TARBALL}}
destination: releases.rancher.com/harvester-ui/plugin
@@ -155,7 +156,7 @@ jobs:
process_gcloudignore: false
- name: Upload plugin directory
uses: 'google-github-actions/upload-cloud-storage@v2'
uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2
with:
path: dist-pkg/${{steps.ci-build-pkg.outputs.PKG_NAME}}
destination: releases.rancher.com/harvester-ui/plugin/${{steps.ci-build-pkg.outputs.PKG_NAME}}
+7 -13
View File
@@ -27,14 +27,14 @@ jobs:
build-status: ${{ job.status }}
steps:
- name: Read Secrets
uses: rancher-eio/read-vault-secrets@main
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
with:
secrets: |
secret/data/github/repo/${{ github.repository }}/dockerhub/rancher/credentials username | DOCKER_USERNAME ;
secret/data/github/repo/${{ github.repository }}/dockerhub/rancher/credentials password | DOCKER_PASSWORD ;
- name: Checkout repository (normal flow)
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Enable Corepack
run: corepack enable
@@ -45,26 +45,20 @@ jobs:
git config user.email 'github-actions[bot]@users.noreply.github.com'
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ env.DOCKER_USERNAME }}
password: ${{ env.DOCKER_PASSWORD }}
- name: Setup Helm
uses: azure/setup-helm@v3
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3
with:
version: v3.8.0
- name: Setup Nodejs with yarn caching
uses: actions/setup-node@v4
with:
node-version: '20'
cache: yarn
- name: Install dependencies
run: yarn
- name: Setup Nodejs with yarn install
uses: ./.github/actions/setup
- name: Build and push UI image
run: |
publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}"
$publish
$publish
+15 -9
View File
@@ -13,7 +13,7 @@ jobs:
target_branch: ${{ steps.get-version.outputs.target_branch }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Determine target branch
id: get-version
@@ -44,7 +44,7 @@ jobs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Extract version from package.json
id: get_version
@@ -62,7 +62,13 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node from .nvmrc
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Setup environment
run: |
@@ -70,7 +76,7 @@ jobs:
yarn install --frozen-lockfile
- name: Setup Helm
uses: azure/setup-helm@v3
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3
with:
version: v3.8.0
@@ -79,7 +85,7 @@ jobs:
yarn publish-pkgs -s ${{ github.repository }} -b ${{ needs.setup-target-branch.outputs.target_branch }} -t harvester-${{ needs.extract-version.outputs.version }}
- name: Upload charts artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: charts
path: tmp
@@ -94,7 +100,7 @@ jobs:
contents: write
steps:
- name: Checkout release branch
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: '${{ github.ref_name }}'
@@ -105,7 +111,7 @@ jobs:
echo "LAST_COMMIT=${LAST_COMMIT}" >> $GITHUB_ENV
- name: Checkout target branch
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: '${{ needs.setup-target-branch.outputs.target_branch }}'
@@ -121,7 +127,7 @@ jobs:
git config user.email 'github-actions[bot]@users.noreply.github.com'
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: charts
@@ -132,7 +138,7 @@ jobs:
git push origin ${{ needs.setup-target-branch.outputs.target_branch }}
- name: Run Helm chart releaser
uses: helm/chart-releaser-action@v1.7.0
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
with:
charts_dir: ./charts
env:
@@ -17,7 +17,7 @@ jobs:
release_tag: ${{ steps.determine_tag.outputs.release_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Determine release tag
id: determine_tag
@@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check package version
env:
TAG_VERSION: ${{ github.event.release.tag_name }}
@@ -43,12 +43,10 @@ jobs:
needs:
- setup-release-tag
- check-version
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@master
uses: rancher/dashboard/.github/workflows/build-extension-charts.yml@9bf23dff6650116673346447bcf0b088e0edb27f # creators-pkg-v3.0.12
permissions:
actions: write
contents: write
deployments: write
pages: write
pull-requests: write
with:
target_branch: gh-pages
tagged_release: '${{ needs.setup-release-tag.outputs.release_tag }}'
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check package version
env:
TAG_VERSION: ${{github.ref_name}}
+34
View File
@@ -0,0 +1,34 @@
name: FOSSA Scanning
on:
push:
branches: ["main", "release-harvester-v*"]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
fossa-scanning:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# The FOSSA token is shared between all repos in Harvester's GH org. It can
# be used directly and there is no need to request specific access to EIO.
- name: Read FOSSA token
uses: rancher-eio/read-vault-secrets@d266f55186f80a893839f6e15662e67388e443e6 # v3
with:
secrets: |
secret/data/github/org/harvester/fossa/credentials token | FOSSA_API_KEY_PUSH_ONLY
- name: FOSSA scan
uses: fossas/fossa-action@ff70fe9fe17cbd2040648f1c45e8ec4e4884dcf3 # v1.9.0
with:
api-key: ${{ env.FOSSA_API_KEY_PUSH_ONLY }}
# Only runs the scan and do not provide/returns any results back to the
# pipeline.
run-tests: false
-30
View File
@@ -1,30 +0,0 @@
name: "[PR Management] Add PR Label"
on:
pull_request_target:
types: [opened, reopened, edited]
branches:
- main
- 'release-harvester-v*'
permissions:
pull-requests: write
jobs:
auto-assign-pr-label:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }}
- name: Setup Nodejs and yarn install
uses: ./.github/actions/setup
- name: Set PR label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_LABEL=$(node ./scripts/extract-release-label.mjs "${{ github.event.pull_request.title }}")
echo "PR_LABEL = $PR_LABEL"
gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --add-label "$PR_LABEL"
+2 -2
View File
@@ -1,7 +1,7 @@
name: Tests
on:
workflow_call: # This tells GH that the workflow is reusable
workflow_call: # This tells GH that the workflow is reusable
push:
branches:
- main
@@ -16,7 +16,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0 # Need full history for commit-lint
+1 -1
View File
@@ -1 +1 @@
20
24
+261
View File
@@ -0,0 +1,261 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
# Project Overview
The Harvester UI Extension is a Rancher extension that provides the user interface for Harvester within the Rancher Dashboard.
```
Note: This extension is available starting from Rancher 2.10.0. Ensure your Rancher version is 2.10.0 or later to access Harvester integration.
```
# Personas
## Software Developer
You are an expert Senior Software Engineer specializing in Vue.js and TypeScript. You have deep knowledge of Kubernetes and the Rancher ecosystem.
- **Focus**: Writing clean, maintainable, and performant code.
- **Priorities**: Adhering to the project's code style, ensuring type safety, and following best practices for component design.
# Agents
## Boundaries
- ✅ **Always:**
- Write to `.github`, `docs/`, `pkg/harvester`, `scripts/`.
- Make commits in a new branch (for a PR).
- Run `yarn lint:fix` before commits.
- Use conventional commit format:
```
<type>: <description>
```
- Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
- After updating files in `docs/agents.md/`, run `yarn agents:generate` to update root `AGENTS.md`.
- ⚠️ **Ask first:**
- Adding dependencies
- Upgrading dependencies
- 🚫 **Never:**
- Commit or log secrets, `.env`, `kubeconfig` or any API keys.
- Edit `node_modules/`.
- Commit directly to `main` (use PRs).
- Skip git hooks with `--no-verify` flag.
## Tools
### Prerequisites
- **Node.js**: `>= 24.0.0` (see `.nvmrc` for the pinned version: `24`)
- **Package manager**: `yarn` (v1 classic)
### Common Commands
| Command | Description |
|---------|-------------|
| `yarn install --frozen-lockfile` | Install dependencies (CI-safe, no lockfile changes) |
| `RANCHER_ENV=harvester API=https://<harvester-ip> yarn dev` | Start development server at `https://127.0.0.1:8005` |
| `yarn build-pkg harvester` | Build the Harvester extension package |
| `yarn serve-pkgs` | Serve the locally built extension for testing |
| `yarn lint` | Run ESLint (zero warnings allowed) |
| `yarn lint:fix` | Run ESLint with auto-fix |
| `yarn clean` | Clean build artifacts |
| `yarn agents:generate` | Regenerate `AGENTS.md` from `docs/agents.md/` sources |
### Development
- **Start dev server**:
```bash
RANCHER_ENV=harvester API=https://your-harvester-ip yarn dev
```
- `API` should point to a running Harvester cluster (e.g., `https://x.x.x.x`).
- The dashboard will be available at `https://127.0.0.1:8005`.
### Building
- **Build extension package**:
```bash
yarn build-pkg harvester
```
- **Serve locally built extension** (for integration testing with a Rancher instance):
```bash
yarn serve-pkgs
```
### Linting
- **Check** (must pass with zero warnings):
```bash
yarn lint
```
- **Auto-fix**:
```bash
yarn lint:fix
```
- ESLint covers `.js`, `.ts`, and `.vue` files.
- Always run `yarn lint:fix` before committing.
### Commit Conventions
- Commits are validated by [commitlint](https://commitlint.js.org/) via a Husky `commit-msg` hook.
- Follow [Conventional Commits](https://www.conventionalcommits.org/) format (configured in `commitlint.config.js`).
### Agent Documentation
- Source files live in `docs/agents.md/` (agents, contributors, personas subdirectories).
- After editing any source file, regenerate the root `AGENTS.md`:
```bash
yarn agents:generate
```
# Contributors Guide
## Getting Started
Please see the [Harvester UI Extension README](https://github.com/harvester/harvester-ui-extension).
To get started, follow the `Development Setup` section.
## Project Information
- **Tech Stack:**
- `Vue.js`: Framework
- `Linting`: ESLint
- `CSS`: SCSS should be used
- `TypeScript`: Primary language for logic.
- **Code Style and Standards:**
- `Language`: TypeScript is preferred for new code.
- `Vue.js`:
- Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- The `<style>` tag should include `lang="scss" scoped`.
- `Linting`: Follow the ESLint configuration in the root.
- **File Structure:**
- `.github/`: CI/CD workflows and Renovate config.
- `docs/`: Documentation source for `AGENTS.md` generation.
- `scripts/`: Bash scripts for build, CI and doc generation.
- `pkg/harvester/`: Main extension source. Files are named after K8s resource types (e.g., `kubevirt.io.virtualmachine`, `harvesterhci.io.volume`).
- `index.ts`: Plugin entry point — registers the product and auto-imports models/detail/edit views.
- `types.ts`: `HCI` constant mapping 60+ K8s resource types to string identifiers.
- `components/`: Reusable Vue components (VNC/serial console, settings panels, upgrade banners, filters).
- `config/`: Constants — settings keys, table column definitions, feature flags, type mappings, doc links.
- `detail/`: Read-only detail views per resource type. Complex resources use subdirectories with tabs.
- `dialog/`: Modal dialogs for operations (VM clone/migrate/restart, backup/restore, device passthrough, etc.).
- `edit/`: Create/edit forms. Complex resources (e.g., VM) split into subcomponents (CpuMemory, Network, Volume, CloudConfig, etc.).
- `formatters/`: Table cell formatters — state badges, usage bars, resource references.
- `l10n/`: Localization (`en-us.yaml`).
- `list/`: List (table) views per resource type, mirroring `detail/` and `edit/` naming.
- `mixins/`: Shared Vue mixins — VM helpers (`harvester-vm/`) and disk helpers (`harvester-disk.js`).
- `models/`: Model classes extending `SteveModel` with computed properties and actions. Base class: `harvester.js`.
- `pages/`: Route-level pages — dashboard, support, console, members, brand, alertmanager.
- `promptRemove/`: Custom delete-confirmation dialogs (VM, backup).
- `routing/`: Vue Router config — all product routes (CRUD, console, support, upgrade, etc.).
- `store/`: Vuex modules — `harvester-common.js` for shared state; `harvester-store/` for VM/resource creation actions with Steve integration.
- `styles/`: Global SCSS files.
- `utils/`: Helpers — VM volume templates, CPU/memory calc, cron parsing, regex validators, feature flags.
- `validators/`: Form validation functions per resource type, pushing i18n error messages.
## Harvester UI Extension Development Guide
### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
To add a feature in a new release, follow these steps:
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
3. **Render**: Use the result of the check to conditionally render the UI components.
## E2E Tests (Cypress)
See https://github.com/harvester/harvester-ui-tests
## Node Dependencies
Dependencies are managed via `package.json` and `yarn`
- To install dependencies use `yarn install`. This will fail if dependencies and versions listed in `package.json` are out of step with the `yarn.lock` file
- To add a dependency use `yarn run add:no-lock ...` instead of `yarn add`
- To upgrade a dependency use `yarn run upgrade:no-lock ...` instead of `yarn upgrade`
Renovate automatically updates dependencies and opens upgrade PRs after 10:00 AM on Sundays (`Asia/Taipei`).
For the Renovate config, see `.github/renovate.json`.
## Milestone guidance
- All issues must first be resolved in the `main` branch
- If backports are needed they can be made via the backport bot
- pull requests
- comment `@Mergifyio backport <target branch>` e.g. `@Mergifyio backport release-harvester-v1.8`
- All backported pull requests must link to a backported issue
## Creating a branch
### To resolve an issue
- Checkout the branch matching the milestone of the issue `git checkout ${targetMilestoneBranch}`. Replace `${targetMilestoneBranch}` with the target milestone of the issue. For example
- `main` for the latest unreleased minor version
- `release-harvester-v.X` for release minor versions
- `release-harvester-v1.6`
- `release-harvester-v1.7`
- `release-harvester-v1.8`
- Ensure you have the latest of that branch `git pull --rebase`
- Checkout the branch to commit the changes to `git checkout issue-${issueNumber}`. Replace `${issueNumber}` with the issue number.
## Creating a commit
- This project uses commit-lint with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to ensure consistent and meaningful commit messages.
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>: <description>
[optional body]
[optional footer(s)]
```
### Supported Types
- feat: New features
- fix: Bug fixes
- docs: Documentation changes
- style: Code style changes (formatting, missing semicolons, etc.)
- refactor: Code refactoring
- perf: Performance improvements
- test: Adding or updating tests
- build: Build system or external dependencies
- ci: CI/CD changes
- chore: Other changes that don't modify src or test files
- revert: Reverts a previous commit
- wip: Work in progress
- deps: Dependency updates
- security: Security fixes
## Creating a Pull Request
- Pull requests must come from forks
- Description should always have commit supported type prefix. E.g `fix: XXX`, `feat: OOO`
- A Pull Request will only be merged once
- ALL CI gates have passed
- At least one harvester/harvester-ui-extension code owners reviews and approves the PR
Symlink
+1
View File
@@ -0,0 +1 @@
./AGENTS.md
+2
View File
@@ -0,0 +1,2 @@
@a110605
@houhoucoop
Symlink
+1
View File
@@ -0,0 +1 @@
./AGENTS.md
+23 -3
View File
@@ -5,14 +5,27 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
> **Note:**
> This extension is available starting from **Rancher 2.10.0**. Ensure your Rancher version is **2.10.0 or later** to access Harvester integration.
## Table of Contents
- [Installation](#installation)
- [Development Setup](#development-setup)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Branch Structure](#branch-structure)
- [Testing Guidelines](#testing-guidelines)
- [Release](#release)
- [Contributing](#contributing)
- [License](#license)
## Installation
For detailed installation instructions, please refer to the [official Harvester documentation](https://docs.harvesterhci.io/v1.5/rancher/harvester-ui-extension#installation-on-rancher-210).
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
## Development Setup
Ensure **Node.js v20 or later** is installed for development and debugging.
Ensure **Node.js v24 or later** is installed for development and debugging.
### Standalone Mode
@@ -157,13 +170,20 @@ 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.
## License
Copyright (c) 2014-2025 [SUSE, LLC.](https://www.suse.com/)
Copyright (c) 2014-2026 [SUSE, LLC.](https://www.suse.com/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -2
View File
@@ -32,10 +32,11 @@ module.exports = {
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'],
'header-max-length': [0, 'always', 100],
'body-leading-blank': [2, 'always'],
'body-max-line-length': [2, 'always', 100],
'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [2, 'always', 100],
'footer-max-line-length': [0, 'always', 100],
},
// Ignore merge commits and revert commits
ignores: [
+63
View File
@@ -0,0 +1,63 @@
# Agents.md
This folder contains files used to construct a single root `AGENTS.md`
We do this because
- The single commonality between almost all AI tools is that they look for a root `AGENTS.md`
- Having the information in a single place for all AI tools is better than duplicating and maintaining the information in multiple files for each AI tool
## Process
1. Add files to or update files in `./docs/agents.md/agents`, `./docs/agents.md/personas`, `./docs/agents.md/contributors`.
2. Each file must
- Start markdown headers at the `##` level
- End in a new line
- Order contents in AGENTS.md by giving an optional number prefix
3. Run `yarn agents:generate`.
- Take the contents of `./docs/agents.md/template_agents.md`
- Insert the contents of the agents, personas and contributors files
- Output to `./AGENTS.md`
4. Commit all changes, including root `./AGENTS.md` and any files that link to it
## Guidance
### Examples
When referencing code or configuration it's good to include an example
## Future Tasks
- Add real world examples when referencing code
- Create a GH workflow to monitor `agents.md` folder files in a PR, if changed run generate script and add new AGENTS.md to PR
- Discuss
- Three multi AI tool related questions
- Questions
- how AI specific patterns, like co-pilot instructions, can be expressed in a way to be adopted by or reduce duplication with the root agents.md
- how AI tool specific metadata code can be included
- multiple agents vs personas approach works
- Proposal
- Generic information applicable to all AI tools is added to the root `agents.md`
- Agent, instruction or other AI specific files or patterns can be used in files specific to that AI tool. They must not contain duplicate information to the root `agents.md`
- The root `agents.md` continues to be AI tool agnostic
- How can folder specific agents be used
- Proposal
- `agents.md` are added to folders as long as most LLMs can make use of them and they do not duplicate information in the root `agents.md`
## Test Prompts
To validate LLMs are picking up information consider running the following prompts.
### Information
Can they provide correct information?
- `what's this project about?`
- `can you tell me the process for fixing a github issue in this repo?`
- `what makes a good software developer?`
- `what should never be done in this project?`
- `what are the folders in this project for?`
- `what are the env vars i could use when running e2e tests?`
### Actions
Can they use the information in their actions?
Pending
+21
View File
@@ -0,0 +1,21 @@
## Boundaries
- ✅ **Always:**
- Write to `.github`, `docs/`, `pkg/harvester`, `scripts/`.
- Make commits in a new branch (for a PR).
- Run `yarn lint:fix` before commits.
- Use conventional commit format:
```
<type>: <description>
```
- Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
- After updating files in `docs/agents.md/`, run `yarn agents:generate` to update root `AGENTS.md`.
- ⚠️ **Ask first:**
- Adding dependencies
- Upgrading dependencies
- 🚫 **Never:**
- Commit or log secrets, `.env`, `kubeconfig` or any API keys.
- Edit `node_modules/`.
- Commit directly to `main` (use PRs).
- Skip git hooks with `--no-verify` flag.
+65
View File
@@ -0,0 +1,65 @@
## Tools
### Prerequisites
- **Node.js**: `>= 24.0.0` (see `.nvmrc` for the pinned version: `24`)
- **Package manager**: `yarn` (v1 classic)
### Common Commands
| Command | Description |
|---------|-------------|
| `yarn install --frozen-lockfile` | Install dependencies (CI-safe, no lockfile changes) |
| `RANCHER_ENV=harvester API=https://<harvester-ip> yarn dev` | Start development server at `https://127.0.0.1:8005` |
| `yarn build-pkg harvester` | Build the Harvester extension package |
| `yarn serve-pkgs` | Serve the locally built extension for testing |
| `yarn lint` | Run ESLint (zero warnings allowed) |
| `yarn lint:fix` | Run ESLint with auto-fix |
| `yarn clean` | Clean build artifacts |
| `yarn agents:generate` | Regenerate `AGENTS.md` from `docs/agents.md/` sources |
### Development
- **Start dev server**:
```bash
RANCHER_ENV=harvester API=https://your-harvester-ip yarn dev
```
- `API` should point to a running Harvester cluster (e.g., `https://x.x.x.x`).
- The dashboard will be available at `https://127.0.0.1:8005`.
### Building
- **Build extension package**:
```bash
yarn build-pkg harvester
```
- **Serve locally built extension** (for integration testing with a Rancher instance):
```bash
yarn serve-pkgs
```
### Linting
- **Check** (must pass with zero warnings):
```bash
yarn lint
```
- **Auto-fix**:
```bash
yarn lint:fix
```
- ESLint covers `.js`, `.ts`, and `.vue` files.
- Always run `yarn lint:fix` before committing.
### Commit Conventions
- Commits are validated by [commitlint](https://commitlint.js.org/) via a Husky `commit-msg` hook.
- Follow [Conventional Commits](https://www.conventionalcommits.org/) format (configured in `commitlint.config.js`).
### Agent Documentation
- Source files live in `docs/agents.md/` (agents, contributors, personas subdirectories).
- After editing any source file, regenerate the root `AGENTS.md`:
```bash
yarn agents:generate
```
@@ -0,0 +1,72 @@
## Getting Started
Please see the [Harvester UI Extension README](https://github.com/harvester/harvester-ui-extension).
To get started, follow the `Development Setup` section.
## Project Information
- **Tech Stack:**
- `Vue.js`: Framework
- `Linting`: ESLint
- `CSS`: SCSS should be used
- `TypeScript`: Primary language for logic.
- **Code Style and Standards:**
- `Language`: TypeScript is preferred for new code.
- `Vue.js`:
- Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- The `<style>` tag should include `lang="scss" scoped`.
- `Linting`: Follow the ESLint configuration in the root.
- **File Structure:**
- `.github/`: CI/CD workflows and Renovate config.
- `docs/`: Documentation source for `AGENTS.md` generation.
- `scripts/`: Bash scripts for build, CI and doc generation.
- `pkg/harvester/`: Main extension source. Files are named after K8s resource types (e.g., `kubevirt.io.virtualmachine`, `harvesterhci.io.volume`).
- `index.ts`: Plugin entry point — registers the product and auto-imports models/detail/edit views.
- `types.ts`: `HCI` constant mapping 60+ K8s resource types to string identifiers.
- `components/`: Reusable Vue components (VNC/serial console, settings panels, upgrade banners, filters).
- `config/`: Constants — settings keys, table column definitions, feature flags, type mappings, doc links.
- `detail/`: Read-only detail views per resource type. Complex resources use subdirectories with tabs.
- `dialog/`: Modal dialogs for operations (VM clone/migrate/restart, backup/restore, device passthrough, etc.).
- `edit/`: Create/edit forms. Complex resources (e.g., VM) split into subcomponents (CpuMemory, Network, Volume, CloudConfig, etc.).
- `formatters/`: Table cell formatters — state badges, usage bars, resource references.
- `l10n/`: Localization (`en-us.yaml`).
- `list/`: List (table) views per resource type, mirroring `detail/` and `edit/` naming.
- `mixins/`: Shared Vue mixins — VM helpers (`harvester-vm/`) and disk helpers (`harvester-disk.js`).
- `models/`: Model classes extending `SteveModel` with computed properties and actions. Base class: `harvester.js`.
- `pages/`: Route-level pages — dashboard, support, console, members, brand, alertmanager.
- `promptRemove/`: Custom delete-confirmation dialogs (VM, backup).
- `routing/`: Vue Router config — all product routes (CRUD, console, support, upgrade, etc.).
- `store/`: Vuex modules — `harvester-common.js` for shared state; `harvester-store/` for VM/resource creation actions with Steve integration.
- `styles/`: Global SCSS files.
- `utils/`: Helpers — VM volume templates, CPU/memory calc, cron parsing, regex validators, feature flags.
- `validators/`: Form validation functions per resource type, pushing i18n error messages.
## Harvester UI Extension Development Guide
### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
To add a feature in a new release, follow these steps:
1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
3. **Render**: Use the result of the check to conditionally render the UI components.
@@ -0,0 +1,3 @@
## E2E Tests (Cypress)
See https://github.com/harvester/harvester-ui-tests
@@ -0,0 +1,12 @@
## Node Dependencies
Dependencies are managed via `package.json` and `yarn`
- To install dependencies use `yarn install`. This will fail if dependencies and versions listed in `package.json` are out of step with the `yarn.lock` file
- To add a dependency use `yarn run add:no-lock ...` instead of `yarn add`
- To upgrade a dependency use `yarn run upgrade:no-lock ...` instead of `yarn upgrade`
Renovate automatically updates dependencies and opens upgrade PRs after 10:00 AM on Sundays (`Asia/Taipei`).
For the Renovate config, see `.github/renovate.json`.
@@ -0,0 +1,58 @@
## Milestone guidance
- All issues must first be resolved in the `main` branch
- If backports are needed they can be made via the backport bot
- pull requests
- comment `@Mergifyio backport <target branch>` e.g. `@Mergifyio backport release-harvester-v1.8`
- All backported pull requests must link to a backported issue
## Creating a branch
### To resolve an issue
- Checkout the branch matching the milestone of the issue `git checkout ${targetMilestoneBranch}`. Replace `${targetMilestoneBranch}` with the target milestone of the issue. For example
- `main` for the latest unreleased minor version
- `release-harvester-v.X` for release minor versions
- `release-harvester-v1.6`
- `release-harvester-v1.7`
- `release-harvester-v1.8`
- Ensure you have the latest of that branch `git pull --rebase`
- Checkout the branch to commit the changes to `git checkout issue-${issueNumber}`. Replace `${issueNumber}` with the issue number.
## Creating a commit
- This project uses commit-lint with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to ensure consistent and meaningful commit messages.
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>: <description>
[optional body]
[optional footer(s)]
```
### Supported Types
- feat: New features
- fix: Bug fixes
- docs: Documentation changes
- style: Code style changes (formatting, missing semicolons, etc.)
- refactor: Code refactoring
- perf: Performance improvements
- test: Adding or updating tests
- build: Build system or external dependencies
- ci: CI/CD changes
- chore: Other changes that don't modify src or test files
- revert: Reverts a previous commit
- wip: Work in progress
- deps: Dependency updates
- security: Security fixes
## Creating a Pull Request
- Pull requests must come from forks
- Description should always have commit supported type prefix. E.g `fix: XXX`, `feat: OOO`
- A Pull Request will only be merged once
- ALL CI gates have passed
- At least one harvester/harvester-ui-extension code owners reviews and approves the PR
@@ -0,0 +1,5 @@
## Software Developer
You are an expert Senior Software Engineer specializing in Vue.js and TypeScript. You have deep knowledge of Kubernetes and the Rancher ecosystem.
- **Focus**: Writing clean, maintainable, and performant code.
- **Priorities**: Adhering to the project's code style, ensuring type safety, and following best practices for component design.
+22
View File
@@ -0,0 +1,22 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
# Project Overview
The Harvester UI Extension is a Rancher extension that provides the user interface for Harvester within the Rancher Dashboard.
```
Note: This extension is available starting from Rancher 2.10.0. Ensure your Rancher version is 2.10.0 or later to access Harvester integration.
```
# Personas
`<personas>`
# Agents
`<agents>`
# Contributors Guide
`<contributors>`
+25 -12
View File
@@ -1,36 +1,47 @@
{
"name": "harvester-ui-extension",
"version": "1.7.0",
"version": "1.9.0-rc5",
"private": false,
"engines": {
"node": ">=20.0.0"
"node": ">=24.0.0"
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.28.3",
"@rancher/shell": "3.0.8-rc.8",
"@babel/plugin-transform-class-static-block": "7.29.7",
"@rancher/shell": "3.0.12-rc.7",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
"@vue-flow/minimap": "^1.4.0",
"cache-loader": "^4.1.0",
"color": "4.2.3",
"ip": "2.0.1",
"@xterm/xterm": "6.0.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/addon-canvas": "0.7.0",
"node-polyfill-webpack-plugin": "^3.0.0",
"elkjs": "^0.11.0",
"vue-draggable-next": "^2.2.1",
"yaml": "^2.5.1"
},
"resolutions": {
"@types/node": "~20.19.0",
"@types/node": "25.9.5",
"cronstrue": "2.59.0",
"d3-color": "3.1.0",
"ejs": "3.1.10",
"follow-redirects": "1.15.11",
"follow-redirects": "1.16.0",
"glob": "7.2.3",
"glob-parent": "6.0.2",
"json5": "2.2.3",
"@types/lodash": "4.17.21",
"@types/lodash": "4.17.25",
"merge": "2.1.1",
"node-forge": "1.3.3",
"node-forge": "1.4.0",
"nth-check": "2.1.1",
"qs": "6.14.0",
"roarr": "7.21.2",
"semver": "7.7.3",
"qs": "6.15.3",
"roarr": "7.21.7",
"semver": "7.8.5",
"@vue/cli-service/html-webpack-plugin": "^5.0.0"
},
"scripts": {
@@ -38,12 +49,14 @@
"build": "./node_modules/.bin/vue-cli-service build",
"clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/.bin/eslint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/.bin/eslint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
"parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name",
"commitlint": "commitlint --edit",
"prepare": "husky"
"prepare": "husky",
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh"
},
"devDependencies": {
"@commitlint/load": "^19.8.1",
+177
View File
@@ -0,0 +1,177 @@
# harvester-ui-extension
The Harvester UI Extension is a Rancher extension that provides the user interface for [Harvester](https://harvesterhci.io) within the [Rancher Dashboard](https://github.com/rancher/dashboard).
> **Note:**
> This extension is available starting from **Rancher 2.10.0**. Ensure your Rancher version is **2.10.0 or later** to access Harvester integration.
## 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).
## Development Setup
Ensure **Node.js v20 or later** is installed for development and debugging.
### Standalone Mode
Run the extension standalone with hot reload at `https://localhost:8005`.
```bash
# Install dependencies
yarn install
# Start the development server
RANCHER_ENV=harvester API=https://your-harvester-ip yarn dev
# Example with specific server version
RANCHER_ENV=harvester VUE_APP_SERVER_VERSION=v1.5.0 API=https://192.168.1.123 yarn dev
```
You may also define environment variables in a `.env` file:
```env
RANCHER_ENV=harvester
VUE_APP_SERVER_VERSION=v1.5.0
API=https://192.168.1.123
```
### Rancher Integration Mode
To run as a Rancher extension, follow the [Rancher UI Extension Guide](https://extensions.rancher.io/extensions/next/extensions-getting-started#running-the-app).
```bash
API=https://your-rancher-ip yarn dev
```
## Commit Message Guidelines
This project uses [commit-lint](https://commitlint.js.org/) with [Conventional Commits](https://www.conventionalcommits.org/) to ensure consistent and meaningful commit messages.
### Commit Message Format
All commit messages must follow the conventional commit format:
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
### Supported Types
- **feat**: New features
- **fix**: Bug fixes
- **docs**: Documentation changes
- **style**: Code style changes (formatting, missing semicolons, etc.)
- **refactor**: Code refactoring
- **perf**: Performance improvements
- **test**: Adding or updating tests
- **build**: Build system or external dependencies
- **ci**: CI/CD changes
- **chore**: Other changes that don't modify src or test files
- **revert**: Reverts a previous commit
- **wip**: Work in progress
- **deps**: Dependency updates
- **security**: Security fixes
### Examples
```bash
# Feature
git commit -m "feat: add new virtual machine creation wizard"
# Bug fix
git commit -m "fix: resolve memory leak in VM console"
# Documentation
git commit -m "docs: update installation instructions"
# Breaking change
git commit -m "feat!: change API endpoint structure
BREAKING CHANGE: The /api/v1/vms endpoint has been replaced with /api/v2/vms"
```
### Git Hooks
The project uses [Husky](https://typicode.github.io/husky/) to automatically validate commit messages and run linting before commits:
- **pre-commit**: Runs ESLint to ensure code quality
- **commit-msg**: Validates commit message format using commit-lint
These hooks are automatically installed when you run `yarn install`.
### Manual Validation
You can manually validate commit messages:
```bash
# Validate the last commit
yarn commitlint
# Validate a specific commit
npx commitlint --from <commit-hash>
# Validate a range of commits
npx commitlint --from <start-hash> --to <end-hash>
```
## Branch Structure
- **`main`** – Main development branch
- **`release-harvester-vX.Y`** – Stable release branches per version series
- **`vX.Y-head`** – Testing branches for ongoing changes to extension builds in each release series
> **Note:**
> The `vX.Y-head` branches are auto-generated and kept in sync with release branches. Use these for testing the latest changes in each version series.
## Testing Guidelines
### UI Extension Testing
To validate changes in a release series, switch to the appropriate `vX.Y-head` branch. For main branch testing, use `main-head`.
- Examples:
- Test `1.0.x` series → `v1.0-head`
- Test `1.5.x` series → `v1.5-head`
**Steps:**
1. Navigate to **Rancher UI** → **Local** → **App** → **Repositories**
2. Refresh the Harvester repository using the target `vX.Y-head` branch
3. Go to the **Extensions** page and install the desired version
### Standalone Mode Testing
To test the standalone UI, configure Harvester to load the UI from an external source.
- Examples of `ui-index`:
- Main branch → `https://releases.rancher.com/harvester-ui/dashboard/latest/index.html`
- Release series `1.5.x` → `https://releases.rancher.com/harvester-ui/dashboard/release-harvester-v1.5/index.html`
**Steps:**
1. Go to **Harvester UI** → **Advanced** → **Settings** → **UI**
2. Set **ui-source** to `External`
3. Set **ui-index** to the desired URL
## 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.
## License
Copyright (c) 2014-2026 [SUSE, LLC.](https://www.suse.com/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
[http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -271,4 +271,9 @@ export default {
.filter-label .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>
@@ -116,6 +116,11 @@ export default {
.vm-schedule-dropdown .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .vm-schedule-filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>
<style lang="scss" scoped>
@@ -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>
+33 -8
View File
@@ -55,8 +55,12 @@ export default {
!this.latestUpgrade.isUpgradeFailed;
},
canUpgrade() {
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
},
versionOptions() {
const versions = this.$store.getters['harvester/all'](HCI.VERSION);
const versions = this.$store.getters['harvester/all'](HCI.VERSION) || [];
return versions.map((V) => V.metadata.name);
},
@@ -154,14 +158,23 @@ export default {
:cluster="currentCluster.nameDisplay"
/>
</h1>
<button
v-if="versionOptions.length && !isUpgradeInProgress"
type="button"
class="btn bg-warning btn-sm"
@click="open"
<span
v-clean-tooltip="{
content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'),
triggers: ['hover', 'focus', 'touch'],
}"
class="upgrade-btn-wrap"
:tabindex="canUpgrade ? -1 : 0"
>
<t k="harvester.upgradePage.upgrade" />
</button>
<button
:disabled="!canUpgrade"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<t k="harvester.upgradePage.upgrade" />
</button>
</span>
</header>
<ModalWithCard
@@ -296,6 +309,18 @@ export default {
line-height: 23px;
}
.upgrade-btn-wrap {
display: inline-flex;
button {
color: #000 !important;
}
button:hover {
color: #000 !important;
}
}
.currentVersion {
margin-top: 10px;
display: flex;
@@ -1,6 +1,8 @@
<script>
import Collapse from '@shell/components/Collapse';
import PercentageBar from '@shell/components/PercentageBar';
import { HCI } from '../types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterUpgradeProgressList',
@@ -25,13 +27,45 @@ export default {
}
},
async fetch() {
await this.$store.dispatch('harvester/findAll', { type: HCI.UPGRADE });
},
data() {
return { open: true };
},
computed: {
showResumeButton() {
return this.title === 'Upgrading Node';
},
latestUpgradeCR() {
return this.$store.getters['harvester/all'](HCI.UPGRADE).find( (U) => U.isLatestUpgrade);
},
resumeUpgradePausedNodeEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('resumeUpgradePausedNode');
},
},
methods: {
handleSwitch() {
this.open = !this.open;
},
async resumeNodeUpgrade(nodeName) {
if (!this.latestUpgradeCR || !nodeName) return;
try {
const upgradePauseMapString = this.latestUpgradeCR.metadata.annotations[HCI_ANNOTATIONS.NODE_UPGRADE_PAUSE_MAP] || '{}';
const upgradePauseMap = JSON.parse(upgradePauseMapString);
// update the upgrade CR annotation harvesterhci.io/node-upgrade-pause-map to unpause the node upgrade process
upgradePauseMap[`${ nodeName }`] = 'unpause';
this.latestUpgradeCR.setAnnotation(HCI_ANNOTATIONS.NODE_UPGRADE_PAUSE_MAP, JSON.stringify(upgradePauseMap));
await this.latestUpgradeCR.save();
} catch (e) {
console.error(`unable to update harvester upgrade CR annotations: ${ this.latestUpgradeCR.id }.`, e); // eslint-disable-line no-console
return false;
}
}
}
};
@@ -63,12 +97,28 @@ export default {
v-for="(item, i) in list"
:key="i"
>
<p>
{{ item.name }} <span
class="status"
:class="{ [item.state]: true }"
>{{ item.state }}</span>
</p>
<div class="upgrade-node-header">
<div class="upgrade-node-title">
<p>
{{ item.name }}
</p>
<span
class="status"
:class="{ [item.state]: true }"
>
{{ item.state }}
</span>
</div>
<button
v-if="showResumeButton && resumeUpgradePausedNodeEnabled && item.state === 'Node-upgrade paused'"
type="button"
class="btn bg-info btn-sm"
data-testid="add-item"
@click="resumeNodeUpgrade(item.name)"
>
{{ t('action.resume') }}
</button>
</div>
<PercentageBar
:model-value="item.percent"
preferred-direction="MORE"
@@ -102,10 +152,21 @@ export default {
}
}
.custom-content {
margin-bottom: 14px;
p {
.upgrade-node-title {
flex: 1 0 80%;
margin-right: 10px;
display: flex;
justify-content: space-between;
}
.upgrade-node-header {
display:flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
margin-bottom: 14px;
.status {
float: right;
}
@@ -117,6 +178,8 @@ export default {
}
.warning {
color: var(--error);
margin-bottom: 8px;
margin-top: 4px;
}
}
}
+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>
+1 -1
View File
@@ -122,7 +122,7 @@ export default {
return this.$store.getters['currentCluster'].isLocal;
},
canEditClusterMembers() {
return this.normanClusterRTBSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
return this.schema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
},
},
};
+442 -63
View File
@@ -1,7 +1,6 @@
<script>
import { allHash } from '@shell/utils/promise';
import debounce from 'lodash/debounce';
import Socket, {
EVENT_CONNECTED,
EVENT_CONNECTING,
@@ -22,74 +21,98 @@ export default {
data() {
return {
socket: null,
terminal: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: []
socket: null,
terminal: null,
textDecoder: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
onResize: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: [],
isDraining: false,
drainScheduled: false,
drainTimer: null,
showSearch: false,
searchQuery: '',
searchOptions: {
caseSensitive: false,
regex: false,
wholeWord: false,
incremental: true,
decorations: {
matchBackground: '#B45309',
matchBorder: '#FDBA74',
matchOverviewRuler: '#FDBA74',
activeMatchBackground: '#EA580C',
activeMatchBorder: '#FED7AA',
activeMatchColorOverviewRuler: '#FED7AA',
},
},
};
},
computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() {
return {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
cursorWidth: 2,
scrollback: 5000,
useStyle: true,
fontSize: 12,
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 16,
lineHeight: 1.3,
};
},
},
watch: {
queue: {
handler: debounce(async function(neu) {
if (neu.length === 0) {
return;
}
const msg = await Promise.all(neu);
(msg || []).forEach((m) => {
this.terminal.write(m);
});
this.queue = [];
}, 10),
deep: true
}
},
beforeUnmount() {
this.close();
},
watch: {
serialConsoleUrl() {
if (this.terminal) {
this.connect();
}
},
},
async mounted() {
this.textDecoder = new TextDecoder('utf-8');
await this.setupTerminal();
await this.connect();
this.onResize = debounce(() => this.fit(), 100);
window.addEventListener('resize', this.onResize);
this.scheduleFit();
},
methods: {
async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body'));
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm');
const xterm = await import(/* webpackChunkName: "xterm" */ '@xterm/xterm');
const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'),
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
});
const terminal = new xterm.Terminal({
theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim()
},
...this.xtermConfig,
@@ -97,25 +120,48 @@ export default {
this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon();
try {
this.webglAddon = new addons.webgl.WebGlAddon();
} catch (e) {
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
this.webglAddon = null;
}
terminal.loadAddon(this.fitAddon);
terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm);
if ( this.webglAddon ) {
// The webgl renderer can fail without throwing: the context may be lost
// mid-session, or it can attach but silently never paint, leaving the
// terminal's helper textarea at 0x0. Detect both cases and fall back to the
// canvas renderer so the terminal stays interactive.
try {
this.webglAddon = new addons.webgl.WebglAddon();
this.webglAddon.onContextLoss(() => {
if (!this.isUnmounted && this.terminal) {
this.fallbackToCanvas(terminal, addons);
}
});
terminal.loadAddon(this.webglAddon);
// Detect the silent "attaches but never paints" failure after a render frame.
requestAnimationFrame(() => {
if (this.isUnmounted || !this.terminal) {
return;
}
const textarea = this.$refs.xterm?.querySelector('.xterm-helper-textarea');
const hasPainted = textarea && textarea.getBoundingClientRect().height > 0;
if (this.webglAddon && !hasPainted) {
this.fallbackToCanvas(terminal, addons);
this.fit();
}
});
} catch (e) {
// Some browsers (e.g. Safari) don't support the webgl renderer, so don't use it.
this.fallbackToCanvas(terminal, addons);
}
this.fit();
this.flush();
this.scheduleDrainQueue();
terminal.onData((input) => {
const msg = this.str2ab(input);
@@ -123,9 +169,132 @@ export default {
this.write(msg);
});
terminal.attachCustomKeyEventHandler((event) => {
const key = event.key?.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'f') {
event.preventDefault();
this.openSearch();
return false;
}
if (key === 'escape' && this.showSearch) {
event.preventDefault();
this.closeSearch();
return false;
}
return true;
});
this.terminal = terminal;
},
openSearch() {
this.showSearch = true;
this.$nextTick(() => {
const input = this.$refs.searchInput;
if (input) {
input.focus();
input.select();
}
});
},
closeSearch() {
this.showSearch = false;
this.searchQuery = '';
this.clearSearchHighlights();
this.terminal?.focus();
},
clearSearchHighlights() {
this.searchAddon?.clearActiveDecoration();
this.searchAddon?.clearDecorations();
this.terminal?.clearSelection();
// Disposing the search decorations doesn't repaint the webgl/canvas
// layer, so force a refresh to remove the lingering highlight.
if (this.terminal) {
this.terminal.refresh(0, this.terminal.rows - 1);
}
},
findNext() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findNext(this.searchQuery, this.searchOptions);
},
findPrevious() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findPrevious(this.searchQuery, this.searchOptions);
},
onSearchInput() {
if (!this.searchQuery) {
this.clearSearchHighlights();
return;
}
this.findNext();
},
onSearchKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
this.findPrevious();
} else {
this.findNext();
}
}
if (event.key === 'Escape') {
event.preventDefault();
this.closeSearch();
}
},
scheduleFit() {
this.$nextTick(() => {
requestAnimationFrame(() => {
this.fit();
// Re-fit after layout settles to avoid undersized rows on first paint.
setTimeout(() => this.fit(), 120);
});
});
},
// Dispose the webgl renderer (if any) and switch to the canvas renderer.
// Used when webgl fails to construct, loses its context, or silently never paints.
fallbackToCanvas(terminal, addons) {
if (this.isUnmounted) {
return;
}
this.webglAddon?.dispose();
this.webglAddon = null;
if (!this.canvasAddon) {
this.canvasAddon = new addons.canvas.CanvasAddon();
terminal.loadAddon(this.canvasAddon);
}
this.fit();
},
str2ab(str) {
const enc = new TextEncoder();
@@ -140,12 +309,131 @@ export default {
}
},
enqueueMessage(messagePromise) {
this.queue.push(messagePromise);
this.scheduleDrainQueue();
},
scheduleDrainQueue() {
if (this.drainScheduled || this.isDraining || !this.terminal) {
return;
}
this.drainScheduled = true;
const shouldDrainImmediately = this.queue.length <= 3;
const runDrain = () => {
this.drainScheduled = false;
this.drainTimer = null;
this.drainQueue();
};
if (shouldDrainImmediately) {
runDrain();
return;
}
this.drainTimer = requestAnimationFrame(runDrain);
},
async decodeMessageData(data) {
if (typeof data === 'string') {
return data;
}
if (!this.textDecoder) {
this.textDecoder = new TextDecoder('utf-8');
}
if (data instanceof Blob) {
const buffer = await data.arrayBuffer();
return this.textDecoder.decode(new Uint8Array(buffer), { stream: true });
}
if (data instanceof ArrayBuffer) {
return this.textDecoder.decode(new Uint8Array(data), { stream: true });
}
if (ArrayBuffer.isView(data)) {
const view = data;
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
return this.textDecoder.decode(bytes, { stream: true });
}
return String(data || '');
},
async drainQueue() {
if (this.isDraining || !this.terminal) {
return;
}
this.isDraining = true;
try {
while (this.queue.length > 0) {
const pendingMessages = this.queue.splice(0, this.queue.length);
const settledMessages = await Promise.allSettled(pendingMessages);
const messages = settledMessages
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
const output = messages.filter(Boolean).join('');
if (output) {
await this.writeOutputInChunks(output);
}
if (this.queue.length > 0) {
await this.nextFrame();
}
}
} finally {
this.isDraining = false;
// If data arrived between the last while-check and releasing the lock,
// immediately schedule another drain so output never gets stuck.
if (this.queue.length > 0 && this.terminal) {
this.scheduleDrainQueue();
}
}
},
async writeOutputInChunks(output) {
if (!output) {
return;
}
const chunkSize = output.length > 20000 ? 16000 : output.length > 8000 ? 8000 : output.length;
if (chunkSize === output.length) {
this.terminal.write(output);
return;
}
for (let index = 0; index < output.length; index += chunkSize) {
this.terminal.write(output.slice(index, index + chunkSize));
if (index + chunkSize < output.length) {
await this.nextFrame();
}
}
},
nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
},
clear() {
this.terminal.clear();
},
getSocketUrl() {
return `${ this.value?.getSerialConsolePath }`;
return this.serialConsoleUrl;
},
async connect() {
@@ -181,6 +469,7 @@ export default {
this.fit();
this.flush();
}
this.scheduleFit();
if (this.firstTime) {
this.socket.send(this.str2ab('\n'));
@@ -195,7 +484,7 @@ export default {
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
this.queue.push(e.detail.data.text());
this.enqueueMessage(this.decodeMessageData(e.detail.data));
});
this.socket.connect();
@@ -218,22 +507,19 @@ export default {
}
this.fitAddon.fit();
const { rows, cols } = this.fitAddon.proposeDimensions();
if ( !this.isOpen ) {
return;
}
const message = JSON.stringify({
Width: cols,
Height: rows
});
this.socket.send(this.str2ab(message));
},
close() {
if (this.onResize) {
window.removeEventListener('resize', this.onResize);
this.onResize = null;
}
if (this.drainTimer) {
cancelAnimationFrame(this.drainTimer);
this.drainTimer = null;
}
if ( this.socket ) {
this.socket.disconnect();
}
@@ -248,6 +534,44 @@ export default {
<template>
<div class="harvester-shell-container">
<div
v-if="showSearch"
class="shell-search"
>
<input
ref="searchInput"
v-model="searchQuery"
type="text"
class="shell-search-input"
placeholder="Search in terminal"
@input="onSearchInput"
@keydown="onSearchKeydown"
>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Shift+Enter"
@click="findPrevious"
>
Prev
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Enter"
@click="findNext"
>
Next
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Esc"
@click="closeSearch"
>
Close
</button>
</div>
<div
ref="xterm"
class="shell-body"
@@ -257,18 +581,73 @@ export default {
</template>
<style lang="scss">
@import '../../../../node_modules/xterm/css/xterm.css';
@import '../../../../node_modules/@xterm/xterm/css/xterm.css';
body, #__nuxt, #__layout, MAIN {
height: 100%;
}
.harvester-shell-container {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
min-height: 0;
overflow: hidden;
.shell-body, .terminal.xterm {
.resize-observer {
display: none;
}
.shell-search {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
position: absolute;
top: 8px;
right: 10px;
z-index: 5;
padding: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--body-bg) 88%, transparent);
}
.shell-search-input {
min-width: 220px;
max-width: 420px;
flex: 0 1 320px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--body-bg);
color: var(--body-text);
padding: 6px 8px;
}
.shell-search-btn {
min-width: 64px;
}
.shell-body {
flex: 1 1 auto;
height: 100%;
width: 100%;
min-height: 0;
padding: 0 10px;
box-sizing: border-box;
}
.terminal.xterm,
.terminal.xterm .xterm-viewport,
.terminal.xterm .xterm-screen,
.terminal.xterm .xterm-scrollable-element {
width: 100%;
height: 100%;
}
.terminal.xterm .xterm-scrollable-element {
min-height: 100%;
}
}
</style>
+12 -5
View File
@@ -55,14 +55,21 @@ export default {
const host = window.location.host;
const prefix = window.location.pathname.replace(this.$route.path, '');
const params = this.$route?.params;
const popupWidth = Math.max(800, screen.width - 200);
const popupHeight = Math.max(600, screen.height - 200);
const popupLeft = Math.max(0, window.screenX + Math.floor((window.outerWidth - popupWidth) / 2));
const popupTop = Math.max(0, window.screenY + Math.floor((window.outerHeight - popupHeight) / 2));
const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
window.open(
url,
'_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
// Defer so v-select can finish closing the dropdown before the popup steals focus
this.$nextTick(() => {
window.open(
url,
'_blank',
`toolbars=0,width=${ popupWidth },height=${ popupHeight },left=${ popupLeft },top=${ popupTop },noreferrer`
);
});
},
isEmpty(o) {
@@ -0,0 +1,196 @@
<script>
import { RadioGroup } from '@components/Form/Radio';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { NAMESPACE } from '@shell/config/types';
import CreateEditView from '@shell/mixins/create-edit-view';
export default {
name: 'HarvesterClusterPodSecurityStandard',
components: {
RadioGroup,
LabeledSelect,
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
default: () => ({}),
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
},
data() {
let enabled = false;
let whitelistedNamespaces = [];
let privilegedNamespaces = [];
let restrictedNamespaces = [];
try {
const parsed = JSON.parse(this.value.value || this.value.default || '{}');
enabled = !!parsed.enabled;
whitelistedNamespaces = parsed.whitelistedNamespacesList ? parsed.whitelistedNamespacesList.split(',') : [];
privilegedNamespaces = parsed.privilegedNamespacesList ? parsed.privilegedNamespacesList.split(',') : [];
restrictedNamespaces = parsed.restrictedNamespacesList ? parsed.restrictedNamespacesList.split(',') : [];
} catch (e) {
enabled = false;
whitelistedNamespaces = [];
privilegedNamespaces = [];
restrictedNamespaces = [];
}
return {
enabled,
whitelistedNamespaces,
privilegedNamespaces,
restrictedNamespaces,
};
},
computed: {
enabledOptions() {
return [
{ label: this.t('generic.enabled'), value: true },
{ label: this.t('generic.disabled'), value: false },
];
},
allNamespaces() {
const inStore = this.$store.getters['currentProduct'].inStore;
return this.$store.getters[`${ inStore }/all`](NAMESPACE).filter((ns) => !ns.isSystem).map((ns) => ns.id);
},
whitelistedOptions() {
const excluded = new Set([...this.privilegedNamespaces, ...this.restrictedNamespaces]);
return this.allNamespaces.filter((ns) => !excluded.has(ns));
},
privilegedOptions() {
const excluded = new Set([...this.whitelistedNamespaces, ...this.restrictedNamespaces]);
return this.allNamespaces.filter((ns) => !excluded.has(ns));
},
restrictedOptions() {
const excluded = new Set([...this.whitelistedNamespaces, ...this.privilegedNamespaces]);
return this.allNamespaces.filter((ns) => !excluded.has(ns));
},
},
methods: {
useDefault() {
try {
const parsed = JSON.parse(this.value.default || '{}');
this.enabled = !!parsed.enabled;
this.whitelistedNamespaces = parsed.whitelistedNamespacesList ? parsed.whitelistedNamespacesList.split(',') : [];
this.privilegedNamespaces = parsed.privilegedNamespacesList ? parsed.privilegedNamespacesList.split(',') : [];
this.restrictedNamespaces = parsed.restrictedNamespacesList ? parsed.restrictedNamespacesList.split(',') : [];
} catch (e) {
this.enabled = false;
this.whitelistedNamespaces = [];
this.privilegedNamespaces = [];
this.restrictedNamespaces = [];
}
this.save();
},
onUpdateEnabled() {
if (!this.enabled) {
this.whitelistedNamespaces = [];
this.privilegedNamespaces = [];
this.restrictedNamespaces = [];
}
this.save();
},
updateWhitelisted(selected) {
this.whitelistedNamespaces = selected;
this.save();
},
updatePrivileged(selected) {
this.privilegedNamespaces = selected;
this.save();
},
updateRestricted(selected) {
this.restrictedNamespaces = selected;
this.save();
},
save() {
this.value.value = JSON.stringify({
enabled: this.enabled,
whitelistedNamespacesList: this.whitelistedNamespaces.join(','),
privilegedNamespacesList: this.privilegedNamespaces.join(','),
restrictedNamespacesList: this.restrictedNamespaces.join(','),
});
},
},
};
</script>
<template>
<div>
<div class="row mb-20">
<div class="col span-6">
<RadioGroup
v-model:value="enabled"
name="enabled"
:options="enabledOptions"
@update:value="onUpdateEnabled"
/>
</div>
</div>
<template v-if="enabled">
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="whitelistedNamespaces"
:label="t('harvester.setting.clusterPodSecurityStandard.whitelistedNamespaces.label')"
:options="whitelistedOptions"
:multiple="true"
:mode="mode"
@update:value="updateWhitelisted"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="privilegedNamespaces"
:label="t('harvester.setting.clusterPodSecurityStandard.privilegedNamespaces.label')"
:options="privilegedOptions"
:multiple="true"
:mode="mode"
@update:value="updatePrivileged"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledSelect
v-model:value="restrictedNamespaces"
:label="t('harvester.setting.clusterPodSecurityStandard.restrictedNamespaces.label')"
:options="restrictedOptions"
:multiple="true"
:mode="mode"
@update:value="updateRestricted"
/>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,123 @@
<script>
import MessageLink from '@shell/components/MessageLink';
import CreateEditView from '@shell/mixins/create-edit-view';
import { LabeledInput } from '@components/Form/LabeledInput';
import { HCI_SETTING } from '../../config/settings';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
export default {
name: 'HarvesterEditClusterRegistrationURL',
components: {
LabeledInput, MessageLink, Checkbox, Banner
},
mixins: [CreateEditView],
data() {
let parseDefaultValue = {};
try {
parseDefaultValue = JSON.parse(this.value.value);
} catch (error) {
parseDefaultValue.url = this.value.value;
parseDefaultValue.insecureSkipTLSVerify = true;
}
return {
parseDefaultValue,
errors: []
};
},
computed: {
toCA() {
return `${ HCI_SETTING.ADDITIONAL_CA }?mode=edit`;
},
clusterRegistrationTLSVerifyEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('clusterRegistrationTLSVerify');
},
registrationURL: {
get() {
return this.clusterRegistrationTLSVerifyEnabled ? this.parseDefaultValue.url : this.parseDefaultValue;
},
set(value) {
if (this.clusterRegistrationTLSVerifyEnabled) {
this.parseDefaultValue.url = value;
} else {
this.parseDefaultValue = value;
}
}
}
},
methods: {
getDefaultValue() {
if (this.clusterRegistrationTLSVerifyEnabled) {
return { url: '', insecureSkipTLSVerify: false };
} else {
return '';
}
},
updateUrl() {
this.update();
},
update() {
if (this.clusterRegistrationTLSVerifyEnabled) {
this.value['value'] = JSON.stringify(this.parseDefaultValue);
} else {
this.value['value'] = this.parseDefaultValue;
}
},
useDefault() {
this.parseDefaultValue = this.getDefaultValue();
},
updateInsecureSkipTLSVerify(newValue) {
const { url = '' } = this.parseDefaultValue;
this.parseDefaultValue = { url, insecureSkipTLSVerify: newValue };
this.update();
},
}
};
</script>
<template>
<div
class="row"
>
<div class="col span-12">
<Banner color="info">
<MessageLink
:to="toCA"
target="_blank"
prefix-label="harvester.setting.clusterRegistrationUrl.tip.prefix"
middle-label="harvester.setting.clusterRegistrationUrl.tip.middle"
suffix-label="harvester.setting.clusterRegistrationUrl.tip.suffix"
/>
</Banner>
<LabeledInput
v-model:value="registrationURL"
class="mb-20"
:mode="mode"
:label="t('harvester.setting.clusterRegistrationUrl.url')"
@update:value="updateUrl"
/>
<div v-if="clusterRegistrationTLSVerifyEnabled">
<Checkbox
v-model:value="parseDefaultValue.insecureSkipTLSVerify"
class="check mb-5"
type="checkbox"
:label="t('harvester.setting.clusterRegistrationUrl.insecureSkipTLSVerify')"
@update:value="updateInsecureSkipTLSVerify"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,104 @@
<script>
import UnitInput from '@shell/components/form/UnitInput';
import { Banner } from '@components/Banner';
export default {
name: 'HarvesterInstanceManagerResources',
components: {
UnitInput,
Banner,
},
props: {
value: {
type: Object,
default: () => ({
value: '',
default: '{}'
})
},
},
data() {
const resources = this.parseJSON(this.value?.value) || this.parseJSON(this.value?.default) || {};
return {
resources,
parseError: null,
};
},
methods: {
parseJSON(string) {
try {
return JSON.parse(string);
} catch (e) {
this.parseError = this.t('harvester.setting.instanceManagerResources.parseError', { error: e.message });
return null;
}
},
update() {
if (!this.value) return;
const cpu = { ...this.resources?.cpu };
if (cpu.v1 !== null && cpu.v1 !== undefined) cpu.v1 = String(cpu.v1);
if (cpu.v2 !== null && cpu.v2 !== undefined) cpu.v2 = String(cpu.v2);
this.value.value = JSON.stringify({ ...this.resources, cpu });
},
useDefault() {
if (this.value?.default) {
this.resources = this.parseJSON(this.value.default) || {};
this.update();
}
},
},
};
</script>
<template>
<div>
<Banner
v-if="parseError"
color="error"
>
{{ parseError }}
</Banner>
<div class="row">
<div class="col span-12">
<UnitInput
v-model:value="resources.cpu.v1"
:label="t('harvester.setting.instanceManagerResources.v1')"
suffix="%"
:delay="0"
type="number"
min="0"
max="100"
required
:mode="mode"
class="mb-20"
@update:value="update"
/>
<UnitInput
v-model:value="resources.cpu.v2"
:label="t('harvester.setting.instanceManagerResources.v2')"
suffix="%"
:delay="0"
type="number"
min="0"
max="100"
required
:mode="mode"
class="mb-20"
@update:value="update"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,371 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { RadioGroup } from '@components/Form/Radio';
import ArrayList from '@shell/components/form/ArrayList';
import { isValidCIDR } from '@shell/utils/validators/cidr';
import { _EDIT } from '@shell/config/query-params';
import { Banner } from '@components/Banner';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types';
import { NETWORK_TYPE } from '../../config/types';
const { L2VLAN, UNTAGGED } = NETWORK_TYPE;
const SHARE_STORAGE_NETWORK = 'share-storage-network';
const NETWORK = 'network';
const DEFAULT_DEDICATED_NETWORK = {
vlan: '',
clusterNetwork: '',
range: '',
exclude: [],
};
export default {
name: 'RwxNetworkSetting',
components: {
RadioGroup,
Banner,
ArrayList,
LabeledInput,
LabeledSelect,
},
props: {
registerBeforeHook: {
type: Function,
required: true,
},
mode: {
type: String,
default: _EDIT,
},
value: {
type: Object,
default: () => {
return {};
},
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
vlanStatus: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN_STATUS }),
});
},
data() {
let enabled = false; // enabled / disabled options
let shareStorageNetwork = false; // shareStorageNetwork / dedicatedRwxNetwork options
let dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
let networkType = L2VLAN;
let exclude = [];
try {
const parsedValue = JSON.parse(this.value.value || this.value.default || '{}');
const parsedNetwork = parsedValue?.[NETWORK] || parsedValue || {};
if (parsedValue && typeof parsedValue === 'object') {
shareStorageNetwork = !!parsedValue[SHARE_STORAGE_NETWORK];
networkType = 'vlan' in parsedNetwork ? L2VLAN : UNTAGGED;
dedicatedNetwork = {
vlan: parsedNetwork.vlan || '',
clusterNetwork: parsedNetwork.clusterNetwork || '',
range: parsedNetwork.range || '',
};
exclude = parsedNetwork?.exclude?.toString().split(',') || [];
enabled = shareStorageNetwork || !!(parsedNetwork.vlan || parsedNetwork.clusterNetwork || parsedNetwork.range);
}
} catch (error) {
enabled = false;
shareStorageNetwork = false;
dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
}
return {
enabled,
shareStorageNetwork,
dedicatedNetwork,
networkType,
exclude,
defaultAddValue: '',
};
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.willSave, 'willSave');
}
},
computed: {
showDedicatedNetworkConfig() {
return this.enabled && !this.shareStorageNetwork;
},
showVlan() {
return this.networkType === L2VLAN;
},
networkTypes() {
return [L2VLAN, UNTAGGED];
},
clusterNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
const clusterNetworksOptions = this.networkType === UNTAGGED ? clusterNetworks.filter((network) => network.id !== 'mgmt') : clusterNetworks;
return clusterNetworksOptions.map((network) => {
const disabled = !network.isReadyForStorageNetwork;
return {
label: disabled ? `${ network.id } (${ this.t('generic.notReady') })` : network.id,
value: network.id,
disabled,
};
});
},
},
methods: {
onUpdateEnabled() {
if (!this.enabled) {
this.shareStorageNetwork = false;
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
}
this.update();
},
onUpdateNetworkType() {
if (this.shareStorageNetwork) {
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
}
this.update();
},
onUpdateDedicatedType(neu) {
this.dedicatedNetwork.clusterNetwork = '';
if (neu === L2VLAN) {
this.dedicatedNetwork.vlan = '';
} else {
delete this.dedicatedNetwork.vlan;
}
this.update();
},
inputVlan(neu) {
if (neu === '') {
this.dedicatedNetwork.vlan = '';
this.update();
return;
}
const newValue = Number(neu);
if (newValue > 4094) {
this.dedicatedNetwork.vlan = 4094;
} else if (newValue < 1) {
this.dedicatedNetwork.vlan = 1;
} else {
this.dedicatedNetwork.vlan = newValue;
}
this.update();
},
useDefault() {
this.enabled = false;
this.shareStorageNetwork = false;
this.dedicatedNetwork = { ...DEFAULT_DEDICATED_NETWORK };
this.update();
},
update() {
const value = { [SHARE_STORAGE_NETWORK]: false };
if (this.enabled && this.shareStorageNetwork) {
value[SHARE_STORAGE_NETWORK] = true;
}
if (this.showDedicatedNetworkConfig) {
value[NETWORK] = {};
if (this.showVlan) {
value[NETWORK].vlan = this.dedicatedNetwork.vlan;
}
value[NETWORK].clusterNetwork = this.dedicatedNetwork.clusterNetwork;
value[NETWORK].range = this.dedicatedNetwork.range;
const excludeList = this.exclude.filter((ip) => ip);
if (Array.isArray(excludeList) && excludeList.length > 0) {
value[NETWORK].exclude = excludeList;
}
}
this.value.value = JSON.stringify(value);
},
willSave() {
this.update();
if (!this.showDedicatedNetworkConfig) {
return Promise.resolve();
}
const errors = [];
if (this.showVlan && !this.dedicatedNetwork.vlan) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.vlan') }, true));
}
if (!this.dedicatedNetwork.clusterNetwork) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.clusterNetwork') }, true));
}
if (!this.dedicatedNetwork.range) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.storageNetwork.range.label') }, true));
} else if (!isValidCIDR(this.dedicatedNetwork.range)) {
errors.push(this.t('harvester.setting.storageNetwork.range.invalid', null, true));
}
if (this.exclude) {
const hasInvalidCIDR = this.exclude.find((cidr) => {
return cidr && !isValidCIDR(cidr);
});
if (hasInvalidCIDR) {
errors.push(this.t('harvester.setting.storageNetwork.exclude.invalid', null, true));
}
}
if (errors.length > 0) {
return Promise.reject(errors);
}
return Promise.resolve();
},
},
};
</script>
<template>
<div :class="mode">
<Banner color="warning">
<t
k="harvester.setting.rwxNetwork.warning"
:raw="true"
/>
</Banner>
<RadioGroup
v-model:value="enabled"
class="mb-20"
name="rwx-network-enable"
:options="[true,false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
@update:value="onUpdateEnabled"
/>
<RadioGroup
v-if="enabled"
v-model:value="shareStorageNetwork"
class="mb-20"
name="rwx-network-type"
:options="[true,false]"
:labels="[t('harvester.setting.rwxNetwork.shareStorageNetwork'), t('harvester.setting.rwxNetwork.dedicatedRwxNetwork')]"
@update:value="onUpdateNetworkType"
/>
<Banner
v-if="shareStorageNetwork"
class="mb-20"
color="warning"
>
<t
k="harvester.setting.rwxNetwork.shareStorageNetworkWarning"
:raw="true"
/>
</Banner>
<template v-if="showDedicatedNetworkConfig">
<LabeledSelect
v-model:value="networkType"
class="mb-20"
:options="networkTypes"
:mode="mode"
:label="t('harvester.fields.type')"
required
@update:value="onUpdateDedicatedType"
/>
<LabeledInput
v-if="showVlan"
v-model:value.number="dedicatedNetwork.vlan"
type="number"
class="mb-20"
:mode="mode"
required
placeholder="e.g. 1 - 4094"
label-key="harvester.setting.storageNetwork.vlan"
@update:value="inputVlan"
/>
<LabeledSelect
v-model:value="dedicatedNetwork.clusterNetwork"
label-key="harvester.setting.storageNetwork.clusterNetwork"
class="mb-20"
required
:options="clusterNetworkOptions"
@update:value="update"
/>
<LabeledInput
v-model:value="dedicatedNetwork.range"
class="mb-5"
:mode="mode"
required
:placeholder="t('harvester.setting.storageNetwork.range.placeholder')"
label-key="harvester.setting.storageNetwork.range.label"
@update:value="update"
/>
<ArrayList
v-model:value="exclude"
:show-header="true"
:default-add-value="defaultAddValue"
:mode="mode"
:add-label="t('harvester.setting.storageNetwork.exclude.addIp')"
class="mt-20"
@update:value="update"
>
<template #column-headers>
<div class="box mb-10">
<div class="key">
{{ t('harvester.setting.storageNetwork.exclude.label') }}
</div>
</div>
</template>
<template #columns="scope">
<div class="key">
<input
v-model="scope.row.value"
:placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')"
@update:value="update"
/>
</div>
</template>
</ArrayList>
</template>
</div>
</template>
@@ -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"
@@ -4,6 +4,8 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RadioGroup } from '@components/Form/Radio';
import { mapGetters } from 'vuex';
import { allHash } from '@shell/utils/promise';
import { NODE } from '@shell/config/types';
export default {
name: 'HarvesterUpgradeConfig',
@@ -15,6 +17,13 @@ export default {
},
mixins: [CreateEditView],
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = { nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }) };
await allHash(hash);
},
data() {
let parseDefaultValue = {};
@@ -39,7 +48,25 @@ export default {
{ value: 'skip', label: 'skip' },
{ value: 'parallel', label: 'parallel' }
];
}
},
nodeUpgradeOptions() {
return [
{ value: 'auto', label: 'auto' },
{ value: 'manual', label: 'manual' }
];
},
nodesOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.map((node) => ({ value: node.id, label: node.name }));
},
showPauseNodes() {
return this.parseDefaultValue.nodeUpgradeOption?.strategy?.mode === 'manual';
},
resumeUpgradePausedNodeEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('resumeUpgradePausedNode');
},
},
created() {
@@ -48,6 +75,18 @@ export default {
methods: {
normalizeValue(obj) {
// handle nodeUpgradeOption.strategy
if (obj?.nodeUpgradeOption?.strategy?.mode === 'auto') {
delete obj.nodeUpgradeOption.strategy.pauseNodes;
}
if (obj?.nodeUpgradeOption?.strategy?.mode === 'manual') {
if (!Array.isArray(obj.nodeUpgradeOption.strategy.pauseNodes)) {
obj.nodeUpgradeOption.strategy.pauseNodes = this.nodesOptions.map((node) => node.value);
}
}
// handle imagePreloadOption.strategy
if (!obj.imagePreloadOption) {
obj.imagePreloadOption = { strategy: { type: 'sequential' } };
}
@@ -105,8 +144,8 @@ export default {
this.update();
},
deep: true
}
}
},
},
};
</script>
@@ -144,6 +183,28 @@ export default {
:labels="[t('generic.enabled'), t('generic.disabled')]"
@update:value="update"
/>
<div v-if="resumeUpgradePausedNodeEnabled">
<label class="mb-5"><b>{{ t('harvester.setting.upgrade.nodeUpgradeOption') }}</b></label>
<LabeledSelect
v-model:value="parseDefaultValue.nodeUpgradeOption.strategy.mode"
class="mb-20 label-select"
:mode="mode"
:label="t('harvester.setting.upgrade.strategy')"
:options="nodeUpgradeOptions"
@update:value="update"
/>
<LabeledSelect
v-if="showPauseNodes"
v-model:value="parseDefaultValue.nodeUpgradeOption.strategy.pauseNodes"
class="mb-20 label-select"
:clearable="true"
:multiple="true"
:mode="mode"
:label="t('harvester.setting.upgrade.pauseNodes')"
:options="nodesOptions"
@update:value="update"
/>
</div>
<div
v-if="errors.length"
class="error"
@@ -69,6 +69,15 @@ export default {
:mode="mode"
label-key="harvester.setting.vmForceDeletionPolicy.period"
/>
<LabeledInput
v-if="parseDefaultValue.enable"
v-model:value.number="parseDefaultValue.vmMigrationTimeout"
class="mb-20"
type="number"
:mode="mode"
label-key="harvester.setting.vmForceDeletionPolicy.vmMigrationTimeout"
/>
</div>
</div>
</template>
@@ -0,0 +1,649 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue';
import StorageDefaultsModal from './StorageDefaultsModal.vue';
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
const DEFAULT_ACCESS_MODES = [ACCESS_MODE.READ_WRITE_MANY];
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
useAllProviderData: { type: Boolean, default: false },
existingNetworkMap: { type: Object, default: null },
existingStorageMap: { type: Object, default: null },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const errors = ref([]);
const loading = ref(true);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE;
const harvesterNetworkOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
];
harvesterNetworks.value.forEach((net) => {
const ns = net.metadata?.namespace || '';
const name = net.metadata?.name || net.id;
const fullName = ns ? `${ ns }/${ name }` : name;
options.push({ label: fullName, value: fullName });
});
return options;
});
const storageClassOptions = computed(() => {
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => {
const value = sc.name;
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value;
let disabled = false;
if (isInternalStorageClass(value)) {
label += ` (${ t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value,
disabled,
};
});
if (!options.find((o) => o.value === 'harvester-longhorn')) {
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
}
return options;
});
const applyNetworkMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
networkEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination) {
const dest = match.destination;
if (dest.type === 'pod') {
entry.target = 'pod';
} else if (dest.type === 'ignored') {
entry.target = 'ignored';
} else if (dest.type === 'multus' && dest.name) {
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
}
}
});
};
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
if (!mapSpec) {
return;
}
storageEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination?.storageClass) {
entry.target = match.destination.storageClass;
if (captureInherited) {
entry.inheritedFromProvider = true;
}
if (match.destination.volumeMode) {
entry.volumeMode = match.destination.volumeMode;
if (captureInherited) {
entry.inheritedVolumeMode = match.destination.volumeMode;
}
}
if (match.destination.accessMode) {
entry.accessModes = [match.destination.accessMode];
if (captureInherited) {
entry.inheritedAccessModes = [match.destination.accessMode];
}
}
if (markOverridden) {
entry.overridden = true;
}
}
});
};
const applyDefaultNetworkMap = () => {
const defaultMap = allNetworkMaps.value.find(
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
);
applyNetworkMapTargets(defaultMap?.spec?.map);
};
const applyDefaultStorageMap = () => {
const defaultMap = allStorageMaps.value.find(
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
// The "Inherited from provider" hint only appears in the migration plan wizard
// (where mappings inherit from the provider default), not on the provider creation page.
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
const openStorageDefaults = (entry) => {
editingStorageEntry.value = entry;
showStorageDefaultsModal.value = true;
};
const closeStorageDefaults = () => {
showStorageDefaultsModal.value = false;
editingStorageEntry.value = null;
};
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
if (editingStorageEntry.value) {
editingStorageEntry.value.volumeMode = volumeMode;
editingStorageEntry.value.accessModes = accessModes;
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
const selectedAccessMode = accessModes?.[0];
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
}
};
const canSave = computed(() => {
if (props.useAllProviderData) {
return true;
}
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value;
});
watch(canSave, (val) => {
emit('ready', val);
});
// After restore, check if ready
if (canSave.value) {
emit('ready', true);
}
const buildNetworkEntries = () => {
const networkMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => {
const netKey = net.id || net.name || 'default';
if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ netKey }`,
};
}
if (!networkMap[netKey].usedBy.includes(vmName)) {
networkMap[netKey].usedBy.push(vmName);
}
});
}
});
networkEntries.value = Object.values(networkMap);
};
const buildStorageEntries = () => {
const datastoreMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.disks && vm.disks.length > 0) {
vm.disks.forEach((disk) => {
const ds = disk.datastore;
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
datastoreMap[ds.id].capacity += disk.capacity || 0;
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
datastoreMap[ds.id].usedBy.push(vmName);
}
}
});
}
});
storageEntries.value = Object.values(datastoreMap);
};
const buildNetworkEntriesFromProvider = (networksData) => {
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
name: net.name || net.id,
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ net.id || net.name }`,
}));
};
const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
const formatStorageDetail = (entry) => {
const parts = [];
if (entry.type) {
parts.push(entry.type);
}
if (entry.capacity) {
const gb = entry.capacity / (1024 * 1024 * 1024);
if (gb >= 1024) {
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
} else {
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
}
}
return parts.join(' • ');
};
const buildNetworkMapSpec = (providerRef) => ({
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
provider: providerRef,
});
const buildStorageMapSpec = (providerRef) => ({
map: buildStorageMapEntries(storageEntries.value),
provider: providerRef,
});
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Update existing maps in place (edit mode)
if (props.existingNetworkMap && props.existingStorageMap) {
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
await props.existingNetworkMap.save();
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
await props.existingStorageMap.save();
return {
networkMapName: props.existingNetworkMap.metadata.name,
storageMapName: props.existingStorageMap.metadata.name,
};
}
const providerOwnerRef = props.provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.provider.metadata.name,
uid: props.provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildNetworkMapSpec(providerRef),
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildStorageMapSpec(providerRef),
});
await storageMap.save();
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
};
defineExpose({ saveMappings: saveAndReturn });
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
} catch (e) {
harvesterNetworks.value = [];
}
try {
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch (e) {
storageClasses.value = [];
}
try {
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
} catch (e) {
allNetworkMaps.value = [];
}
try {
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
} catch (e) {
allStorageMaps.value = [];
}
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
try {
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [networksData, datastoresData] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
]);
if (props.useAllProviderData) {
if (networkEntries.value.length === 0) {
buildNetworkEntriesFromProvider(networksData);
}
if (storageEntries.value.length === 0) {
buildStorageEntriesFromProvider(datastoresData);
}
} else {
vms.value = props.selectedVms;
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
}
return resolved;
});
if (networkEntries.value.length === 0) {
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
}
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
}
}
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
if (!hasExistingNetworkTargets) {
if (props.existingNetworkMap?.spec?.map) {
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
} else {
applyDefaultNetworkMap();
}
}
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
} else {
applyDefaultStorageMap();
}
}
loading.value = false;
};
init();
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-mappings"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
:entries="networkEntries"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
</template>
</MappingColumn>
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
:entries="storageEntries"
:options="storageClassOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
>
<template #source-detail="{ entry }">
<span
v-if="entry.type || entry.capacity"
class="source-detail text-deemphasized"
>{{ formatStorageDetail(entry) }}</span>
</template>
</MappingColumn>
</div>
<StorageDefaultsModal
v-if="showStorageDefaultsModal && editingStorageEntry"
:storage-class-name="editingStorageEntry.target"
:provider-name="providerName"
:show-inherited="!!inheritedProviderName"
:volume-mode="editingStorageEntry.volumeMode"
:access-modes="editingStorageEntry.accessModes"
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
@apply="applyStorageDefaults"
@close="closeStorageDefaults"
/>
</div>
</template>
<style lang="scss" scoped>
.mappings-columns {
display: flex;
flex-direction: column;
gap: 32px;
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
gap: 48px;
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
gap: 64px;
}
}
.configure-mappings {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,573 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { decodeSecretValue } from '../../utils/forklift';
const CREATE_NEW = '__create_new__';
const props = defineProps({
stepData: { type: Object, required: true },
createOnly: { type: Boolean, default: false },
editMode: { type: Boolean, default: false },
});
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
const store = useStore();
const { t } = useI18n(store);
const allProviders = ref([]);
const allSecrets = ref([]);
const errors = ref([]);
const testBtnRef = ref(null);
const loading = ref(true);
const testing = ref(false);
const {
selectedProvider,
providerName,
url,
username,
password,
skipTlsVerify,
vddkInitImage,
testPassed,
testResult,
testError,
createdProvider,
createdSecret,
} = toRefs(props.stepData);
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const providerOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
];
allProviders.value.forEach((p) => {
if (!p.spec?.url) {
return;
}
const secretRef = p.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (!secret?.data?.user || !secret?.data?.password) {
return;
}
} else {
return;
}
options.push({
label: p.metadata.name,
value: p.metadata.name,
});
});
return options;
});
// When the provider selection changes, populate or clear the fields
watch(selectedProvider, (val) => {
testPassed.value = false;
testResult.value = null;
testError.value = null;
if (val === CREATE_NEW) {
providerName.value = '';
url.value = '';
username.value = '';
password.value = '';
skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null;
createdSecret.value = null;
} else {
const provider = allProviders.value.find(
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
);
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
username.value = decodeSecretValue(secret.data.user);
password.value = decodeSecretValue(secret.data.password);
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
}
}
createdProvider.value = provider;
}
}
});
// Reset test state when any field changes (only for create new)
watch([providerName, url, username, password], () => {
if (!isExistingProvider.value) {
testPassed.value = false;
testResult.value = null;
}
});
// Emit ready/complete when testPassed changes
watch(testPassed, (val) => {
emit('ready', val);
if (val) {
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
}
}, { immediate: true });
// Emit form-valid when form validity changes
watch(isFormValid, (val) => {
emit('form-valid', val);
}, { immediate: true });
watch(testing, (val) => {
emit('testing', val);
}, { immediate: true });
const pollProviderReady = async(name) => {
const inStore = store.getters['currentProduct'].inStore;
const namespace = FORKLIFT_NAMESPACE;
const maxAttempts = 15;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ name }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
const readyCondition = conditions.find((c) => c.type === 'Ready');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
return { connected: true };
}
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
}
if (readyCondition) {
if (readyCondition.status === 'True') {
return { connected: true };
} else if (readyCondition.status === 'False') {
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
}
}
}
return { connected: false, errorMsg: '' };
};
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
};
const cleanupCreatedResources = async() => {
if (createdProvider.value) {
try {
await createdProvider.value.remove();
} catch (e) {}
createdProvider.value = null;
createdSecret.value = null;
} else if (createdSecret.value) {
try {
await createdSecret.value.remove();
} catch (e) {}
createdSecret.value = null;
}
};
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
testing.value = false;
buttonCb(false);
return;
}
const inStore = store.getters['currentProduct'].inStore;
// Edit mode: update existing provider URL + secret, then poll
if (props.editMode && createdProvider.value) {
try {
createdProvider.value.spec.url = url.value;
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
createdProvider.value.spec.settings = {
...(createdProvider.value.spec.settings || {}),
vddkInitImage: vddkImage,
};
} else if (createdProvider.value.spec.settings) {
delete createdProvider.value.spec.settings.vddkInitImage;
}
await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret;
if (secretRef && createdSecret.value) {
createdSecret.value.data = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await createdSecret.value.save();
}
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For new providers, create provider + secret then poll
try {
await cleanupCreatedResources();
const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const spec = {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
};
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
spec.settings = { vddkInitImage: vddkImage };
}
const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec,
});
await provider.save();
createdProvider.value = provider;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
},
],
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await newSecret.save();
createdSecret.value = newSecret;
const result = await pollProviderReady(providerName.value);
if (!result.connected) {
await cleanupCreatedResources();
}
handlePollResult(result, buttonCb);
} catch (err) {
await cleanupCreatedResources();
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
}
loading.value = false;
};
init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-provider-step"
>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
</p>
<div class="configure-provider-step-content">
<h3 class="table-title m-0">
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
</h3>
<Banner
class="requirements-banner m-0"
color="info"
>
<div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
<br>
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
</div>
</Banner>
<div class="configure-provider-step-form">
<div v-if="!createOnly && !editMode">
<LabeledSelect
v-model:value="selectedProvider"
required
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider || editMode"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
:disabled="editMode"
required
/>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="vddkInitImage"
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
:disabled="isExistingProvider && !editMode"
/>
</div>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
</div>
<div>
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider && !editMode"
/>
<p class="text-deemphasized ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p>
</div>
<div>
<AsyncButton
ref="testBtnRef"
mode="test"
:disabled="!isFormValid"
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
:action-color="'role-secondary'"
:waiting-color="'role-disabled'"
@click="testConnection"
/>
</div>
<Banner
v-if="testResult"
color="success"
>
{{ testResult }}
</Banner>
<Banner
v-if="testError"
color="error"
>
{{ testError }}
</Banner>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
>
{{ err }}
</Banner>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.requirements-banner {
font-weight: 400;
.requirements-banner-title {
font-weight: 600;
}
}
.configure-provider-step {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
.configure-provider-step-content {
display: flex;
flex-direction: column;
gap: 12px;
.configure-provider-step-form {
display: flex;
flex-direction: column;
gap: 20px;
}
}
:deep(.checkbox-label) {
color: var(--body-text);
}
}
</style>
@@ -0,0 +1,252 @@
<script setup>
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE } from '../../config/types';
const store = useStore();
const { t } = useI18n(store);
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
// Storage-specific: show the volume/access mode defaults row with an Edit action.
showVolumeSettings: { type: Boolean, default: false },
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
inheritedProviderName: { type: String, default: '' },
});
const emit = defineEmits(['edit-defaults']);
// Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
return props.options;
}
return [
{
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
value: null,
kind: 'highlighted',
},
{
label: 'divider',
disabled: true,
kind: 'divider',
},
...props.options,
];
};
const formatModes = (entry) => {
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
const accessModes = (entry.accessModes || []).join(', ');
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
};
</script>
<template>
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ title }}
</h3>
<p
v-if="description"
class="text-deemphasized line-height-20"
>
{{ description }}
</p>
</div>
<RcItemCard
v-for="entry in entries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
>
<template #item-card-content>
<div class="card-content-column">
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<slot
name="source-detail"
:entry="entry"
/>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
<i
class="icon icon-right-arrow-alt"
aria-hidden="true"
/>
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="optionsFor(entry)"
:placeholder="placeholder+'...'"
:searchable="true"
/>
</div>
</div>
<div
v-if="showVolumeSettings && entry.target"
class="storage-defaults-row"
>
<div class="storage-defaults">
<div class="storage-defaults-info">
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
<span
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
class="text-deemphasized storage-defaults-inherited"
>
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
</span>
</div>
<a
role="button"
class="storage-defaults-edit"
@click.prevent="emit('edit-defaults', entry)"
>
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
</a>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
</template>
<style lang="scss" scoped>
.mapping-column {
display: flex;
gap: 12px;
flex-direction: column;
:deep(.item-card-header) {
display: none;
}
}
.mapping-section-title {
font-weight: 600;
margin: 0 0 5px 0;
line-height: 28px;
}
.card-content-row {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
flex: 2;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 3;
min-width: 0;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.storage-defaults-row {
width: 100%;
}
.storage-defaults {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 4px;
background-color: var(--body-bg);
.storage-defaults-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.storage-defaults-summary {
font-size: 13px;
line-height: 20px;
}
.storage-defaults-inherited {
font-size: 12px;
line-height: 18px;
}
.storage-defaults-edit {
flex-shrink: 0;
cursor: pointer;
}
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
</style>
@@ -0,0 +1,674 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import {
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
} from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
mappingEntries: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore();
const { t } = useI18n(store);
const vms = computed(() => props.selectedVms);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE;
const nameConflictError = computed(() => {
const name = (planName.value || '').trim();
if (!name) {
return '';
}
if (existingProviderNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
}
if (existingPlanNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
}
return '';
});
watch(planName, (val) => {
props.stepData.planName = val;
}, { immediate: true });
watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val;
});
watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true });
const formatNetworkTarget = (target = '') => {
if (target === 'pod') {
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
}
if (target === 'ignored') {
return t('harvester.addons.vmMigration.generic.ignored');
}
return target;
};
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => {
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return mbToGB(totalMB);
});
const totalStorageGB = computed(() => {
let totalBytes = 0;
vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) {
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
});
return bytesToGB(totalBytes);
});
const vmRows = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id,
name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os,
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
networkMapping: networkMappingSort,
storageMapping: storageMappingSort,
};
});
});
const vmHeaders = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
value: 'name',
sort: ['name'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
value: 'os',
sort: ['os'],
width: 140,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
value: 'resources',
sort: false,
width: 120,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 100,
},
{
name: 'networkMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
value: 'networkMapping',
sort: ['networkMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'storageMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
value: 'storageMapping',
sort: ['storageMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'actions',
label: ' ',
align: 'right',
sort: false,
width: 40,
},
];
const onEditSelection = () => emit('edit-selection');
const onEditMappings = () => emit('edit-mappings');
const onRemoveVm = (id) => emit('remove-vm', id);
const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: props.providerName,
namespace: NS,
},
destination: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: 'host',
namespace: NS,
},
};
const networkMapName = `${ planName.value }-network-map`;
const storageMapName = `${ planName.value }-storage-map`;
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NS },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NS },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
});
await storageMap.save();
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NS },
spec: {
provider: providerRef,
map: {
network: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: networkMapName,
namespace: NS,
},
storage: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: storageMapName,
namespace: NS,
},
},
targetNamespace: targetNamespace.value,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
} catch (e) {
namespaceOptions.value = [];
}
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {}
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
} catch (e) {}
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
}
loading.value = false;
};
init();
defineExpose({ startMigration: startMigrationAction });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<div
class="review-migration-content"
>
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary -->
<div class="migration-details">
<div class="section-header">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row">
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
<LabeledSelect
v-model:value="targetNamespace"
class="namespace-select"
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
:options="namespaceOptions"
required
/>
</div>
<div class="summary-row">
<div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div>
<div class="stats-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-value">{{ providerName }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value">{{ vms.length }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div>
</div>
</div>
<!-- Virtual Machines -->
<div class="vm-section">
<SortableTable
class="vm-sortable-table"
:rows="vmRows"
:headers="vmHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
>
<template #header-left>
<h4 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h4>
</template>
<template #header-right>
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
</a>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.name }}</span>
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resources }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:networkMapping="{ row }">
<template v-if="row.networkMappings.length">
<div
v-for="(m, i) in row.networkMappings"
:key="'net-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:storageMapping="{ row }">
<template v-if="row.storageMappings.length">
<div
v-for="(m, i) in row.storageMappings"
:key="'stor-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ m.target }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:actions="{ row }">
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
<rc-dropdown-trigger
variant="ghost"
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<rc-dropdown-item @click="onEditMappings">
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
</rc-dropdown-item>
<rc-dropdown-item
class="text-error"
@click="onRemoveVm(row.id)"
>
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>
<template #no-rows>
<tr class="no-rows">
<td
:colspan="vmHeaders.length"
class="text-center"
>
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
</td>
</tr>
</template>
</SortableTable>
</div>
<!-- Error banner -->
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-migration {
gap: 36px;
}
.review-migration-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
font-weight: 600;
}
.name-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
align-items: start;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
line-height: 20px;
margin-top: 12px;
}
.section-header {
display: flex;
flex-direction: column;
gap: 4px;
.section-description {
font-size: 14px;
}
}
.migration-mode-column {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-size: 14px;
.cold-migration-badge {
padding: 2px 10px;
border-radius: 4px;
opacity: 0.8;
font-weight: 600;
background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600;
.theme-light & {
background: rgba(168, 128, 26, 0.16);
}
}
}
.stats-column {
display: grid;
grid-template-columns: auto auto;
justify-content: start;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
}
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
}
.edit-selection-link {
cursor: pointer;
color: var(--link);
font-size: 14px;
}
.vm-sortable-table {
:deep(.fixed-header-actions) {
align-items: end;
}
:deep(table) {
table-layout: fixed;
}
:deep(td) {
vertical-align: middle;
overflow: hidden;
}
:deep(th:first-child),
:deep(td:first-child) {
padding-left: 16px;
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
.vm-name {
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
}
.mapping-cell {
.mapping-target {
margin-left: 4px;
white-space: nowrap;
}
}
.mapping-cell + .mapping-cell {
margin-top: 8px;
}
.banner-title {
font-weight: 600;
}
.migration-details {
display: flex;
flex-direction: column;
gap: 12px;
}
header {
margin: 0;
}
.vm-section {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
@@ -0,0 +1,668 @@
<script setup>
import {
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
} from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { useI18n } from '@shell/composables/useI18n';
import { bytesToGB, mbToGB } from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['complete', 'loading']);
const store = useStore();
const { t } = useI18n(store);
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]);
const loading = ref(true);
// Restored from stepData so friendly names survive step navigation / remount.
const networkMap = ref(props.stepData.networkMap || {});
const datastoreMap = ref(props.stepData.datastoreMap || {});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const errors = ref([]);
let skipNextSelectionEvent = false;
const lastFetchedAt = ref(null);
const now = ref(Date.now());
const formatElapsed = (ms) => {
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
}
if (hours > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
}
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
};
const lastSyncedTime = computed(() => {
if (!lastFetchedAt.value) {
return '';
}
return formatElapsed(now.value - lastFetchedAt.value);
});
const nowTimer = setInterval(() => {
now.value = Date.now();
}, 30000);
onBeforeUnmount(() => {
clearInterval(nowTimer);
});
// Restore selection state from stepData
if (selectedVMIds.value.size > 0) {
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
}
const vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length);
const showSelectAllBanner = computed(() => {
if (allVMsSelected.value) {
return true;
}
const pagedRows = sortableTableRef.value?.pagedRows || [];
if (pagedRows.length === 0) {
return false;
}
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
return allPageSelected && tableRows.value.length > pagedRows.length;
});
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = ref([
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os',
sort: ['os'],
width: 180,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources',
sort: false,
width: 140,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 130,
},
{
name: 'network',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
]);
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
let networks = [];
if (vm.networks && vm.networks.length > 0) {
networks = vm.networks.map((n) => ({
name: n.name || networkMap.value[n.id] || n.id || n,
id: n.id || '',
vlanId: n.vlanId || '',
}));
}
const datastores = [];
if (vm.disks && vm.disks.length > 0) {
const seen = new Set();
vm.disks.forEach((d) => {
const dsId = d.datastore?.id || d.datastore?.name;
if (dsId && !seen.has(dsId)) {
seen.add(dsId);
datastores.push({
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
id: d.datastore?.id || '',
type: d.datastore?.type || '',
capacity: d.capacity || 0,
});
}
});
}
return {
_original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name,
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-',
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
networks,
network: networks.map((n) => n.name).join(', ') || '-',
datastores,
datastore: datastores.map((d) => d.name).join(', ') || '-',
};
});
};
const onSelect = (rows) => {
if (skipNextSelectionEvent) {
return;
}
const currentPageRows = sortableTableRef.value?.pagedRows || [];
const selectedKeys = new Set(rows.map((r) => r._key));
currentPageRows.forEach((row) => {
const vmId = row._original?.id;
if (selectedKeys.has(row._key)) {
selectedVMIds.value.add(vmId);
} else {
selectedVMIds.value.delete(vmId);
}
});
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
emit('complete', { selectedVMs: selectedVMs.value });
};
const clearSelection = () => {
allVMsSelected.value = false;
selectedVMIds.value = new Set();
selectedVMs.value = [];
nextTick(() => {
const table = sortableTableRef.value;
if (table) {
table.clearSelection();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
}
});
};
const selectAllVMs = () => {
allVMsSelected.value = true;
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
selectedVMs.value = discoveredVMs.value.slice();
};
// Moves currently-selected VMs to the first page(s) and re-checks them.
const sortSelectedToFront = () => {
if (selectedVMIds.value.size === 0) {
return;
}
tableRows.value.forEach((row) => {
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
});
headers.value = headers.value.map((h) => (
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
));
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
skipNextSelectionEvent = false;
return;
}
if (typeof table.changeSort === 'function') {
table.changeSort('vmName', false);
} else if (typeof table.setPage === 'function') {
table.setPage(1);
}
nextTick(() => {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
});
};
watch(
() => sortableTableRef.value?.page,
() => {
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
return;
}
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
}
);
const refreshing = ref(false);
const isLoading = computed(() => loading.value || refreshing.value);
watch(isLoading, (val) => {
emit('loading', val);
}, { immediate: true });
const fetchVMs = async() => {
if (!props.provider) {
return;
}
const inStore = store.getters['currentProduct'].inStore;
const providerUid = props.provider.metadata.uid;
const providerType = props.provider.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
]);
if (Array.isArray(vmsResp)) {
discoveredVMs.value = vmsResp;
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
discoveredVMs.value = vmsResp.data;
}
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
networkMap.value = networks.reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
datastoreMap.value = datastores.reduce((map, d) => {
map[d.id] = d.name;
return map;
}, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
const previousSelectedIds = new Set(selectedVMIds.value);
try {
errors.value = [];
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
}
selectedVMIds.value = new Set(
discoveredVMs.value
.filter((vm) => previousSelectedIds.has(vm.id))
.map((vm) => vm.id)
);
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
props.stepData.discoveredVMs = discoveredVMs.value;
props.stepData.selectedVMIds = selectedVMIds.value;
props.stepData.tableRows = tableRows.value;
emit('complete', { selectedVMs: selectedVMs.value });
refreshing.value = false;
await nextTick();
const table = sortableTableRef.value;
if (table) {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
}
skipNextSelectionEvent = false;
};
const init = async() => {
if (discoveredVMs.value.length > 0) {
if (!lastFetchedAt.value) {
lastFetchedAt.value = Date.now();
}
tableRows.value = buildTableRows();
loading.value = false;
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
await nextTick();
sortSelectedToFront();
return;
}
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
}
loading.value = false;
};
// Emit initial complete if VMs are already selected (restored from stepData)
if (selectedVMs.value.length > 0) {
emit('complete', { selectedVMs: selectedVMs.value });
}
init();
</script>
<template>
<Loading v-if="loading || refreshing" />
<div
v-else
class="select-vms-step"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link
v-if="provider"
class="provider-link"
:to="provider._detailLocation"
>
{{ providerName }}
</router-link>
<br>
<span
v-if="lastSyncedTime"
>
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
<a
role="button"
class="text-bold"
:class="{ disabled: refreshing }"
@click.prevent="refreshVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
</a>
</span>
</p>
<!-- Discovered VMs table -->
<SortableTable
ref="sortableTableRef"
:rows="tableRows"
:headers="headers"
:search="true"
:table-actions="true"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
@selection="onSelect"
>
<template #header-left>
<div class="vm-table-title">
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="selected-actions">
<span class="text-deemphasized">
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
</span>
<template v-if="selectedCount > 0">
<span class="text-deemphasized">|</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
</a>
</template>
</span>
</div>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.vmName }}</span>
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resourcesDisplay }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
/>
</template>
<template #cell:network="{ row }">
<div>
<div
v-for="(net, i) in row.networks"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ net.name }}</span><br>
<span class="text-deemphasized">{{ net.id }}</span>
</div>
</div>
</template>
<template #cell:datastore="{ row }">
<div>
<div
v-for="(ds, i) in row.datastores"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ ds.name }}</span><br>
<span class="text-deemphasized">{{ ds.id }}</span>
</div>
</div>
</template>
</SortableTable>
<Teleport
v-if="showSelectAllBanner && theadElement"
:to="theadElement"
>
<tr class="select-all-banner-row">
<td
:colspan="headers.length + 1"
class="select-all-banner-cell"
>
<template v-if="allVMsSelected">
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a>
</template>
<template v-else>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a
role="button"
@click.prevent="selectAllVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a>
</template>
</td>
</tr>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.vm-table-title {
.text-deemphasized {
font-size: 13px;
}
.selected-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
a {
cursor: pointer;
&.disabled {
color: var(--muted);
cursor: default;
pointer-events: none;
}
}
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
font-size: 14px;
.vm-name {
line-height: 20px;
}
.vm-id {
line-height: 20px;
}
}
.select-vms-step {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
.line-height-20 {
line-height: 20px;
}
}
:deep(.select-all-banner-row) {
.select-all-banner-cell {
text-align: center;
padding: 8px 16px;
background-color: var(--sortable-table-header-bg);
border-bottom: 1px solid var(--border);
font-size: 13px;
a {
color: var(--primary);
cursor: pointer;
font-weight: 600;
margin-left: 5px;
&:hover {
text-decoration: underline;
}
}
}
}
</style>
@@ -0,0 +1,175 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import AppModal from '@shell/components/AppModal';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
const ACCESS_MODE_OPTIONS = [ACCESS_MODE.READ_WRITE_ONCE, ACCESS_MODE.READ_WRITE_MANY, ACCESS_MODE.READ_ONLY_MANY];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
accessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
});
const emit = defineEmits(['apply', 'close']);
const store = useStore();
const { t } = useI18n(store);
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
const localAccessMode = ref(props.accessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY);
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
const apply = () => {
emit('apply', {
volumeMode: localVolumeMode.value,
accessModes: [localAccessMode.value],
});
emit('close');
};
// Repopulate the dropdowns with the provider default values; the user then applies.
const reset = () => {
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
localAccessMode.value = props.inheritedAccessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY;
};
const cancel = () => {
emit('close');
};
</script>
<template>
<app-modal
class="storage-defaults-modal"
name="storageDefaultsDialog"
:width="620"
height="auto"
:click-to-close="false"
@close="cancel"
>
<Card
class="storage-defaults-card"
:show-highlight-border="false"
>
<template #title>
<h4 class="text-default-text">
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
</h4>
</template>
<template #body>
<p class="text-deemphasized description">
<template v-if="showInherited">
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
</template>
<template v-else>
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
</template>
</p>
<div class="settings-box">
<LabeledSelect
v-model:value="localVolumeMode"
class="volume-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
:options="volumeModeOptions"
:searchable="false"
:clearable="false"
/>
<LabeledSelect
v-model:value="localAccessMode"
class="access-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
:options="accessModeOptions"
:searchable="false"
:clearable="false"
/>
</div>
</template>
<template #actions>
<div class="buttons">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
</button>
<div class="right-buttons">
<button
v-if="showInherited"
class="btn role-secondary mr-10"
@click="reset"
>
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
</button>
<button
class="btn role-primary"
:disabled="!localAccessMode"
@click="apply"
>
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
</button>
</div>
</div>
</template>
</Card>
</app-modal>
</template>
<style lang="scss" scoped>
.storage-defaults-modal {
z-index: 45;
}
.storage-defaults-card {
margin: 0;
padding: 20px;
}
.description {
margin-bottom: 16px;
line-height: 20px;
}
.settings-box {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px;
border-radius: 6px;
background-color: var(--category-active);
}
.volume-mode {
max-width: 280px;
}
.access-mode {
max-width: 280px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right-buttons {
display: flex;
}
}
</style>
+23 -2
View File
@@ -54,8 +54,29 @@ const FEATURE_FLAGS = {
'lhV2VolExpansion',
'l2VlanTrunkMode',
'kubevirtMigration',
'hotplugNic'
]
'hotplugNic',
'resumeUpgradePausedNode',
],
'v1.7.1': [],
'v1.8.0': [
'hotplugCdRom',
'supportBundleFileNameSetting',
'clusterRegistrationTLSVerify',
'vGPUAsPCIDevice',
'instanceManagerResourcesSetting',
'rwxNetworkSetting',
'createPVCWithDataVolume',
'clusterPodSecurityStandardSetting',
],
'v1.8.1': [],
'v1.9.0': [
'supportFilesystem',
'disableResourcePooling',
'expandOnlineEncryptedVolume',
'longhornV2HugepageSettings',
'staticIPForVM',
'fsFreezeDeadline',
],
};
const generateFeatureFlags = () => {
+543 -20
View File
@@ -35,12 +35,40 @@ import {
SNAPSHOT_TARGET_VOLUME,
IMAGE_VIRTUAL_SIZE,
IMAGE_STORAGE_CLASS,
HARVESTER_DESCRIPTION
HARVESTER_DESCRIPTION,
VM_IMPORT_SOURCE_VM,
VM_IMPORT_SOURCE_CLUSTER,
VM_IMPORT_STATUS,
VM_IMPORT_SOURCE_V_DC,
VM_IMPORT_SOURCE_V_ENDPOINT,
VM_IMPORT_SOURCE_V_STATUS,
VM_IMPORT_SOURCE_O_REGION,
VM_IMPORT_SOURCE_O_ENDPOINT,
VM_IMPORT_SOURCE_O_STATUS,
VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS,
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';
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';
@@ -68,7 +96,6 @@ export function init($plugin, store) {
configureType,
virtualType,
weightGroup,
weightType,
} = $plugin.DSL(store, PRODUCT_NAME);
const isSingleVirtualCluster = process.env.rancherEnv === PRODUCT_NAME;
@@ -155,7 +182,7 @@ export function init($plugin, store) {
group: 'Root',
name: HCI.HOST,
namespaced: true,
weight: 399,
weight: 499,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST }
@@ -187,7 +214,7 @@ export function init($plugin, store) {
group: 'root',
name: HCI.VM,
namespaced: true,
weight: 299,
weight: 498,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VM }
@@ -195,6 +222,146 @@ export function init($plugin, store) {
exact: false
});
// ===========================================================================
// VM Import Controller UI Flow
// ===========================================================================
// Define group (Hidden by default)
weightGroup('vmimport', 0, false);
// VirtualMachineImport
headers(HCI.VMIMPORT, [
STATE,
NAME_COL,
NAMESPACE_COL,
VM_IMPORT_SOURCE_VM,
VM_IMPORT_SOURCE_CLUSTER,
VM_IMPORT_STATUS,
AGE
]);
configureType(HCI.VMIMPORT, {
resource: HCI.VMIMPORT,
resourceDetail: HCI.VMIMPORT,
resourceEdit: HCI.VMIMPORT,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT,
labelKey: 'harvester.addons.vmImport.labels.vmimport',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT }
}
});
// Source: VMware
headers(HCI.VMIMPORT_SOURCE_V, [
STATE,
NAME_COL,
VM_IMPORT_SOURCE_V_ENDPOINT,
VM_IMPORT_SOURCE_V_DC,
VM_IMPORT_SOURCE_V_STATUS,
AGE
]);
configureType(HCI.VMIMPORT_SOURCE_V, {
resource: HCI.VMIMPORT_SOURCE_V,
resourceDetail: HCI.VMIMPORT_SOURCE_V,
resourceEdit: HCI.VMIMPORT_SOURCE_V,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT_SOURCE_V,
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_V,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_V }
}
});
// Source: OpenStack
headers(HCI.VMIMPORT_SOURCE_O, [
STATE,
NAME_COL,
VM_IMPORT_SOURCE_O_ENDPOINT,
VM_IMPORT_SOURCE_O_REGION,
VM_IMPORT_SOURCE_O_STATUS,
AGE
]);
configureType(HCI.VMIMPORT_SOURCE_O, {
resource: HCI.VMIMPORT_SOURCE_O,
resourceDetail: HCI.VMIMPORT_SOURCE_O,
resourceEdit: HCI.VMIMPORT_SOURCE_O,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT_SOURCE_O,
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_O,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_O }
}
});
// Source: OVA
headers(HCI.VMIMPORT_SOURCE_OVA, [
STATE,
NAME_COL,
VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS,
AGE
]);
configureType(HCI.VMIMPORT_SOURCE_OVA, {
resource: HCI.VMIMPORT_SOURCE_OVA,
resourceDetail: HCI.VMIMPORT_SOURCE_OVA,
resourceEdit: HCI.VMIMPORT_SOURCE_OVA,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
}
});
virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav()
name: HCI.VMIMPORT_SOURCE_OVA,
labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA',
group: 'vmimport',
namespaced: true,
ifHaveType: HCI.VMIMPORT_SOURCE_OVA,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VMIMPORT_SOURCE_OVA }
}
});
// Enable SideNav based on Addon Status
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.VM_IMPORT_CONTROLLER,
resourceType: HCI.ADD_ONS,
navGroup: 'vmimport',
types: [
HCI.VMIMPORT_SOURCE_V,
HCI.VMIMPORT_SOURCE_O,
HCI.VMIMPORT_SOURCE_OVA,
HCI.VMIMPORT
]
});
// ===========================================================================
basicType([HCI.VOLUME]);
configureType(HCI.VOLUME, {
location: {
@@ -212,7 +379,7 @@ export function init($plugin, store) {
ifHaveType: PVC,
name: HCI.VOLUME,
namespaced: true,
weight: 199,
weight: 497,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VOLUME }
@@ -238,7 +405,7 @@ export function init($plugin, store) {
group: 'root',
name: HCI.IMAGE,
namespaced: true,
weight: 198,
weight: 496,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IMAGE }
@@ -253,7 +420,7 @@ export function init($plugin, store) {
group: 'root',
namespaced: true,
name: 'projects-namespaces',
weight: 98,
weight: 495,
route: { name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces` },
exact: true,
});
@@ -265,7 +432,7 @@ export function init($plugin, store) {
labelKey: 'harvester.namespace.label',
name: NAMESPACE,
namespaced: true,
weight: 89,
weight: 495,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: NAMESPACE }
@@ -336,6 +503,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_FLOW,
labelKey: 'harvester.logging.clusterFlow.label',
name: HCI.CLUSTER_FLOW,
namespaced: true,
@@ -359,6 +527,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_OUTPUT,
labelKey: 'harvester.logging.clusterOutput.label',
name: HCI.CLUSTER_OUTPUT,
namespaced: true,
@@ -382,6 +551,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.FLOW,
labelKey: 'harvester.logging.flow.label',
name: HCI.FLOW,
namespaced: true,
@@ -405,6 +575,7 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.OUTPUT,
labelKey: 'harvester.logging.output.label',
name: HCI.OUTPUT,
namespaced: true,
@@ -425,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,
@@ -443,9 +653,12 @@ export function init($plugin, store) {
'backupAndSnapshot'
);
weightGroup('networks', 300, true);
weightType(NAMESPACE, 299, true);
weightGroup('backupAndSnapshot', 289, true);
weightGroup('networks', 494, true);
weightGroup('Overlay Networks', 493, true);
weightGroup('NAT & Internet', 492, true);
weightGroup('Rules', 491, true);
weightGroup('Underlay Networks', 490, true);
weightGroup('backupAndSnapshot', 489, true);
basicType(
[
@@ -524,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 }
@@ -539,14 +752,14 @@ export function init($plugin, store) {
},
resource: NETWORK_ATTACHMENT,
resourceDetail: HCI.NETWORK_ATTACHMENT,
resourceEdit: HCI.NETWORK_ATTACHMENT
resourceEdit: HCI.NETWORK_ATTACHMENT,
});
virtualType({
labelKey: 'harvester.network.label',
name: HCI.NETWORK_ATTACHMENT,
namespaced: true,
weight: 188,
weight: 485,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.NETWORK_ATTACHMENT }
@@ -560,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 }
@@ -569,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 }
@@ -584,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: {
@@ -938,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 }
@@ -977,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 }
@@ -986,4 +1306,207 @@ export function init($plugin, store) {
ifHaveType: HCI.IP_POOL,
});
headers(HCI.IP_POOL, IP_POOL_HEADERS);
configureType(HCI.HOST_NETWORK_CONFIG, {
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST_NETWORK_CONFIG }
},
canYaml: false,
});
virtualType({
labelKey: 'harvester.hostNetworkConfig.label',
name: HCI.HOST_NETWORK_CONFIG,
namespaced: false,
weight: 481,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST_NETWORK_CONFIG }
},
exact: false,
ifHaveType: HCI.HOST_NETWORK_CONFIG,
});
// ===========================================================================
// Forklift Addon UI Flow
// ===========================================================================
weightGroup('vmMigration', 0, false);
// Provider
headers(HCI.FORKLIFT_PROVIDER, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
AGE
]);
configureType(HCI.FORKLIFT_PROVIDER, {
resource: HCI.FORKLIFT_PROVIDER,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'vmMigration',
namespaced: true,
weight: 100,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
// NetworkMap
headers(HCI.FORKLIFT_NETWORK_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_NETWORK_MAP, {
resource: HCI.FORKLIFT_NETWORK_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
// StorageMap
headers(HCI.FORKLIFT_STORAGE_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_STORAGE_MAP, {
resource: HCI.FORKLIFT_STORAGE_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
// Plan
headers(HCI.FORKLIFT_PLAN, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
AGE
]);
configureType(HCI.FORKLIFT_PLAN, {
resource: HCI.FORKLIFT_PLAN,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
// Migration
headers(HCI.FORKLIFT_MIGRATION, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MIGRATION_PLAN,
AGE
]);
configureType(HCI.FORKLIFT_MIGRATION, {
resource: HCI.FORKLIFT_MIGRATION,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({
name: 'forklift-create',
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'vmMigration',
namespaced: true,
weight: 200,
route: {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {}
}
});
// Enable SideNav based on Forklift Addon Status
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
// is registered with requireSchema:false to still be gated by the addon status.
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
requireSchema: false,
types: ['forklift-create']
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
types: [
HCI.FORKLIFT_PROVIDER,
]
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration::Configurations',
types: [
HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP,
HCI.FORKLIFT_PLAN,
HCI.FORKLIFT_MIGRATION,
]
});
// ===========================================================================
}
+12
View File
@@ -39,6 +39,12 @@ export const VOLUME_TYPE = [{
value: 'cd-rom'
}];
export const VOLUME_HOTPLUG_ACTION = {
INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE',
EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE',
DETACH_DISK: 'DETACH_DISK'
};
export const ACCESS_CREDENTIALS = {
RESET_PWD: 'userPassword',
INJECT_SSH: 'sshPublicKey'
@@ -73,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 = {
@@ -82,8 +89,13 @@ export const CSI_SECRETS = {
CSI_NODE_PUBLISH_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-publish-secret-namespace',
CSI_NODE_STAGE_SECRET_NAME: 'csi.storage.k8s.io/node-stage-secret-name',
CSI_NODE_STAGE_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-stage-secret-namespace',
CSI_NODE_EXPAND_SECRET_NAME: 'csi.storage.k8s.io/node-expand-secret-name',
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
};
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
export const FORKLIFT_NAMESPACE = 'forklift';
// Some harvester CRD type is not equal to model file name, define the mapping here
export const HARVESTER_CRD_MAP = {
node: HCI.HOST,
@@ -16,9 +16,11 @@ export const HCI = {
CREATOR: 'harvesterhci.io/creator',
OS: 'harvesterhci.io/os',
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
NETWORK_TYPE: 'network.harvesterhci.io/type',
VM_NAME: 'harvesterhci.io/vmName',
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
VM_DISPLAY_NAME: 'harvesterhci.io/vmDisplayName',
VM_RESERVED_MEMORY: 'harvesterhci.io/reservedMemory',
MAINTENANCE_STATUS: 'harvesterhci.io/maintain-status',
HOST_CUSTOM_NAME: 'harvesterhci.io/host-custom-name',
@@ -28,6 +30,7 @@ export const HCI = {
NODE_ROLE_CONTROL_PLANE: 'node-role.kubernetes.io/control-plane',
NODE_ROLE_ETCD: 'node-role.harvesterhci.io/witness',
PROMOTE_STATUS: 'harvesterhci.io/promote-status',
CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status',
MIGRATION_STATE: 'harvesterhci.io/migrationState',
VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates',
IMAGE_NAME: 'harvesterhci.io/image-name',
@@ -77,4 +80,11 @@ export const HCI = {
CLONE_STRATEGY: 'cdi.harvesterhci.io/storageProfileCloneStrategy',
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address',
STATIC_IP: 'static-ip.harvesterhci.io',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
};
+68 -41
View File
@@ -1,44 +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_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration'
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'vlan',
UI_SOURCE: 'ui-source',
UI_PL: 'ui-pl',
HTTP_PROXY: 'http-proxy',
ADDITIONAL_CA: 'additional-ca',
OVERCOMMIT_CONFIG: 'overcommit-config',
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED: 'longhorn-v2-data-engine-hugepage-enabled',
LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE: 'longhorn-v2-data-engine-memory-size',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
RANCHER_CLUSTER: 'rancher-cluster',
MAX_HOTPLUG_RATIO: 'max-hotplug-ratio',
KUBEVIRT_MIGRATION: 'kubevirt-migration',
INSTANCE_MANAGER_RESOURCES: 'instance-manager-resources',
CLUSTER_POD_SECURITY_STANDARD: 'cluster-pod-security-standard'
};
export const HCI_ALLOWED_SETTINGS = {
@@ -71,11 +77,17 @@ export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.OVERCOMMIT_CONFIG]: { kind: 'json', from: 'import' },
[HCI_SETTING.SUPPORT_BUNDLE_TIMEOUT]: { kind: 'number' },
[HCI_SETTING.SUPPORT_BUNDLE_EXPIRATION]: { kind: 'number' },
[HCI_SETTING.SUPPORT_BUNDLE_FILE_NAME]: {
kind: 'string', canReset: true, featureFlag: 'supportBundleFileNameSetting'
},
[HCI_SETTING.SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT]: { kind: 'number', featureFlag: 'supportBundleNodeCollectionTimeoutSetting' },
[HCI_SETTING.SUPPORT_BUNDLE_IMAGE]: { kind: 'json', from: 'import' },
[HCI_SETTING.STORAGE_NETWORK]: {
kind: 'custom', from: 'import', canReset: true
},
[HCI_SETTING.RWX_NETWORK]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'rwxNetworkSetting'
},
[HCI_SETTING.VM_FORCE_RESET_POLICY]: { kind: 'json', from: 'import' },
[HCI_SETTING.SSL_CERTIFICATES]: { kind: 'json', from: 'import' },
[HCI_SETTING.SSL_PARAMETERS]: {
@@ -102,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',
@@ -118,12 +138,19 @@ export const HCI_ALLOWED_SETTINGS = {
},
[HCI_SETTING.KUBEVIRT_MIGRATION]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'kubevirtMigration',
},
[HCI_SETTING.INSTANCE_MANAGER_RESOURCES]: {
kind: 'json', from: 'import', featureFlag: 'instanceManagerResourcesSetting'
},
[HCI_SETTING.CLUSTER_POD_SECURITY_STANDARD]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'clusterPodSecurityStandardSetting'
}
};
export const HCI_SINGLE_CLUSTER_ALLOWED_SETTING = {
[HCI_SETTING.CLUSTER_REGISTRATION_URL]: {
kind: 'url',
kind: 'custom',
from: 'import',
canReset: true,
},
[HCI_SETTING.UI_PL]: {
+166
View File
@@ -131,3 +131,169 @@ export const PROVIDER = {
value: 'spec.provider',
align: 'left',
};
// Source VM column in migration.harvesterhci.io.virtualmachineimport list page
export const VM_IMPORT_SOURCE_VM = {
name: 'sourceVm',
labelKey: 'harvester.tableHeaders.vmImportSourceVm',
value: 'spec.virtualMachineName',
sort: 'spec.virtualMachineName',
align: 'left',
};
// Source Cluster column in migration.harvesterhci.io.virtualmachineimport list page
export const VM_IMPORT_SOURCE_CLUSTER = {
name: 'sourceCluster',
labelKey: 'harvester.tableHeaders.vmImportSourceCluster',
value: 'spec.sourceCluster.name',
sort: 'spec.sourceCluster.name',
align: 'left',
};
// Import Status column in migration.harvesterhci.io.virtualmachineimport list page
export const VM_IMPORT_STATUS = {
name: 'importStatus',
labelKey: 'harvester.tableHeaders.vmImportStatus',
value: 'status.importStatus',
sort: 'status.importStatus',
align: 'left',
};
// Datacenter column in migration.harvesterhci.io.vmwaresource list page
export const VM_IMPORT_SOURCE_V_DC = {
name: 'datacenter',
labelKey: 'harvester.tableHeaders.vmImportSourceVDatacenter',
value: 'spec.dc',
sort: 'spec.dc',
align: 'left',
};
// Endpoint column in migration.harvesterhci.io.vmwaresource list page
export const VM_IMPORT_SOURCE_V_ENDPOINT = {
name: 'endpoint',
labelKey: 'harvester.tableHeaders.vmImportSourceVEndpoint',
value: 'spec.endpoint',
sort: 'spec.endpoint',
align: 'left',
};
// Cluster Status column in migration.harvesterhci.io.vmwaresource list page
export const VM_IMPORT_SOURCE_V_STATUS = {
name: 'clusterStatus',
labelKey: 'harvester.tableHeaders.vmImportSourceVClusterStatus',
value: 'status.status',
sort: 'status.status',
align: 'left',
};
// Region column in migration.harvesterhci.io.openstacksource list page
export const VM_IMPORT_SOURCE_O_REGION = {
name: 'region',
labelKey: 'harvester.tableHeaders.vmImportSourceORegion',
value: 'spec.region',
sort: 'spec.region',
align: 'left',
};
// Endpoint column in migration.harvesterhci.io.openstacksource list page
export const VM_IMPORT_SOURCE_O_ENDPOINT = {
name: 'endpoint',
labelKey: 'harvester.tableHeaders.vmImportSourceOEndpoint',
value: 'spec.endpoint',
sort: 'spec.endpoint',
align: 'left',
};
// Cluster Status column in migration.harvesterhci.io.openstacksource list page
export const VM_IMPORT_SOURCE_O_STATUS = {
name: 'clusterStatus',
labelKey: 'harvester.tableHeaders.vmImportSourceOClusterStatus',
value: 'status.status',
sort: 'status.status',
align: 'left',
};
// URL column in migration.harvesterhci.io.ovasource list page
export const VM_IMPORT_SOURCE_OVA_URL = {
name: 'url',
labelKey: 'harvester.tableHeaders.vmImportSourceOVAUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
};
// Status column in migration.harvesterhci.io.ovasource list page
export const VM_IMPORT_SOURCE_OVA_STATUS = {
name: 'status',
labelKey: 'harvester.tableHeaders.vmImportSourceOVAStatus',
value: 'status.status',
sort: 'status.status',
align: 'left',
};
// ========================================
// Forklift table headers
// ========================================
// Provider type column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_TYPE = {
name: 'providerType',
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
value: 'spec.type',
sort: 'spec.type',
align: 'left',
};
// Provider URL column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_URL = {
name: 'providerUrl',
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
};
// Source provider column in forklift network/storage map list page
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
name: 'sourceProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
value: 'spec.provider.source.name',
sort: 'spec.provider.source.name',
align: 'left',
};
// Destination provider column in forklift network/storage map list page
export const FORKLIFT_MAP_DEST_PROVIDER = {
name: 'destProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
value: 'spec.provider.destination.name',
sort: 'spec.provider.destination.name',
align: 'left',
};
// Target namespace column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_TARGET_NS = {
name: 'targetNamespace',
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
value: 'spec.targetNamespace',
sort: 'spec.targetNamespace',
align: 'left',
};
// VM count column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_VM_COUNT = {
name: 'vmCount',
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'center',
};
// Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan',
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
value: 'spec.plan.name',
sort: 'spec.plan.name',
align: 'left',
};
+23
View File
@@ -29,3 +29,26 @@ export const L2VLAN_MODE = {
ACCESS: 'access',
TRUNK: 'trunk',
};
export const VMIMPORT_SOURCE_PROVIDER = {
VMWARE: 'vmware',
OPENSTACK: 'openstack',
OVA: 'ova',
};
export const VMIMPORT_SOURCE_KINDS = {
VMWARE: 'VmwareSource',
OPENSTACK: 'OpenstackSource',
OVA: 'OvaSource',
};
export const CDI_POPULATOR_KIND = {
VOLUME_IMPORT_SOURCE: 'VolumeImportSource',
VOLUME_CLONE_SOURCE: 'VolumeCloneSource',
};
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>
@@ -75,7 +75,7 @@ export default {
<div class="row">
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.cron')"
:name="t('harvester.schedule.cron.label')"
:value="cronExpression"
/>
</div>
File diff suppressed because it is too large Load Diff
@@ -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);
+31 -16
View File
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import { Banner } from '@components/Banner';
import { Card } from '@components/Card';
import { Checkbox } from '@components/Form/Checkbox';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
import { getHarvesterUserName } from '../utils/auth';
export default {
name: 'HarvesterEnablePciPassthrough',
@@ -12,7 +15,9 @@ export default {
components: {
AsyncButton,
Banner,
Card,
Checkbox,
},
props: {
@@ -23,10 +28,16 @@ export default {
},
data() {
return {};
return { disableResourcePooling: false };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
computed: {
...mapGetters({ t: 'i18n/t' }),
disableResourcePoolingEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('disableResourcePooling');
},
},
methods: {
close() {
@@ -34,16 +45,7 @@ export default {
},
async save(buttonCb) {
// isSingleProduct == this is a standalone Harvester cluster
const isSingleProduct = this.$store.getters['isSingleProduct'];
let userName = 'admin';
// if this is imported Harvester, there may be users other than 'admin
if (!isSingleProduct) {
const user = this.$store.getters['auth/v3User'];
userName = user?.username || user?.id;
}
const userName = getHarvesterUserName(this.$store.getters);
for (let i = 0; i < this.resources.length; i++) {
const actionResource = this.resources[i];
@@ -60,9 +62,10 @@ export default {
}]
},
spec: {
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName,
disableResourcePooling: this.disableResourcePooling,
}
} );
@@ -93,7 +96,19 @@ export default {
</template>
<template #body>
{{ t('harvester.pci.enablePassthroughWarning') }}
<p class="mb-20">
{{ t('harvester.pci.enablePassthroughWarning') }}
</p>
<template v-if="disableResourcePoolingEnabled">
<Checkbox
v-model:value="disableResourcePooling"
label-key="harvester.pci.disableResourcePooling"
/>
<Banner
color="info"
:label="t('harvester.pci.disableResourcePoolingDescription')"
/>
</template>
</template>
<template #actions>
@@ -0,0 +1,133 @@
<script>
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { Checkbox } from '@components/Form/Checkbox';
import { escapeHtml } from '@shell/utils/string';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterEnableSriovGpuDevice',
emits: ['close'],
components: {
AsyncButton,
Card,
Checkbox,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return { skipMigConfiguration: this.resources[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true' };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
this.$emit('close');
},
async save(buttonCb) {
const actionResource = this.resources[0];
const prevEnabled = actionResource.spec.enabled;
const prevAnnotation = actionResource.metadata.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
try {
if (this.skipMigConfiguration) {
if (!actionResource.metadata.annotations) {
actionResource.metadata.annotations = {};
}
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = 'true';
} else {
if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
}
actionResource.spec.enabled = true;
await actionResource.save();
buttonCb(true);
this.close();
} catch (err) {
actionResource.spec.enabled = prevEnabled;
if (prevAnnotation !== undefined) {
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = prevAnnotation;
} else if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(actionResource.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.sriovgpu.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<Checkbox
v-model:value="skipMigConfiguration"
:label="t('harvester.sriovgpu.enable.skipMigConfiguration')"
/>
<p class="note mt-10">
{{ t('harvester.sriovgpu.enable.note') }}
</p>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
@click="save"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
.note {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
</style>
+2 -10
View File
@@ -4,6 +4,7 @@ import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
import { HCI } from '../types';
import { getHarvesterUserName } from '../utils/auth';
export default {
name: 'HarvesterEnableUSBPassthrough',
@@ -34,16 +35,7 @@ export default {
},
async save(buttonCb) {
// isSingleProduct == this is a standalone Harvester cluster
const isSingleProduct = this.$store.getters['isSingleProduct'];
let userName = 'admin';
// if this is imported Harvester, there may be users other than 'admin
if (!isSingleProduct) {
const user = this.$store.getters['auth/v3User'];
userName = user?.username || user?.id;
}
const userName = getHarvesterUserName(this.$store.getters);
for (let i = 0; i < this.resources.length; i++) {
const actionResource = this.resources[i];
@@ -2,13 +2,16 @@
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { mapGetters } from 'vuex';
import { PVC } from '@shell/config/types';
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI } from '@pkg/harvester/types';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Checkbox } from '@components/Form/Checkbox';
export default {
name: 'HotplugVolumeModal',
@@ -16,7 +19,7 @@ export default {
emits: ['close'],
components: {
AsyncButton, Card, LabeledInput, LabeledSelect, Banner
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
},
props: {
@@ -28,14 +31,19 @@ export default {
async fetch() {
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
this.allStorageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
this.allVMs = await this.$store.dispatch('harvester/findAll', { type: HCI.VM });
},
data() {
return {
diskName: '',
volumeName: '',
errors: [],
allPVCs: [],
diskName: '',
volumeName: '',
shareable: false,
errors: [],
allPVCs: [],
allStorageClasses: [],
allVMs: [],
};
},
@@ -50,6 +58,31 @@ export default {
return this.resources[0];
},
// claim names already attached to the VM this modal was opened for
currentVMClaimNames() {
const volumes = this.actionResource?.spec?.template?.spec?.volumes || [];
return volumes.map((vol) => vol.persistentVolumeClaim?.claimName).filter((name) => !!name);
},
// claim names attached to other VMs in the same namespace
otherVMClaimNames() {
const out = [];
this.allVMs.forEach((vm) => {
if (vm.metadata.namespace !== this.actionResource?.metadata?.namespace || vm.id === this.actionResource?.id) {
return;
}
(vm.spec?.template?.spec?.volumes || []).forEach((vol) => {
if (vol.persistentVolumeClaim?.claimName) {
out.push(vol.persistentVolumeClaim.claimName);
}
});
});
return out;
},
volumeOption() {
return sortBy(
this.PVCs
@@ -61,6 +94,14 @@ export default {
if (pvc.isGoldenImageVolume) {
return false;
}
// a volume attached to this VM can never be attached to it again
if (this.currentVMClaimNames.includes(pvc.metadata.name)) {
return false;
}
// a volume attached to another VM can only be re-attached as a shareable disk
if (this.otherVMClaimNames.includes(pvc.metadata.name) && !this.isShareableCapablePVC(pvc)) {
return false;
}
return true;
})
@@ -73,19 +114,62 @@ export default {
'label'
);
},
selectedPVC() {
return this.PVCs.find((P) => P.metadata.name === this.volumeName);
},
isShareableCapable() {
return this.selectedPVC ? this.isShareableCapablePVC(this.selectedPVC) : false;
},
},
watch: {
isShareableCapable(neu) {
if (!neu) {
this.shareable = false;
}
},
},
methods: {
isShareableCapablePVC(pvc) {
const pvcSpec = pvc?.spec;
if (!pvcSpec) {
return false;
}
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
const storageClass = this.allStorageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
// fail closed: without a resolved StorageClass the provisioner
// requirement cannot be evaluated
if (!storageClass) {
return false;
}
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
},
close() {
this.diskName = '';
this.volumeName = '';
this.shareable = false;
this.$emit('close');
},
async save(buttonCb) {
if (this.actionResource) {
try {
const res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false);
const input = { volumeSourceName: this.volumeName, diskName: this.diskName };
if (this.isShareableCapable && this.shareable) {
input.shareable = true;
}
const res = await this.actionResource.doAction('addVolume', input, {}, false);
if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', {
@@ -141,6 +225,19 @@ export default {
class="mt-20"
required
/>
<Checkbox
v-if="isShareableCapable"
v-model:value="shareable"
class="mt-20"
type="checkbox"
label-key="harvester.virtualMachine.volume.shareable.label"
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
/>
<Banner
v-if="isShareableCapable && shareable"
color="warning"
:label="t('harvester.virtualMachine.volume.shareable.warning')"
/>
<Banner
v-for="(err, i) in errors"
:key="i"
+26 -3
View File
@@ -5,6 +5,8 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default {
name: 'HarvesterBackupModal',
@@ -15,6 +17,7 @@ export default {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
@@ -27,8 +30,9 @@ export default {
data() {
return {
backUpName: '',
errors: []
backUpName: '',
errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
};
},
@@ -37,12 +41,22 @@ export default {
actionResource() {
return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
}
},
methods: {
close() {
this.backUpName = '';
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.errors = [];
this.$emit('close');
},
@@ -51,7 +65,7 @@ export default {
try {
const res = await this.actionResource.doAction(
'backup',
{ name: this.backUpName },
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
{},
false
);
@@ -100,9 +114,18 @@ export default {
<template #body>
<LabeledInput
v-model:value="backUpName"
class="mt-20"
:label="t('generic.name')"
required
/>
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.backup.fileSystemFreezeDeadline.label')"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
@@ -0,0 +1,183 @@
<script>
import { mapGetters } from 'vuex';
import { STORAGE_CLASS } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { isInternalStorageClass } from '../utils/storage-class';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'HarvesterDataMigrationDialog',
emits: ['close'],
components: {
AsyncButton, Banner, Card, LabeledInput, LabeledSelect
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
this.storageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
},
data() {
return {
targetVolumeName: '',
targetStorageClassName: '',
errors: [],
storageClasses: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0];
},
storageClassOptions() {
return sortBy(
this.storageClasses
.filter((sc) => !isInternalStorageClass(sc.metadata?.name))
.map((sc) => ({
label: sc.metadata?.name,
value: sc.metadata?.name
})),
'label'
);
},
disableSave() {
return !this.targetVolumeName || !this.targetStorageClassName;
},
},
methods: {
close() {
this.targetVolumeName = '';
this.targetStorageClassName = '';
this.errors = [];
this.$emit('close');
},
async apply(buttonDone) {
if (!this.actionResource) {
buttonDone(false);
return;
}
if (!this.targetVolumeName) {
const name = this.t('harvester.modal.dataMigration.fields.targetVolumeName.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
if (!this.targetStorageClassName) {
const name = this.t('harvester.modal.dataMigration.fields.targetStorageClassName.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
try {
await this.actionResource.doAction('dataMigration', {
targetVolumeName: this.targetVolumeName,
targetStorageClassName: this.targetStorageClassName
}, {}, false);
buttonDone(true);
this.close();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
},
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.dataMigration.title') }}
</template>
<template #body>
<LabeledInput
v-model:value="targetVolumeName"
:label="t('harvester.modal.dataMigration.fields.targetVolumeName.label')"
:placeholder="t('harvester.modal.dataMigration.fields.targetVolumeName.placeholder')"
class="mb-20"
required
/>
<LabeledSelect
v-model:value="targetStorageClassName"
:label="t('harvester.modal.dataMigration.fields.targetStorageClassName.label')"
:placeholder="t('harvester.modal.dataMigration.fields.targetStorageClassName.placeholder')"
:options="storageClassOptions"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</template>
<template
#actions
class="actions"
>
<div class="buttons">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="disableSave"
@click="apply"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -0,0 +1,154 @@
<script>
import merge from 'lodash/merge';
import jsyaml from 'js-yaml';
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import { LabeledInput } from '@components/Form/LabeledInput';
import AsyncButton from '@shell/components/AsyncButton';
import { escapeHtml } from '@shell/utils/string';
const DEFAULT_VALUE = { image: { repository: 'rancher/harvester-nvidia-driver-toolkit' } };
export default {
name: 'HarvesterEnableNvidiaDriverToolkit',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
const addon = this.resources[0];
let valuesContentJson;
try {
valuesContentJson = merge({}, DEFAULT_VALUE, jsyaml.load(addon.spec.valuesContent));
} catch (e) {
valuesContentJson = { ...DEFAULT_VALUE };
}
return { valuesContentJson };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
buttonDisabled() {
const { image, driverLocation } = this.valuesContentJson;
return !(image?.repository || '').trim() || !(image?.tag || '').trim() || !(driverLocation || '').trim();
}
},
methods: {
close() {
this.$emit('close');
},
async enable(buttonCb) {
const addon = this.resources[0];
try {
addon.spec.valuesContent = jsyaml.dump(this.valuesContentJson);
addon.spec.enabled = true;
await addon.save();
buttonCb(true);
this.close();
} catch (err) {
addon.spec.enabled = false;
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(addon.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.addons.nvidiaDriverToolkit.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<div class="row mb-15">
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.image.repository"
:required="true"
:label="t('harvester.addons.nvidiaDriverToolkit.image.repository')"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.image.tag"
:required="true"
:label="t('harvester.addons.nvidiaDriverToolkit.image.tag')"
/>
</div>
</div>
<div class="row mb-15">
<div class="col span-12">
<LabeledInput
v-model:value="valuesContentJson.driverLocation"
:required="true"
:label="t('harvester.addons.nvidiaDriverToolkit.driver.location')"
/>
</div>
</div>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
:disabled="buttonDisabled"
@click="enable"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.body {
display: flex;
flex-direction: column;
min-width: 400px;
}
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
+30 -6
View File
@@ -5,6 +5,10 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
const VOLUME = 'volume';
const NETWORK = 'network';
const CDROM = 'cdrom';
export default {
name: 'HarvesterHotUnplug',
@@ -40,19 +44,37 @@ export default {
},
isVolume() {
return this.modalData.type === 'volume';
return this.modalData.type === VOLUME;
},
titleKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.title' : 'harvester.virtualMachine.hotUnplug.detachNIC.title';
const keys = {
[VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.title',
[CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.title',
[NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.title',
};
return keys[this.modalData.type];
},
actionLabelKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel' : 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabel';
const keys = {
[VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabels',
[CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.actionLabels',
[NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabels',
};
return keys[this.modalData.type];
},
successMessageKey() {
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.success' : 'harvester.virtualMachine.hotUnplug.detachNIC.success';
const keys = {
[VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.success',
[CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.success',
[NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.success',
};
return keys[this.modalData.type];
}
},
@@ -65,10 +87,12 @@ export default {
try {
let res;
if (this.isVolume) {
if (this.modalData.type === VOLUME) {
res = await this.actionResource.doAction('removeVolume', { diskName: this.name });
} else {
} else if (this.modalData.type === NETWORK) {
res = await this.actionResource.doAction('removeNic', { interfaceName: this.name });
} else {
res = await this.actionResource.doAction('ejectCdRomVolume', { deviceName: this.name });
}
if (res._status === 200 || res._status === 204) {
@@ -0,0 +1,198 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapState, mapGetters } from 'vuex';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { HCI } from '../types';
export default {
name: 'HarvesterInsertCdRomVolume',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
try {
this.images = await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE });
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.images = [];
}
},
data() {
return {
imageName: '',
images: [],
errors: [],
};
},
computed: {
...mapState('action-menu', ['modalData']),
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources?.[0];
},
isFormValid() {
return this.imageName !== '';
},
deviceName() {
return this.modalData.name;
},
imagesOption() {
return this.images
.filter((image) => {
const labels = image.metadata?.labels || {};
const type = labels[HCI_ANNOTATIONS.IMAGE_SUFFIX];
return type === 'iso';
})
.map((image) => {
return ({
label: this.imageOptionLabel(image),
value: image.id,
disabled: image.isImportedImage
});
});
}
},
methods: {
close() {
this.imageName = '';
this.errors = [];
this.$emit('close');
},
imageOptionLabel(image) {
return `${ image.metadata.namespace }/${ image.spec.displayName }`;
},
async save(buttonCb) {
if (!this.actionResource) {
buttonCb(false);
return;
}
const payload = {
deviceName: this.deviceName,
imageName: this.imageName
};
try {
const res = await this.actionResource.doAction('insertCdRomVolume', payload);
if ([200, 204].includes(res?._status)) {
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.insertCdRomVolume.success', {
deviceName: this.deviceName,
imageName: this.imageName,
})
}, { root: true });
this.close();
buttonCb(true);
} else {
this.errors = exceptionToErrorsArray(res);
buttonCb(false);
}
} catch (err) {
this.errors = exceptionToErrorsArray(err);
buttonCb(false);
}
}
}
};
</script>
<template>
<Card
ref="modal"
name="modal"
:show-highlight-border="false"
>
<template #title>
<h4
v-clean-html="t('harvester.modal.insertCdRomVolume.title')"
class="text-default-text"
/>
</template>
<template #body>
<LabeledInput
v-model:value="deviceName"
:label="t('generic.name')"
disabled
/>
<LabeledSelect
v-model:value="imageName"
class="mt-20"
:label="t('harvester.modal.insertCdRomVolume.image')"
:options="imagesOption"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
:label="err"
color="error"
/>
</template>
<template #actions>
<div class="actions">
<div class="buttons">
<button
type="button"
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="!isFormValid"
@click="save"
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -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,239 @@
<script>
import { mapGetters } from 'vuex';
import { PVC } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { HCI } from '../types';
import { parseVolumeClaimTemplates } from '@pkg/harvester/utils/vm';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'HarvesterStorageMigrationDialog',
emits: ['close'],
components: {
AsyncButton, Banner, Card, LabeledSelect
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
},
data() {
return {
sourceVolume: '',
targetVolume: '',
errors: [],
allPVCs: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0];
},
sourceVolumeOptions() {
const volumes = this.actionResource.spec?.template?.spec?.volumes || [];
return sortBy(
volumes
.map((v) => v.persistentVolumeClaim?.claimName)
.filter((name) => !!name)
.map((name) => ({
label: name,
value: name
})),
'label'
);
},
namespacePVCs() {
return this.allPVCs.filter((pvc) => pvc.metadata.namespace === this.actionResource.metadata.namespace);
},
vmUsedVolumeNames() {
const allVMs = this.$store.getters['harvester/all'](HCI.VM) || [];
const names = new Set();
allVMs.forEach((vm) => {
// Collect volume names from spec.template.spec.volumes (both PVC and DataVolume references)
const volumes = vm.spec?.template?.spec?.volumes || [];
volumes.forEach((v) => {
const name = v.persistentVolumeClaim?.claimName || v.dataVolume?.name;
if (name) {
names.add(`${ vm.metadata.namespace }/${ name }`);
}
});
// Collect volume names from volumeClaimTemplates annotation
const templates = parseVolumeClaimTemplates(vm);
templates.forEach((t) => {
if (t.metadata?.name) {
names.add(`${ vm.metadata.namespace }/${ t.metadata.name }`);
}
});
});
return names;
},
targetVolumeOptions() {
return sortBy(
this.namespacePVCs
.filter((pvc) => {
// Exclude volumes used by any VM (via spec.volumes or volumeClaimTemplates)
if (this.vmUsedVolumeNames.has(`${ pvc.metadata.namespace }/${ pvc.metadata.name }`)) {
return false;
}
return true;
})
.map((pvc) => ({
label: pvc.metadata.name,
value: pvc.metadata.name
})),
'label'
);
},
disableSave() {
return !this.sourceVolume || !this.targetVolume;
},
},
methods: {
close() {
this.sourceVolume = '';
this.targetVolume = '';
this.errors = [];
this.$emit('close');
},
async apply(buttonDone) {
if (!this.actionResource) {
buttonDone(false);
return;
}
if (!this.sourceVolume) {
const name = this.t('harvester.modal.storageMigration.fields.sourceVolume.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
if (!this.targetVolume) {
const name = this.t('harvester.modal.storageMigration.fields.targetVolume.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
try {
await this.actionResource.doAction('storageMigration', {
sourceVolume: this.sourceVolume,
targetVolume: this.targetVolume
}, {}, false);
buttonDone(true);
this.close();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
},
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.storageMigration.title') }}
</template>
<template #body>
<LabeledSelect
v-model:value="sourceVolume"
:label="t('harvester.modal.storageMigration.fields.sourceVolume.label')"
:placeholder="t('harvester.modal.storageMigration.fields.sourceVolume.placeholder')"
:options="sourceVolumeOptions"
class="mb-20"
required
/>
<LabeledSelect
v-model:value="targetVolume"
:label="t('harvester.modal.storageMigration.fields.targetVolume.label')"
:placeholder="t('harvester.modal.storageMigration.fields.targetVolume.placeholder')"
:options="targetVolumeOptions"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</template>
<template
#actions
class="actions"
>
<div class="buttons">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="disableSave"
@click="apply"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>
@@ -277,8 +277,9 @@ export default {
:label="t('harvester.modal.bundle.namespaces.label')"
:clearable="true"
:multiple="true"
:append-to-body="false"
:options="namespaceOptions"
class="mb-10 label-select"
class="mb-10 namespace-select"
:tooltip="t('harvester.modal.bundle.namespaces.tooltip', _, true)"
@update:value="updateNamespaces"
/>
@@ -372,6 +373,11 @@ export default {
padding: 10px 0;
height: 160px;
}
.namespace-select {
:deep(.vs__dropdown-menu) {
max-height: 210px;
}
}
}
div {
@@ -5,8 +5,10 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI } from '../types';
import { BACKUP_TYPE } from '../config/types';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default {
name: 'HarvesterVMSnapshotDialog',
@@ -17,6 +19,7 @@ export default {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
@@ -31,7 +34,8 @@ export default {
return {
snapshotName: '',
snapshotNamespace: '',
errors: []
errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
};
},
@@ -40,13 +44,23 @@ export default {
actionResource() {
return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
}
},
methods: {
close() {
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.snapshotNamespace = '';
this.snapshotName = '';
this.errors = [];
this.$emit('close');
},
@@ -60,7 +74,8 @@ export default {
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
},
spec: {
source: {
fsFreezeDeadline: this.fsFreezeDeadline,
source: {
apiGroup: 'kubevirt.io',
kind: 'VirtualMachine',
name: this.actionResource.metadata.name
@@ -132,6 +147,14 @@ export default {
:label="t('generic.name')"
required
/>
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.vmSnapshot.fileSystemFreezeDeadline.label')"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
@@ -0,0 +1,24 @@
<script>
import { h } from 'vue';
import { PRODUCT_NAME } from '../config/harvester';
export default {
name: 'ForkliftProviderEdit',
created() {
const { namespace, id } = this.$route.params;
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
this.$router.replace({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$route.params.product,
cluster: this.$route.params.cluster,
},
query,
});
},
render: () => h('div'),
};
</script>
@@ -108,6 +108,7 @@ export default {
<YamlEditor
ref="yamlUser"
v-model:value="config"
:mode="mode"
class="yaml-editor"
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
@onChanges="update"
@@ -71,21 +71,38 @@ export default {
},
data() {
let provisioner = `${ this.value.provisioner || LONGHORN_DRIVER }`;
if (provisioner === LONGHORN_DRIVER) {
provisioner = `${ provisioner }_${ this.value.provisionerVersion || LONGHORN_VERSION_V1 }`;
}
return {
provisioner,
volumeGroupDialog: null,
randomStr: randomStr(10).toLowerCase(),
isOpen: false
isOpen: false,
};
},
computed: {
provisioner: {
get() {
let provisioner = `${ this.value?.provisioner || LONGHORN_DRIVER }`;
if (provisioner === LONGHORN_DRIVER) {
provisioner = `${ provisioner }_${ this.value?.provisionerVersion || LONGHORN_VERSION_V1 }`;
}
return provisioner;
},
set(value) {
this.randomStr = randomStr(10).toLowerCase();
const [provisioner, provisionerVersion] = (value || '').split('_');
this.value.provisioner = provisioner;
if (provisioner === LONGHORN_DRIVER) {
this.value.provisionerVersion = provisionerVersion || LONGHORN_VERSION_V1;
} else {
this.value.provisionerVersion = undefined;
}
},
},
provisioners() {
const out = [];
@@ -283,20 +300,6 @@ export default {
},
watch: {
provisioner(value) {
this.randomStr = randomStr(10).toLowerCase();
const [provisioner, provisionerVersion] = value?.split('_');
this.value.provisioner = provisioner;
if (provisioner === LONGHORN_DRIVER) {
this.value.provisionerVersion = provisionerVersion || LONGHORN_VERSION_V1;
} else {
this.value.provisionerVersion = undefined;
}
},
'value.lvmVolumeGroup'(neu) {
if (neu === _NEW) {
this.value.lvmVolumeGroup = null;
@@ -468,10 +468,15 @@ export default {
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
const parentDevice = d.status?.deviceStatus?.parentDevice;
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
const mountPoint = d.status?.deviceStatus?.fileSystem?.mountPoint;
const name = d.displayName;
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
if (mountPoint) {
label = `${ label } - ${ this.t('harvester.host.disk.mounted') }`;
}
if (parentDevice) {
label = `- ${ label }`;
}
@@ -481,7 +486,7 @@ export default {
value: d.id,
action: this.addDisk,
kind: !parentDevice ? 'group' : '',
disabled: !!isChildAdded,
disabled: !!isChildAdded || !!mountPoint,
group: parentDevice || devPath,
isParent: !!parentDevice,
};
+10 -2
View File
@@ -8,6 +8,7 @@ import FileSelector, { createOnSelected } from '@shell/components/form/FileSelec
import { randomStr } from '@shell/utils/string';
import CreateEditView from '@shell/mixins/create-edit-view';
import { getLoginAwareErrors } from '../utils/error';
export default {
name: 'HarvesterEditKeypair',
@@ -63,6 +64,14 @@ export default {
}
},
computed: {
normalizedErrors() {
const message = this.t('harvester.virtualMachine.genericLoginError');
return getLoginAwareErrors(this.errors, message);
}
},
methods: { onKeySelected: createOnSelected('publicKey') },
};
</script>
@@ -72,10 +81,9 @@ export default {
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:errors="normalizedErrors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<div class="header mb-20">
<FileSelector
@@ -9,6 +9,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { NAMESPACE, NODE } from '@shell/config/types';
import { HCI } from '../types';
import { NETWORK_TYPE, L2VLAN_MODE } from '../config/types';
import { removeObject } from '@shell/utils/array';
@@ -20,6 +21,7 @@ const { ACCESS, TRUNK } = L2VLAN_MODE;
const AUTO = 'auto';
const MANUAL = 'manual';
const KUBE_SYSTEM = 'kube-system';
export default {
emits: ['update:value'],
@@ -70,7 +72,12 @@ export default {
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }) });
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
});
},
created() {
@@ -199,6 +206,80 @@ export default {
}
return this.type === UNTAGGED;
},
showNicsTab() {
return this.isOverlayNetwork && this.value.metadata.namespace === KUBE_SYSTEM;
},
namespaceOptions() {
const ns = this.$store.getters['harvester/all'](NAMESPACE) || [];
// Allow users to select the "kube-system" namespace as the external subnet from Kube-OVN.
// This expects the provider network to be in the "kube-system" namespace for VPC NAT gateway functionality.
return ns
.filter((ns) => !ns.isSystem || ns.id === KUBE_SYSTEM)
.map((ns) => ({ name: ns.id }))
.sort((a, b) => a.name.localeCompare(b.name));
},
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.filter((n) => n.isEtcd !== 'true');
},
nics() {
const inStore = this.$store.getters['currentProduct'].inStore;
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
const linkStatus = linkMonitor?.status?.linkStatus || {};
const nodes = this.nodes.map((n) => n.id);
const out = [];
// Collect all nics from all nodes (for overlay, we select from all nodes)
Object.keys(linkStatus).map((nodeName) => {
if (nodes.includes(nodeName)) {
const nics = linkStatus[nodeName] || [];
nics.map((nic) => {
out.push({
...nic,
nodeName,
});
});
}
});
return out;
},
nicOptions() {
const out = [];
const seen = new Set();
(this.nics || []).forEach((nic) => {
if (!seen.has(nic.name)) {
seen.add(nic.name);
out.push({
label: nic.name,
value: nic.name,
});
}
});
return out.sort((a, b) => a.label.localeCompare(b.label));
},
master: {
get() {
return this.config?.master || '';
},
set(value) {
this.config.master = value;
}
}
},
@@ -214,6 +295,7 @@ export default {
this.config.ipam = {};
this.config.bridge = '';
delete this.config.provider;
delete this.config.master;
delete this.config.server_socket;
}
},
@@ -230,6 +312,13 @@ export default {
this.config.vlan = '';
}
},
'value.metadata.namespace'(newNamespace) {
// NIC selection is only valid for overlay in kube-system namespace.
if (newNamespace !== KUBE_SYSTEM) {
delete this.config.master;
this.value.spec.config = JSON.stringify({ ...this.config });
}
},
},
methods: {
@@ -324,6 +413,10 @@ export default {
delete this.config.promiscMode;
delete this.config.vlan;
delete this.config.ipam;
if (this.value.metadata.namespace !== KUBE_SYSTEM) {
delete this.config.master;
}
}
if (this.isUntaggedNetwork) {
@@ -350,6 +443,7 @@ export default {
ref="nd"
:value="value"
:mode="mode"
:namespace-options="namespaceOptions"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
@@ -521,6 +615,25 @@ export default {
</div>
</div>
</Tab>
<Tab
v-if="showNicsTab"
name="nics"
:label="t('harvester.network.tabs.nic')"
:weight="97"
class="bordered-table"
>
<div class="row mt-10">
<div class="col span-12">
<LabeledSelect
v-model:value="master"
:label="t('harvester.vlanConfig.uplink.nics.label')"
:placeholder="t('harvester.vlanConfig.uplink.nics.overlayWarning')"
:mode="mode"
:options="nicOptions"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>
@@ -1,4 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { RadioGroup } from '@components/Form/Radio';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
@@ -16,6 +17,7 @@ import { sortBy } from '@shell/utils/sort';
import { BACKUP_TYPE } from '../config/types';
import { _EDIT, _CREATE } from '@shell/config/query-params';
import { isBackupTargetSettingEmpty, isBackupTargetSettingUnavailable } from '../utils/setting';
import CronExpressionEditorModal from '@shell/components/Cron/CronExpressionEditorModal.vue';
export default {
name: 'CreateVMSchedule',
@@ -28,6 +30,7 @@ export default {
LabeledSelect,
MessageLink,
Banner,
CronExpressionEditorModal
},
mixins: [CreateEditView],
@@ -86,10 +89,12 @@ export default {
}
}
return { settings: [] };
return { settings: [], showModel: false };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
backupTargetResource() {
return this.settings.find( (O) => O.id === 'backup-target');
},
@@ -172,6 +177,9 @@ export default {
this.value.spec['maxFailure'] = this.value.spec.retain;
}
},
openModal() {
this.showModel = true;
},
},
};
</script>
@@ -256,16 +264,28 @@ export default {
:weight="99"
class="bordered-table"
>
<LabeledInput
v-model:value="value.spec.cron"
class="mb-30"
type="cron"
required
:mode="mode"
:label="t('harvester.schedule.cron')"
placeholder="0 * * * *"
:disabled="isBackupTargetUnAvailable || isView"
/>
<div class="cronEditor">
<LabeledInput
v-model:value="value.spec.cron"
class="mb-30"
type="cron"
required
:mode="mode"
:label="t('harvester.schedule.cron.label')"
placeholder="0 * * * *"
:disabled="isBackupTargetUnAvailable || isView"
/>
<button
class="editCronBtn btn role-primary"
@click="openModal"
>
{{ t('harvester.schedule.cron.editButton') }}
</button>
<CronExpressionEditorModal
v-model:show="showModel"
v-model:cron-expression="value.spec.cron"
/>
</div>
<LabeledInput
v-model:value.number="value.spec.retain"
class="mb-30"
@@ -292,3 +312,16 @@ export default {
</Tabbed>
</CruResource>
</template>
<style lang="scss" scoped>
.cronEditor {
align-items: center;
display: flex;
}
.editCronBtn {
margin-bottom: 30px;
margin-left: 10px;
height: 60px;
}
</style>
@@ -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
@@ -6,6 +6,7 @@ import { Checkbox } from '@components/Form/Checkbox';
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { set } from '@shell/utils/object';
import { Banner } from '@components/Banner';
import KeyValue from '@shell/components/form/KeyValue';
import NodeScheduling from '@shell/components/form/NodeScheduling';
@@ -22,8 +23,10 @@ import Reserved from './kubevirt.io.virtualmachine/VirtualMachineReserved';
import Volume from './kubevirt.io.virtualmachine/VirtualMachineVolume';
import Network from './kubevirt.io.virtualmachine/VirtualMachineNetwork';
import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory';
import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel';
import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig';
import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey';
import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem';
export default {
name: 'HarvesterEditVMTemplate',
@@ -38,6 +41,7 @@ export default {
Network,
Checkbox,
CpuMemory,
CpuModel,
CruResource,
CloudConfig,
LabeledSelect,
@@ -48,6 +52,7 @@ export default {
UnitInput,
Banner,
KeyValue,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
@@ -70,12 +75,12 @@ export default {
return {
templateId,
templateValue: null,
templateSpec: null,
versionName: '',
description: '',
defaultVersion: null,
isDefaultVersion: false,
templateValue: null,
templateSpec: null,
versionName: '',
description: '',
defaultVersion: null,
isDefaultVersion: false,
};
},
@@ -92,6 +97,10 @@ export default {
secretNamePrefix() {
return this.templateValue?.metadata?.name;
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@@ -151,9 +160,22 @@ export default {
mounted() {
this.imageId = this.diskRows[0]?.image || '';
this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm);
},
methods: {
updateCpuModel(value) {
if (!this.spec?.template?.spec?.domain?.cpu) {
set(this.spec, 'template.spec.domain.cpu', {});
}
if (value && value !== '') {
set(this.spec.template.spec.domain.cpu, 'model', value);
} else {
delete this.spec.template.spec.domain.cpu.model;
}
},
async saveVMT(buttonCb) {
this.parseVM();
@@ -334,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')"
@@ -436,6 +471,17 @@ export default {
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<CpuModel
:value="spec.template.spec.domain.cpu?.model || ''"
:mode="mode"
@update:value="updateCpuModel"
/>
</div>
</div>
<div class="row mb-20">
<a
v-if="showAdvanced"
@@ -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
+135 -17
View File
@@ -9,8 +9,11 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions';
import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import jsyaml from 'js-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { allHash } from '@shell/utils/promise';
import { get } from '@shell/utils/object';
import { clone, get } from '@shell/utils/object';
import { STORAGE_CLASS, LONGHORN, PV } from '@shell/config/types';
import { sortBy } from '@shell/utils/sort';
import { saferDump } from '@shell/utils/create-yaml';
@@ -33,6 +36,7 @@ export default {
components: {
Banner,
Checkbox,
Tab,
UnitInput,
CruResource,
@@ -90,14 +94,34 @@ export default {
source,
storage,
imageId,
snapshots: [],
images: [],
showAdvanced: false,
createWithDataVolume: false,
snapshots: [],
images: [],
GIBIBYTE
};
},
created() {
this.registerBeforeHook(this.willSave, 'willSave');
if (this.mode === _CREATE) {
const originalSaveYaml = this.value.saveYaml?.bind(this.value);
this.value.saveYaml = async(yaml) => {
if (this.createWithDataVolume && this.isBlank) {
const parsed = jsyaml.load(yaml);
const dvObj = { ...parsed, type: 'cdi.kubevirt.io.datavolume' };
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
await dataVolume.save();
return dataVolume;
}
return originalSaveYaml(yaml);
};
}
},
computed: {
@@ -135,6 +159,10 @@ export default {
return Object.values(VOLUME_MODE);
},
accessModeOptions() {
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
},
imageOption() {
return sortBy(
this.images
@@ -275,6 +303,10 @@ export default {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
},
isCreatePVCWithDataVolumeFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
},
isResizeDisabled() {
return (
!this.isLHV2VolExpansionFeatureEnabled &&
@@ -341,6 +373,58 @@ export default {
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
},
buildDataVolumeObj() {
const storage = {
storageClassName: this.value.spec.storageClassName,
resources: { requests: { storage: this.storage } },
};
if (this.showAdvanced && this.value.spec.accessModes?.length > 0) {
storage.accessModes = this.value.spec.accessModes;
}
if (this.showAdvanced && this.value.spec.volumeMode) {
storage.volumeMode = this.value.spec.volumeMode;
}
return {
type: 'cdi.kubevirt.io.datavolume',
apiVersion: 'cdi.kubevirt.io/v1beta1',
kind: 'DataVolume',
metadata: {
name: this.value.metadata.name,
namespace: this.value.metadata.namespace,
annotations: this.value.metadata.annotations || {},
labels: this.value.metadata.labels || {},
},
spec: {
source: { blank: {} },
storage,
}
};
},
async save(buttonDone) {
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
try {
this.update();
const dvObj = this.buildDataVolumeObj();
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
await dataVolume.save();
buttonDone(true);
this.done();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
} else {
await CreateEditView.methods.save.call(this, buttonDone);
}
},
willSave() {
this.update();
},
@@ -383,9 +467,17 @@ export default {
this.update();
},
generateYaml() {
const out = saferDump(this.value);
this.update();
return out;
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
return saferDump(this.buildDataVolumeObj());
}
const plain = clone(this.value);
delete plain.saveYaml;
return saferDump(plain);
},
}
};
@@ -458,18 +550,6 @@ export default {
@update:value="update"
/>
<LabeledSelect
v-if="showVolumeMode"
v-model:value="value.spec.volumeMode"
:label="t('harvester.volume.volumeMode')"
:options="volumeModeOptions"
required
:disabled="!isCreate"
:mode="mode"
class="mb-20"
@update:value="update"
/>
<UnitInput
v-model:value="storage"
:label="t('harvester.volume.size')"
@@ -490,6 +570,44 @@ export default {
>
<span>{{ t('harvester.volume.longhorn.disableResize') }}</span>
</Banner>
<div class="row mb-20">
<Checkbox
v-if="isCreate && isBlank && isCreatePVCWithDataVolumeFeatureEnabled"
v-model:value="createWithDataVolume"
:label="t('harvester.volume.createWithDataVolume')"
tooltip-key="harvester.volume.createWithDataVolumeTooltip"
/>
</div>
<a
v-if="isCreate && isCreatePVCWithDataVolumeFeatureEnabled"
role="button"
class="hand"
@click="showAdvanced = !showAdvanced"
>
{{ showAdvanced ? t('harvester.volume.hideAdvanced') : t('harvester.volume.showAdvanced') }}
</a>
<LabeledSelect
v-if="showAdvanced"
v-model:value="value.spec.accessModes"
:label="t('harvester.volume.accessModes')"
:options="accessModeOptions"
:multiple="true"
:mode="mode"
class="mb-20 mt-20"
@update:value="update"
/>
<LabeledSelect
v-if="showAdvanced"
v-model:value="value.spec.volumeMode"
:label="t('harvester.volume.volumeMode')"
:options="volumeModeOptions"
:mode="mode"
class="mb-20"
@update:value="update"
/>
</Tab>
<Tab
v-if="!isCreate"
@@ -0,0 +1,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>

Some files were not shown because too many files have changed in this diff Show More