Compare commits

..

79 Commits
main ... v1.7.3

Author SHA1 Message Date
renovate[bot]
9be1d68a63
deps: update dependency @types/lodash to v4.17.25 (#1101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 10:11:46 +08:00
Harvester Bot
d0d318af7f
chore: bump version to 1.7.3 (#1097)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-07 11:42:22 +08:00
renovate[bot]
4898a1094f
deps: update patch dependencies (#1077)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 16:46:40 +08:00
Harvester Bot
118edd67ae
chore: bump version to 1.7.3-rc1 (#1047)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-22 13:10:57 +08:00
mergify[bot]
b12a8e41a5
ci: update build-extension-charts workflow reference (backport #967) (#981)
* feat: security update (#967)

(cherry picked from commit 479c298f4a182ba4b7549f5fba68be79ebb92f67)

# Conflicts:
#	.github/workflows/build-extension-on-release.yml

* fix: conflict

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Alexandre Alves <alexandre.alves@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-09 18:13:58 +08:00
Harvester Bot
11ff73704b
chore: bump version to 1.7.2 (#972)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-07-07 11:16:35 +08:00
renovate[bot]
47b2b638cb
deps: update dependency qs to v6.15.3 (#969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 11:25:07 +08:00
renovate[bot]
b224604389
deps: update patch dependencies (#947)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 12:19:44 +08:00
Harvester Bot
95f35c633c
chore: bump version to 1.7.2-rc3 (#944)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-06-26 16:11:02 +08:00
renovate[bot]
981287af0a
deps: update patch dependencies to v7.8.4 (#934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 16:12:22 +08:00
Harvester Bot
94be1c1c09
chore: bump version to 1.7.2-rc2 (#930)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-06-17 18:04:41 +08:00
renovate[bot]
09469c67e2
deps: update patch dependencies (#923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 15:18:02 +08:00
renovate[bot]
b12d2b5df2
deps: update minor dependencies to v7.29.7 (#916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 15:58:17 +08:00
renovate[bot]
887a0d0517
deps: update dependency semver to v7.8.1 (#895)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 17:15:31 +08:00
renovate[bot]
73e91d19a4
deps: update patch dependencies (#885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 18:56:26 +08:00
renovate[bot]
bee30483ad
deps: update dependency @types/node to v20.19.40 (#872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:52:27 +08:00
renovate[bot]
71dad21796
deps: update dependency semver to v7.8.0 (#873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:39:05 +08:00
renovate[bot]
5704240ac5
deps: update dependency eslint-plugin-promise to v7.3.0 (#860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:17:16 +08:00
renovate[bot]
085d216a30
deps: update dependency yaml to v2.8.4 (#859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-13 15:58:23 +08:00
Harvester Bot
ff692affb0
chore: bump version to 1.7.2-rc1 (#856)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-05-07 00:14:54 +08:00
mergify[bot]
cfb3392e2e
feat: add Insecure Skip TLS Verify checkbox in cluster-registration-url setting (backport #716) (#838)
* 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>
(cherry picked from commit 62b80b3cec62c608d51b98e7a91a0146a91e9791)

# Conflicts:
#	pkg/harvester/config/feature-flags.js

* chore: resolve backport merge conflict

Signed-off-by: Ivan Sim <ivan.sim@suse.com>

---------

Signed-off-by: Ivan Sim <ivan.sim@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Ivan Sim <ivan.sim@suse.com>
2026-04-29 12:12:35 +08:00
renovate[bot]
fe42708361
deps: update patch dependencies (#831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 11:58:53 +08:00
renovate[bot]
62df6f5cbb
deps: update minor dependencies (#832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 11:55:09 +08:00
renovate[bot]
2da7d0eb58
deps: update fossas/fossa-action action to v1.9.0 (#806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 14:14:02 +08:00
mergify[bot]
251d92afba
fix: the rancher-eio/read-vault-secret sha (#786) (#803)
(cherry picked from commit 4ce35ce075e7a1fd77fd82e9f20e932c05565216)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-07 16:13:15 +08:00
mergify[bot]
7f3865343d
ci: add add_minrelaseday to delay dep update (#798) (#799)
(cherry picked from commit 6dd9b333365f172bf7a9993a9e3a82c9d49b27ad)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-07 16:08:59 +08:00
mergify[bot]
8f7a153665
ci: remove the single quota for use commit hash (#767) (#802)
(cherry picked from commit 8083a41df04ed5c82346cf3798dd85eedcb1e13a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-04-07 16:05:23 +08:00
mergify[bot]
2a376933f2
chore: pin GH Actions to commit sha (#765) (#801)
(cherry picked from commit 62801b3b1371a221f0c485abe50f22b005155fe7)

# Conflicts:
#	.github/workflows/fossa.yml

Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-04-07 16:02:32 +08:00
renovate[bot]
e5c253f044
deps: update dependency yaml to v2.8.3 (#756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-22 13:05:42 +00:00
renovate[bot]
f0a9fe76a1
deps: update dependency @types/node to v20.19.37 (#728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 06:15:00 +00:00
mergify[bot]
a0a797d5b9
fix: missing Download Logs button in Rancher integration mode (#715) (#722)
* fix: missing Download Logs button in Rancher integration mode



* refactor: add comment



---------


(cherry picked from commit de103ff91e71be1670f80bf24f9873bd7330c040)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-03-03 00:28:25 +08:00
renovate[bot]
1cc6f34d84
deps: update patch digest dependencies (#720)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 21:07:22 +00:00
renovate[bot]
ebfc4e8369
deps: update dependency qs to v6.15.0 (#711)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 12:04:34 +08:00
renovate[bot]
bd647738ae
deps: update patch digest dependencies (#706)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 09:11:33 +00:00
Andy Lee
c6fb969d7e
chore: update version v1.7.1 (#704)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-02-06 14:48:07 +08:00
Andy Lee
2afb04947d
chore: update v1.7.1-rc4 version
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-02-03 16:42:06 +08:00
Andy Lee
730c68bf14
chore: add v1.7.1 feature flag (#701)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-30 15:11:08 +08:00
freeze
20bee39a6c
chore: bump to v1.7.1-rc3 (#699)
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
2026-01-29 01:10:39 +08:00
Andy Lee
dbb199d7bb
chore: bump to v1.7.1-rc2
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-27 22:37:48 +08:00
mergify[bot]
5488979448
fix: use longhorn-static for upgrade vmimage (#690) (#692)
(cherry picked from commit 0647600e88b59ccdf6a8d8f78d88b972cd604185)

Signed-off-by: Cooper Tseng <cooper.tseng@suse.com>
2026-01-22 09:51:01 +08:00
mergify[bot]
9e588e90c2
fix: remove isCordoned condition (#689) (#691)
(cherry picked from commit 99dbba7958c5bffb38aa68a3c6f6f0f44706ebae)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-01-21 17:43:08 +08:00
Andy Lee
9378277102
chore: change to v1.7.1-rc1 version
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-01-20 17:34:15 +08:00
mergify[bot]
b5e78018a5
fix: use file as field name instead of chunk in cdi vmimage upload (#684) (#688)
(cherry picked from commit 915559962a91802789750cec7549b61baea096e7)

Signed-off-by: Cooper Tseng <cooper.tseng@suse.com>
Co-authored-by: Kuan-Po Tseng <brandboat@gmail.com>
2026-01-20 11:00:50 +08:00
renovate[bot]
f411a0c0af
deps: update patch digest dependencies (#687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 08:48:10 +00:00
mergify[bot]
cfa58985cf
fix: do not set cpu.maxSockets on UI (#674) (#685)
* fix: do not set cpu.maxSockets for ARM clusters



* fix: remove maxSocket to fix bug on ARM cluster



---------


(cherry picked from commit b1b1a31c04a2f0b20fdfee42f987c694614617bf)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-01-16 17:15:34 +08:00
renovate[bot]
66a8f9d0e7
deps: update patch digest dependencies (#680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-11 08:53:44 +00:00
freeze
bf61c7dd7d
chore: bump version to v1.7.1-dev (#673)
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
2026-01-04 23:21:46 +08:00
mergify[bot]
56d97260c4
fix: drop mac-address annotation from vm template to prevent MAC address reusing (#663) (#669)
related-to: harvester/harvester#9789

(cherry picked from commit 1352246e1efdab691b7ccba9e1d02da01b6844a1)

Signed-off-by: Tim Liou <tim.liou@suse.com>
Co-authored-by: Tim Liou <tim.liou@suse.com>
2026-01-02 18:13:35 +08:00
mergify[bot]
beabb34920
docs: add README.md in pkg/harvester (#661) (#662)
(cherry picked from commit fe3a12e28ca6b28193b18e665af151078ce46499)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-01-02 16:18:06 +08:00
renovate[bot]
d2609157bd
deps: update dependency qs to v6.14.1 [security] (#668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-02 00:42:31 +00:00
mergify[bot]
8fbe1943d8
chore: bump version to v1.7.0 (#658) (#659)
(cherry picked from commit a86302c9d5f2c1e065fb2cfe9cfd8d927bdc239b)

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

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

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

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

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



* refactor: add descheduler description



---------


(cherry picked from commit b03fffbc3014dc7214177cac69bfcecdf7cb30c3)

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

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

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



* fix: ensure parseVM result is immutable



---------


(cherry picked from commit 0b37467f7637639209c27570bfe5633a41b96ac0)

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

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

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

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



* refactor: update spec.targetStorageClassName



* refactor: based on comment



---------


(cherry picked from commit 10d19cd329cce7e376ce2712a8843742d8968b65)

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

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

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



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

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




---------




(cherry picked from commit 30de2b1a185ccc2a3ec159e220de742dd2156229)

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

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

---------


(cherry picked from commit f9bff21e840885a120679864a0ef312163bd48a7)

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

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

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

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

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


(cherry picked from commit a1cf41bda92ceb399be21904ec267311f9568bdb)

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

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2025-11-04 16:12:14 +08:00
200 changed files with 5217 additions and 22006 deletions

View File

@ -4,8 +4,7 @@ description: Setup node and install dependencies
runs:
using: 'composite'
steps:
- name: Setup Nodejs with yarn caching
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'

6
.github/auto-assign-config.yaml vendored Normal file
View File

@ -0,0 +1,6 @@
addAssignees: author
addReviewers: true
numberOfReviewers: 0
reviewers:
- a110605
- houhoucoop

View File

@ -1,23 +1,14 @@
<!-- 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 Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
### PR Checklists
- Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
- [ ] Yes, the backend owner is:
### Related Issue
<!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
### Test Steps
<!-- Provide the test steps for reviewer to verify the issue -->
1. Go to ... page
### Related Issue #
<!-- Define findings related to the feature or bug issue. -->
### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison -->
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->

19
.github/renovate.json vendored
View File

@ -7,24 +7,16 @@
],
"baseBranches": [
"main",
"release-harvester-v1.7",
"release-harvester-v1.8"
"/^release-harvester-v\\d+\\.\\d+$/"
],
"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
@ -46,12 +38,11 @@
"reviewers": ["a110605", "houhoucoop"]
},
{
"matchUpdateTypes": ["patch"],
"automerge": false,
"matchUpdateTypes": ["patch", "digest"],
"automerge": true,
"minimumReleaseAge": "7 days",
"groupName": "patch dependencies",
"labels": ["patch-update"],
"reviewers": ["a110605", "houhoucoop"]
"groupName": "patch digest dependencies",
"labels": ["patch-update", "automerge"]
}
]
}

View File

@ -1,39 +0,0 @@
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

View File

@ -1,33 +0,0 @@
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"

View File

@ -1,28 +0,0 @@
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

View File

@ -0,0 +1,17 @@
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@b70f0bdf12a03e5e3f33e4f92ccb6c89deb3ebd9 # main
with:
configuration-path: .github/auto-assign-config.yaml

View File

@ -1,35 +0,0 @@
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"

View File

@ -1,30 +0,0 @@
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

View File

@ -1,44 +1,40 @@
name: "PR Management Add Backport Labels"
name: "[PR Management] Add Labels"
on:
workflow_run:
workflows:
- "PR Management Add Labels Collect Data"
types: [completed]
pull_request_target:
types: [opened, reopened]
branches:
- main
- 'release-harvester-v*'
permissions:
pull-requests: write
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: Download PR data artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
name: pr-backport-label-data
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
ref: ${{ github.base_ref }}
- 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'
- name: Fetch release branches and PR labels
id: fetch_info
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
repo="${{ github.repository }}"
pr_number="$PR_NUMBER"
pr_number=${{ github.event.pull_request.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 "No release branches found, skipping."
echo "should_label=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@ -48,36 +44,51 @@ jobs:
tags=$(gh api "repos/${repo}/releases" --paginate --jq '.[].tag_name')
if echo "$tags" | grep -Fxq "$release_tag"; then
echo "Release $release_tag already published, skipping."
echo "should_label=false" >> "$GITHUB_OUTPUT"
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"
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')
- 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)
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
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 }}"
IS_MERGIFY=$(echo "$PR_USER_LOGIN" | grep -iq 'mergify' && echo true || echo false)
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 }}
if [[ "$IS_MERGIFY" != "true" ]]; then
echo "PR author is not Mergify, skipping."
exit 0
fi
echo "IS_MERGIFY=$IS_MERGIFY" >> $GITHUB_OUTPUT
echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_OUTPUT
version="${PR_BASE_REF#release-harvester-v}"
- 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}"
label="backport/v${version}"
echo "Adding label: $label"
gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "$label"
echo "Adding label $label"
gh pr edit ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--add-label "$label"

View File

@ -1,28 +0,0 @@
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

View File

@ -1,47 +1,41 @@
name: "PR Management Request Backport via Mergify"
name: "[PR Management] Request Backport via Mergify"
on:
workflow_run:
workflows:
- "PR Management Request Backport via Mergify Collect Data"
types: [completed]
pull_request_target:
types: [closed]
branches: [main]
permissions:
pull-requests: write
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: Download PR data artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
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
ref: ${{ github.base_ref }}
- name: Post Mergify backport command
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
pr_number="$PR_NUMBER"
pr_number=${{ github.event.pull_request.number }}
repo="${{ github.repository }}"
labels=$(echo "$PR_LABELS" | tr ',' '\n')
labels_json='${{ toJson(github.event.pull_request.labels.*.name) }}'
labels=$(echo "$labels_json" | jq -r '.[] // empty')
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 backport labels found — skipping."
exit 0
fi

View File

@ -55,8 +55,14 @@ jobs:
with:
version: v3.8.0
- name: Setup Nodejs with yarn install
uses: ./.github/actions/setup
- name: Setup Nodejs with yarn caching
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
cache: yarn
- name: Install dependencies
run: yarn
- name: Build and push UI image
run: |

View File

@ -64,12 +64,6 @@ jobs:
- name: Checkout repository
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: |
corepack enable

View File

@ -1,7 +1,6 @@
name: Build Standalone on PR Merge
on:
workflow_dispatch: # manually trigger on Github Actions only support PR branch pushed to harvester/harvester-ui-extension
push:
branches:
- main
@ -30,35 +29,3 @@ jobs:
id-token: write
with:
CI_BRANCH: ${{github.ref_name}}
comment-on-pr:
name: Comment build result on PR
# Only for manual runs so the existing push / pull_request flows are untouched.
if: github.event_name == 'workflow_dispatch'
needs:
- build
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Add build artifact comment to the PR opened from this branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
# Match scripts/version: sanitize the branch and map main -> latest.
if [ "$BRANCH" = "main" ]; then
DIR="latest"
else
DIR=$(printf '%s' "$BRANCH" | sed -E 's/[^a-zA-Z0-9.-]+/-/g')
fi
URL="https://releases.rancher.com/harvester-ui/dashboard/${DIR}/index.html"
PR=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$BRANCH" --json number --jq '.[0].number // empty')
if [ -z "$PR" ]; then
echo "No open PR found for branch $BRANCH, skipping comment."
exit 0
fi
BODY=$(printf '🚀 **UI Artifact Build Status:** `SUCCESS` ✅\n> ui-index: %s\n> ui-source: external' "$URL")
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$BODY"

30
.github/workflows/release-label.yaml vendored Normal file
View File

@ -0,0 +1,30 @@
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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"

View File

@ -1,7 +1,7 @@
name: Tests
on:
workflow_call: # This tells GH that the workflow is reusable
workflow_call: # This tells GH that the workflow is reusable
push:
branches:
- main

2
.nvmrc
View File

@ -1 +1 @@
24
20

261
AGENTS.md
View File

@ -1,261 +0,0 @@
> 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

View File

@ -1 +0,0 @@
./AGENTS.md

View File

@ -1,2 +0,0 @@
@a110605
@houhoucoop

View File

@ -1 +0,0 @@
./AGENTS.md

View File

@ -5,27 +5,14 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
> **Note:**
> This extension is available starting from **Rancher 2.10.0**. Ensure your Rancher version is **2.10.0 or later** to access Harvester integration.
## Table of Contents
- [Installation](#installation)
- [Development Setup](#development-setup)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Branch Structure](#branch-structure)
- [Testing Guidelines](#testing-guidelines)
- [Release](#release)
- [Contributing](#contributing)
- [License](#license)
## Installation
For Harvester UI extension installation instructions, please refer to the page **Rancher Integration** -> **Harvester UI Extension** in [official Harvester documentation](https://docs.harvesterhci.io).
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).
## Development Setup
Ensure **Node.js v24 or later** is installed for development and debugging.
Ensure **Node.js v20 or later** is installed for development and debugging.
### Standalone Mode
@ -170,20 +157,13 @@ To test the standalone UI, configure Harvester to load the UI from an external s
2. Set **ui-source** to `External`
3. Set **ui-index** to the desired URL
## Release
The Harvester UI Extension follows the [Harvester](https://github.com/harvester/harvester) release cycle. After RC1 is cut for a new Harvester version, we usually create and work from the corresponding release branch (for example, `release-harvester-v1.8`). The remaining RC builds and the final official release are published from that branch.
After Harvester releases a new version, update the Harvester entry in rancher/ui-plugin-charts [manifest.json](https://github.com/rancher/ui-plugin-charts/blob/aafd215debbc6cb3100e7ba4b0a542c932397acd/manifest.json#L133-L151). This ensures air-gapped users can pull the new Harvester UI Extension image.
## Contributing
If you want to contribute, start by reading this document, then visit our [Getting Started guide](https://extensions.rancher.io/extensions/next/extensions-getting-started) to learn how to develop and submit changes.
## License
Copyright (c) 2014-2026 [SUSE, LLC.](https://www.suse.com/)
Copyright (c) 2014-2025 [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.

View File

@ -32,11 +32,10 @@ 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': [0, 'always', 100],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [0, 'always', 100],
'footer-max-line-length': [2, 'always', 100],
},
// Ignore merge commits and revert commits
ignores: [

View File

@ -1,63 +0,0 @@
# 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

View File

@ -1,21 +0,0 @@
## 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.

View File

@ -1,65 +0,0 @@
## 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
```

View File

@ -1,72 +0,0 @@
## 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.

View File

@ -1,3 +0,0 @@
## E2E Tests (Cypress)
See https://github.com/harvester/harvester-ui-tests

View File

@ -1,12 +0,0 @@
## 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`.

View File

@ -1,58 +0,0 @@
## 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

View File

@ -1,5 +0,0 @@
## 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.

View File

@ -1,22 +0,0 @@
> 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>`

View File

@ -1,33 +1,22 @@
{
"name": "harvester-ui-extension",
"version": "1.10.0-dev",
"version": "1.7.3",
"private": false,
"engines": {
"node": ">=24.0.0"
"node": ">=20.0.0"
},
"dependencies": {
"@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",
"@rancher/shell": "3.0.8-rc.8",
"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": "25.9.5",
"@types/node": "~20.19.0",
"cronstrue": "2.59.0",
"d3-color": "3.1.0",
"ejs": "3.1.10",
@ -49,14 +38,12 @@
"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",
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh"
"prepare": "husky"
},
"devDependencies": {
"@commitlint/load": "^19.8.1",

View File

@ -7,7 +7,8 @@ The Harvester UI Extension is a Rancher extension that provides the user interfa
## 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).
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).
## Development Setup
@ -162,7 +163,7 @@ If you want to contribute, start by reading this document, then visit our [Getti
## License
Copyright (c) 2014-2026 [SUSE, LLC.](https://www.suse.com/)
Copyright (c) 2014-2025 [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.

View File

@ -271,9 +271,4 @@ export default {
.filter-label .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>

View File

@ -116,11 +116,6 @@ 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>

View File

@ -1,186 +0,0 @@
<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>

View File

@ -55,12 +55,8 @@ 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);
},
@ -158,23 +154,14 @@ export default {
:cluster="currentCluster.nameDisplay"
/>
</h1>
<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"
<button
v-if="versionOptions.length && !isUpgradeInProgress"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<button
:disabled="!canUpgrade"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<t k="harvester.upgradePage.upgrade" />
</button>
</span>
<t k="harvester.upgradePage.upgrade" />
</button>
</header>
<ModalWithCard
@ -309,18 +296,6 @@ 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;

View File

@ -1,8 +1,6 @@
<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',
@ -27,45 +25,13 @@ 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;
}
}
}
};
@ -97,28 +63,12 @@ export default {
v-for="(item, i) in list"
:key="i"
>
<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>
<p>
{{ item.name }} <span
class="status"
:class="{ [item.state]: true }"
>{{ item.state }}</span>
</p>
<PercentageBar
:model-value="item.percent"
preferred-direction="MORE"
@ -152,21 +102,10 @@ export default {
}
}
.custom-content {
.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: 14px;
p {
margin-bottom: 4px;
}
margin-bottom: 14px;
.status {
float: right;
}
@ -178,8 +117,6 @@ export default {
}
.warning {
color: var(--error);
margin-bottom: 8px;
margin-top: 4px;
}
}
}

View File

@ -1,59 +0,0 @@
<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>

View File

@ -122,7 +122,7 @@ export default {
return this.$store.getters['currentCluster'].isLocal;
},
canEditClusterMembers() {
return this.schema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
return this.normanClusterRTBSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
},
},
};

View File

@ -1,6 +1,7 @@
<script>
import { allHash } from '@shell/utils/promise';
import debounce from 'lodash/debounce';
import Socket, {
EVENT_CONNECTED,
EVENT_CONNECTING,
@ -21,98 +22,74 @@ export default {
data() {
return {
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',
},
},
socket: null,
terminal: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: []
};
},
computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() {
return {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
cursorWidth: 2,
scrollback: 5000,
useStyle: true,
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 16,
lineHeight: 1.3,
fontSize: 12,
};
},
},
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/xterm');
const xterm = await import(/* webpackChunkName: "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'),
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
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'),
});
const terminal = new xterm.Terminal({
theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim()
},
...this.xtermConfig,
@ -120,48 +97,25 @@ 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);
// 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);
}
});
if ( this.webglAddon ) {
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);
@ -169,132 +123,9 @@ 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();
@ -309,131 +140,12 @@ 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.serialConsoleUrl;
return `${ this.value?.getSerialConsolePath }`;
},
async connect() {
@ -469,7 +181,6 @@ export default {
this.fit();
this.flush();
}
this.scheduleFit();
if (this.firstTime) {
this.socket.send(this.str2ab('\n'));
@ -484,7 +195,7 @@ export default {
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
this.enqueueMessage(this.decodeMessageData(e.detail.data));
this.queue.push(e.detail.data.text());
});
this.socket.connect();
@ -507,19 +218,22 @@ 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();
}
@ -534,44 +248,6 @@ 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"
@ -581,73 +257,18 @@ export default {
</template>
<style lang="scss">
@import '../../../../node_modules/@xterm/xterm/css/xterm.css';
@import '../../../../node_modules/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;
.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;
.shell-body, .terminal.xterm {
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>

View File

@ -55,21 +55,14 @@ 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 }`;
// 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`
);
});
window.open(
url,
'_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer`
);
},
isEmpty(o) {

View File

@ -1,196 +0,0 @@
<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>

View File

@ -1,104 +0,0 @@
<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>

View File

@ -1,371 +0,0 @@
<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>

View File

@ -64,9 +64,6 @@ 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) {
@ -75,8 +72,7 @@ export default {
vlan: '',
clusterNetwork: '',
range: '',
exclude: [],
exclusiveVlan: false
exclude: []
};
}
const exclude = parsedDefaultValue?.exclude?.toString().split(',') || [];
@ -98,10 +94,6 @@ export default {
},
computed: {
showExclusiveVlan() {
return this.networkType === L2VLAN &&
Number(this.parsedDefaultValue.vlan) !== 1;
},
showVlan() {
return this.networkType === L2VLAN;
},
@ -140,18 +132,6 @@ export default {
};
});
},
exclusiveVlanOptions() {
return [
{
label: this.t('generic.enabled'),
value: true
},
{
label: this.t('generic.disabled'),
value: false
}
];
},
},
watch: {
@ -194,8 +174,7 @@ export default {
vlan: '',
clusterNetwork: '',
range: '',
exclude: [],
exclusiveVlan: false
exclude: []
};
},
@ -301,15 +280,7 @@ 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"

View File

@ -4,8 +4,6 @@ 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',
@ -17,13 +15,6 @@ 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 = {};
@ -48,25 +39,7 @@ 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() {
@ -75,18 +48,6 @@ 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' } };
}
@ -144,8 +105,8 @@ export default {
this.update();
},
deep: true
},
},
}
}
};
</script>
@ -183,28 +144,6 @@ 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"

View File

@ -69,15 +69,6 @@ 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>

View File

@ -1,649 +0,0 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue';
import StorageDefaultsModal from './StorageDefaultsModal.vue';
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
const DEFAULT_ACCESS_MODE = ACCESS_MODE?.READ_WRITE_MANY ?? 'ReadWriteMany';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
useAllProviderData: { type: Boolean, default: false },
existingNetworkMap: { type: Object, default: null },
existingStorageMap: { type: Object, default: null },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const errors = ref([]);
const loading = ref(true);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE;
const harvesterNetworkOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
];
harvesterNetworks.value.forEach((net) => {
const ns = net.metadata?.namespace || '';
const name = net.metadata?.name || net.id;
const fullName = ns ? `${ ns }/${ name }` : name;
options.push({ label: fullName, value: fullName });
});
return options;
});
const storageClassOptions = computed(() => {
const options = storageClasses.value.filter((s) => !s.parameters?.backingImage).map((sc) => {
const value = sc.name;
let label = sc.isDefault ? `${ value } (${ t('generic.default') })` : value;
let disabled = false;
if (isInternalStorageClass(value)) {
label += ` (${ t('harvester.storage.internal.label') })`;
disabled = true;
}
return {
label,
value,
disabled,
};
});
if (!options.find((o) => o.value === 'harvester-longhorn')) {
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
}
return options;
});
const applyNetworkMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
networkEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination) {
const dest = match.destination;
if (dest.type === 'pod') {
entry.target = 'pod';
} else if (dest.type === 'ignored') {
entry.target = 'ignored';
} else if (dest.type === 'multus' && dest.name) {
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
}
}
});
};
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
if (!mapSpec) {
return;
}
storageEntries.value.forEach((entry) => {
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination?.storageClass) {
entry.target = match.destination.storageClass;
if (captureInherited) {
entry.inheritedFromProvider = true;
}
if (match.destination.volumeMode) {
entry.volumeMode = match.destination.volumeMode;
if (captureInherited) {
entry.inheritedVolumeMode = match.destination.volumeMode;
}
}
if (match.destination.accessMode) {
entry.accessModes = [match.destination.accessMode];
if (captureInherited) {
entry.inheritedAccessModes = [match.destination.accessMode];
}
}
if (markOverridden) {
entry.overridden = true;
}
}
});
};
const applyDefaultNetworkMap = () => {
const defaultMap = allNetworkMaps.value.find(
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
);
applyNetworkMapTargets(defaultMap?.spec?.map);
};
const applyDefaultStorageMap = () => {
const defaultMap = allStorageMaps.value.find(
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
// The "Inherited from provider" hint only appears in the migration plan wizard
// (where mappings inherit from the provider default), not on the provider creation page.
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
const openStorageDefaults = (entry) => {
editingStorageEntry.value = entry;
showStorageDefaultsModal.value = true;
};
const closeStorageDefaults = () => {
showStorageDefaultsModal.value = false;
editingStorageEntry.value = null;
};
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
if (editingStorageEntry.value) {
editingStorageEntry.value.volumeMode = volumeMode;
editingStorageEntry.value.accessModes = accessModes;
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
const selectedAccessMode = accessModes?.[0];
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
}
};
const canSave = computed(() => {
if (props.useAllProviderData) {
return true;
}
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value;
});
watch(canSave, (val) => {
emit('ready', val);
});
// After restore, check if ready
if (canSave.value) {
emit('ready', true);
}
const buildNetworkEntries = () => {
const networkMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => {
const netKey = net.id || net.name || 'default';
if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ netKey }`,
};
}
if (!networkMap[netKey].usedBy.includes(vmName)) {
networkMap[netKey].usedBy.push(vmName);
}
});
}
});
networkEntries.value = Object.values(networkMap);
};
const buildStorageEntries = () => {
const datastoreMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.disks && vm.disks.length > 0) {
vm.disks.forEach((disk) => {
const ds = disk.datastore;
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [DEFAULT_ACCESS_MODE],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
datastoreMap[ds.id].capacity += disk.capacity || 0;
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
datastoreMap[ds.id].usedBy.push(vmName);
}
}
});
}
});
storageEntries.value = Object.values(datastoreMap);
};
const buildNetworkEntriesFromProvider = (networksData) => {
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
name: net.name || net.id,
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ net.id || net.name }`,
}));
};
const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [DEFAULT_ACCESS_MODE],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
const formatStorageDetail = (entry) => {
const parts = [];
if (entry.type) {
parts.push(entry.type);
}
if (entry.capacity) {
const gb = entry.capacity / (1024 * 1024 * 1024);
if (gb >= 1024) {
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
} else {
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
}
}
return parts.join(' • ');
};
const buildNetworkMapSpec = (providerRef) => ({
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
provider: providerRef,
});
const buildStorageMapSpec = (providerRef) => ({
map: buildStorageMapEntries(storageEntries.value),
provider: providerRef,
});
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Update existing maps in place (edit mode)
if (props.existingNetworkMap && props.existingStorageMap) {
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
await props.existingNetworkMap.save();
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
await props.existingStorageMap.save();
return {
networkMapName: props.existingNetworkMap.metadata.name,
storageMapName: props.existingStorageMap.metadata.name,
};
}
const providerOwnerRef = props.provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.provider.metadata.name,
uid: props.provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildNetworkMapSpec(providerRef),
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: buildStorageMapSpec(providerRef),
});
await storageMap.save();
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
};
defineExpose({ saveMappings: saveAndReturn });
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
} catch (e) {
harvesterNetworks.value = [];
}
try {
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch (e) {
storageClasses.value = [];
}
try {
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
} catch (e) {
allNetworkMaps.value = [];
}
try {
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
} catch (e) {
allStorageMaps.value = [];
}
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
try {
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [networksData, datastoresData] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
]);
if (props.useAllProviderData) {
if (networkEntries.value.length === 0) {
buildNetworkEntriesFromProvider(networksData);
}
if (storageEntries.value.length === 0) {
buildStorageEntriesFromProvider(datastoresData);
}
} else {
vms.value = props.selectedVms;
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
}
return resolved;
});
if (networkEntries.value.length === 0) {
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
}
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
}
}
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
if (!hasExistingNetworkTargets) {
if (props.existingNetworkMap?.spec?.map) {
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
} else {
applyDefaultNetworkMap();
}
}
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
} else {
applyDefaultStorageMap();
}
}
loading.value = false;
};
init();
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-mappings"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
:entries="networkEntries"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
</template>
</MappingColumn>
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
:entries="storageEntries"
:options="storageClassOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
>
<template #source-detail="{ entry }">
<span
v-if="entry.type || entry.capacity"
class="source-detail text-deemphasized"
>{{ formatStorageDetail(entry) }}</span>
</template>
</MappingColumn>
</div>
<StorageDefaultsModal
v-if="showStorageDefaultsModal && editingStorageEntry"
:storage-class-name="editingStorageEntry.target"
:provider-name="providerName"
:show-inherited="!!inheritedProviderName"
:volume-mode="editingStorageEntry.volumeMode"
:access-modes="editingStorageEntry.accessModes"
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
@apply="applyStorageDefaults"
@close="closeStorageDefaults"
/>
</div>
</template>
<style lang="scss" scoped>
.mappings-columns {
display: flex;
flex-direction: column;
gap: 32px;
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
gap: 48px;
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
gap: 64px;
}
}
.configure-mappings {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,573 +0,0 @@
<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>

View File

@ -1,252 +0,0 @@
<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>

View File

@ -1,674 +0,0 @@
<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>

View File

@ -1,668 +0,0 @@
<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>

View File

@ -1,181 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import AppModal from '@shell/components/AppModal';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
const {
READ_WRITE_ONCE = 'ReadWriteOnce',
READ_ONLY_MANY = 'ReadOnlyMany',
READ_WRITE_MANY = 'ReadWriteMany',
READ_WRITE_ONCE_POD = 'ReadWriteOncePod'
} = ACCESS_MODE || {};
const ACCESS_MODE_OPTIONS = [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY, READ_WRITE_ONCE_POD];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
accessModes: { type: Array, default: () => ['ReadWriteMany'] },
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
inheritedAccessModes: { type: Array, default: () => ['ReadWriteMany'] },
});
const emit = defineEmits(['apply', 'close']);
const store = useStore();
const { t } = useI18n(store);
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
const localAccessMode = ref(props.accessModes?.[0] || READ_WRITE_MANY);
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
const apply = () => {
emit('apply', {
volumeMode: localVolumeMode.value,
accessModes: [localAccessMode.value],
});
emit('close');
};
// Repopulate the dropdowns with the provider default values; the user then applies.
const reset = () => {
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
localAccessMode.value = props.inheritedAccessModes?.[0] || READ_WRITE_MANY;
};
const cancel = () => {
emit('close');
};
</script>
<template>
<app-modal
class="storage-defaults-modal"
name="storageDefaultsDialog"
:width="620"
height="auto"
:click-to-close="false"
@close="cancel"
>
<Card
class="storage-defaults-card"
:show-highlight-border="false"
>
<template #title>
<h4 class="text-default-text">
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
</h4>
</template>
<template #body>
<p class="text-deemphasized description">
<template v-if="showInherited">
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
</template>
<template v-else>
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
</template>
</p>
<div class="settings-box">
<LabeledSelect
v-model:value="localVolumeMode"
class="volume-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
:options="volumeModeOptions"
:searchable="false"
:clearable="false"
/>
<LabeledSelect
v-model:value="localAccessMode"
class="access-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
:options="accessModeOptions"
:searchable="false"
:clearable="false"
/>
</div>
</template>
<template #actions>
<div class="buttons">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
</button>
<div class="right-buttons">
<button
v-if="showInherited"
class="btn role-secondary mr-10"
@click="reset"
>
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
</button>
<button
class="btn role-primary"
:disabled="!localAccessMode"
@click="apply"
>
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
</button>
</div>
</div>
</template>
</Card>
</app-modal>
</template>
<style lang="scss" scoped>
.storage-defaults-modal {
z-index: 45;
}
.storage-defaults-card {
margin: 0;
padding: 20px;
}
.description {
margin-bottom: 16px;
line-height: 20px;
}
.settings-box {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px;
border-radius: 6px;
background-color: var(--category-active);
}
.volume-mode {
max-width: 280px;
}
.access-mode {
max-width: 280px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right-buttons {
display: flex;
}
}
</style>

View File

@ -54,28 +54,11 @@ const FEATURE_FLAGS = {
'lhV2VolExpansion',
'l2VlanTrunkMode',
'kubevirtMigration',
'hotplugNic',
'resumeUpgradePausedNode',
'hotplugNic'
],
'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',
'v1.7.2': [
'clusterRegistrationTLSVerify'
],
};

View File

@ -35,40 +35,12 @@ import {
SNAPSHOT_TARGET_VOLUME,
IMAGE_VIRTUAL_SIZE,
IMAGE_STORAGE_CLASS,
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,
HARVESTER_DESCRIPTION
} 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';
@ -96,6 +68,7 @@ export function init($plugin, store) {
configureType,
virtualType,
weightGroup,
weightType,
} = $plugin.DSL(store, PRODUCT_NAME);
const isSingleVirtualCluster = process.env.rancherEnv === PRODUCT_NAME;
@ -182,7 +155,7 @@ export function init($plugin, store) {
group: 'Root',
name: HCI.HOST,
namespaced: true,
weight: 499,
weight: 399,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.HOST }
@ -214,7 +187,7 @@ export function init($plugin, store) {
group: 'root',
name: HCI.VM,
namespaced: true,
weight: 498,
weight: 299,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VM }
@ -222,146 +195,6 @@ 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: {
@ -379,7 +212,7 @@ export function init($plugin, store) {
ifHaveType: PVC,
name: HCI.VOLUME,
namespaced: true,
weight: 497,
weight: 199,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VOLUME }
@ -405,7 +238,7 @@ export function init($plugin, store) {
group: 'root',
name: HCI.IMAGE,
namespaced: true,
weight: 496,
weight: 198,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IMAGE }
@ -420,7 +253,7 @@ export function init($plugin, store) {
group: 'root',
namespaced: true,
name: 'projects-namespaces',
weight: 495,
weight: 98,
route: { name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces` },
exact: true,
});
@ -432,7 +265,7 @@ export function init($plugin, store) {
labelKey: 'harvester.namespace.label',
name: NAMESPACE,
namespaced: true,
weight: 495,
weight: 89,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: NAMESPACE }
@ -503,7 +336,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_FLOW,
labelKey: 'harvester.logging.clusterFlow.label',
name: HCI.CLUSTER_FLOW,
namespaced: true,
@ -527,7 +359,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.CLUSTER_OUTPUT,
labelKey: 'harvester.logging.clusterOutput.label',
name: HCI.CLUSTER_OUTPUT,
namespaced: true,
@ -551,7 +382,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.FLOW,
labelKey: 'harvester.logging.flow.label',
name: HCI.FLOW,
namespaced: true,
@ -575,7 +405,6 @@ export function init($plugin, store) {
});
virtualType({
ifHaveType: LOGGING.OUTPUT,
labelKey: 'harvester.logging.output.label',
name: HCI.OUTPUT,
namespaced: true,
@ -596,53 +425,14 @@ export function init($plugin, store) {
[
HCI.CLUSTER_NETWORK,
HCI.NETWORK_ATTACHMENT,
HCI.HOST_NETWORK_CONFIG,
HCI.VPC,
NETWORK_POLICY,
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,
@ -653,12 +443,9 @@ export function init($plugin, store) {
'backupAndSnapshot'
);
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);
weightGroup('networks', 300, true);
weightType(NAMESPACE, 299, true);
weightGroup('backupAndSnapshot', 289, true);
basicType(
[
@ -737,7 +524,7 @@ export function init($plugin, store) {
name: HCI.CLUSTER_NETWORK,
ifHaveType: HCI.CLUSTER_NETWORK,
namespaced: false,
weight: 484,
weight: 189,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.CLUSTER_NETWORK }
@ -752,14 +539,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: 485,
weight: 188,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.NETWORK_ATTACHMENT }
@ -773,7 +560,7 @@ export function init($plugin, store) {
labelKey: 'harvester.vpc.label',
name: HCI.VPC,
namespaced: true,
weight: 195,
weight: 187,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VPC }
@ -782,73 +569,13 @@ 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: 194,
weight: 186,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: NETWORK_POLICY }
@ -857,53 +584,6 @@ 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: {
@ -1258,7 +938,7 @@ export function init($plugin, store) {
labelKey: 'harvester.loadBalancer.label',
name: HCI.LB,
namespaced: true,
weight: 483,
weight: 185,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.LB }
@ -1297,7 +977,7 @@ export function init($plugin, store) {
labelKey: 'harvester.ipPool.label',
name: HCI.IP_POOL,
namespaced: false,
weight: 482,
weight: 184,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.IP_POOL }
@ -1306,207 +986,4 @@ 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,
]
});
// ===========================================================================
}

View File

@ -39,12 +39,6 @@ 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'
@ -79,7 +73,6 @@ 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 = {
@ -89,13 +82,8 @@ 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,

View File

@ -16,11 +16,9 @@ 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',
@ -30,7 +28,6 @@ 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',
@ -81,10 +78,4 @@ export const HCI = {
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address',
STATIC_IP: 'static-ip.harvesterhci.io',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
};

View File

@ -1,50 +1,44 @@
export const HCI_SETTING = {
BACKUP_TARGET: 'backup-target',
CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'vlan',
UI_SOURCE: 'ui-source',
UI_PL: 'ui-pl',
HTTP_PROXY: 'http-proxy',
ADDITIONAL_CA: 'additional-ca',
OVERCOMMIT_CONFIG: 'overcommit-config',
CLUSTER_REGISTRATION_URL: 'cluster-registration-url',
DEFAULT_STORAGE_CLASS: 'default-storage-class',
SUPPORT_BUNDLE_TIMEOUT: 'support-bundle-timeout',
SUPPORT_BUNDLE_EXPIRATION: 'support-bundle-expiration',
SUPPORT_BUNDLE_FILE_NAME: 'support-bundle-file-name',
SUPPORT_BUNDLE_IMAGE: 'support-bundle-image',
SUPPORT_BUNDLE_NODE_COLLECTION_TIMEOUT: 'support-bundle-node-collection-timeout',
STORAGE_NETWORK: 'storage-network',
RWX_NETWORK: 'rwx-network',
VM_FORCE_RESET_POLICY: 'vm-force-reset-policy',
SSL_CERTIFICATES: 'ssl-certificates',
SSL_PARAMETERS: 'ssl-parameters',
SUPPORT_BUNDLE_NAMESPACES: 'support-bundle-namespaces',
AUTO_DISK_PROVISION_PATHS: 'auto-disk-provision-paths',
RELEASE_DOWNLOAD_URL: 'release-download-url',
CCM_CSI_VERSION: 'harvester-csi-ccm-versions',
CSI_DRIVER_CONFIG: 'csi-driver-config',
CSI_ONLINE_EXPAND_VALIDATION: 'csi-online-expand-validation',
VM_TERMINATION_PERIOD: 'default-vm-termination-grace-period-seconds',
NTP_SERVERS: 'ntp-servers',
AUTO_ROTATE_RKE2_CERTS: 'auto-rotate-rke2-certs',
KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES: 'kubeconfig-default-token-ttl-minutes',
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
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'
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'
};
export const HCI_ALLOWED_SETTINGS = {
@ -77,17 +71,11 @@ 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]: {
@ -114,14 +102,6 @@ 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',
@ -138,12 +118,6 @@ 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'
}
};

View File

@ -131,169 +131,3 @@ 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',
};

View File

@ -15,13 +15,6 @@ export const VOLUME_MODE = {
FILE_SYSTEM: 'Filesystem'
};
export const ACCESS_MODE = {
READ_WRITE_MANY: 'ReadWriteMany',
READ_WRITE_ONCE: 'ReadWriteOnce',
READ_ONLY_MANY: 'ReadOnlyMany',
READ_WRITE_ONCE_POD: 'ReadWriteOncePod',
};
export const NETWORK_PROTOCOL = {
IPv4: 'IPv4',
IPv6: 'IPv6',
@ -36,26 +29,3 @@ 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',
};

View File

@ -27,11 +27,15 @@ export default {
await allHash({
vms: this.$store.dispatch('harvester/findAll', { type: HCI.VM }),
vmis: this.$store.dispatch('harvester/findAll', { type: HCI.VMI }),
vmims: this.$store.dispatch('harvester/findAll', { type: HCI.VMIM }),
allClusterNetwork: this.$store.dispatch('harvester/findAll', { type: HCI.CLUSTER_NETWORK }),
});
},
computed: {
allClusterNetwork() {
return this.$store.getters['harvester/all'](HCI.CLUSTER_NETWORK);
},
rows() {
const vms = this.$store.getters['harvester/all'](HCI.VM);
@ -104,6 +108,7 @@ export default {
<HarvesterVmState
class="vmstate"
:row="scope.row"
:all-cluster-network="allClusterNetwork"
/>
</div>
</template>

View File

@ -75,7 +75,7 @@ export default {
<div class="row">
<div class="col span-6 mb-20">
<LabelValue
:name="t('harvester.schedule.cron.label')"
:name="t('harvester.schedule.cron')"
:value="cronExpression"
/>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -390,17 +390,14 @@ export default {
:vmi-resource="vmi"
:vmim-resource="vmim"
/>
<div
<DashboardMetrics
v-if="showVmMetrics && liveMigrationProgressEnabled"
class="migration-metrics-scroll mb-30"
>
<DashboardMetrics
:detail-url="VM_MIGRATION_DETAIL_URL"
graph-height="960px"
:has-summary-and-detail="false"
:vars="graphVars"
/>
</div>
:detail-url="VM_MIGRATION_DETAIL_URL"
graph-height="640px"
:has-summary-and-detail="false"
:vars="graphVars"
class="mb-30"
/>
<Events
v-if="liveMigrationProgressEnabled"
:resource="vmi"
@ -439,10 +436,3 @@ export default {
</Tabbed>
</div>
</template>
<style lang="scss" scoped>
.migration-metrics-scroll {
max-height: 640px;
overflow-y: auto;
}
</style>

View File

@ -98,9 +98,9 @@ export default {
methods: {
escapeHtml,
close(data) {
close() {
this.errors = [];
this.$emit('close', data);
this.$emit('close');
},
async apply(buttonDone) {
@ -109,7 +109,7 @@ export default {
await resource.doActionGrowl(this.modalData.action, {});
}
buttonDone(true);
this.close({ performCallback: true, clearTableSelection: true });
this.close();
} catch (e) {
this.errors = exceptionToErrorsArray(e);
buttonDone(false);

View File

@ -1,12 +1,9 @@
<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',
@ -15,9 +12,7 @@ export default {
components: {
AsyncButton,
Banner,
Card,
Checkbox,
},
props: {
@ -28,16 +23,10 @@ export default {
},
data() {
return { disableResourcePooling: false };
return {};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
disableResourcePoolingEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('disableResourcePooling');
},
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
@ -45,7 +34,16 @@ export default {
},
async save(buttonCb) {
const userName = getHarvesterUserName(this.$store.getters);
// 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;
}
for (let i = 0; i < this.resources.length; i++) {
const actionResource = this.resources[i];
@ -62,10 +60,9 @@ export default {
}]
},
spec: {
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName,
disableResourcePooling: this.disableResourcePooling,
address: actionResource.status.address,
nodeName: actionResource.status.nodeName,
userName
}
} );
@ -96,19 +93,7 @@ export default {
</template>
<template #body>
<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>
{{ t('harvester.pci.enablePassthroughWarning') }}
</template>
<template #actions>

View File

@ -1,133 +0,0 @@
<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>

View File

@ -4,7 +4,6 @@ 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',
@ -35,7 +34,16 @@ export default {
},
async save(buttonCb) {
const userName = getHarvesterUserName(this.$store.getters);
// 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;
}
for (let i = 0; i < this.resources.length; i++) {
const actionResource = this.resources[i];

View File

@ -2,16 +2,13 @@
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { mapGetters } from 'vuex';
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
import { PVC } 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',
@ -19,7 +16,7 @@ export default {
emits: ['close'],
components: {
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
AsyncButton, Card, LabeledInput, LabeledSelect, Banner
},
props: {
@ -31,19 +28,14 @@ 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: '',
shareable: false,
errors: [],
allPVCs: [],
allStorageClasses: [],
allVMs: [],
diskName: '',
volumeName: '',
errors: [],
allPVCs: [],
};
},
@ -58,31 +50,6 @@ 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
@ -94,14 +61,6 @@ 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;
})
@ -114,62 +73,19 @@ 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 input = { volumeSourceName: this.volumeName, diskName: this.diskName };
if (this.isShareableCapable && this.shareable) {
input.shareable = true;
}
const res = await this.actionResource.doAction('addVolume', input, {}, false);
const res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false);
if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', {
@ -225,19 +141,6 @@ 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"

View File

@ -5,8 +5,6 @@ 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',
@ -17,7 +15,6 @@ export default {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
@ -30,9 +27,8 @@ export default {
data() {
return {
backUpName: '',
errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
backUpName: '',
errors: []
};
},
@ -41,22 +37,12 @@ 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');
},
@ -65,7 +51,7 @@ export default {
try {
const res = await this.actionResource.doAction(
'backup',
{ name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
{ name: this.backUpName },
{},
false
);
@ -114,18 +100,9 @@ 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"

View File

@ -1,183 +0,0 @@
<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>

View File

@ -1,154 +0,0 @@
<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>

View File

@ -5,10 +5,6 @@ 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',
@ -44,37 +40,19 @@ export default {
},
isVolume() {
return this.modalData.type === VOLUME;
return this.modalData.type === 'volume';
},
titleKey() {
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];
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.title' : 'harvester.virtualMachine.hotUnplug.detachNIC.title';
},
actionLabelKey() {
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];
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel' : 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabel';
},
successMessageKey() {
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];
return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.success' : 'harvester.virtualMachine.hotUnplug.detachNIC.success';
}
},
@ -87,12 +65,10 @@ export default {
try {
let res;
if (this.modalData.type === VOLUME) {
if (this.isVolume) {
res = await this.actionResource.doAction('removeVolume', { diskName: this.name });
} 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 });
res = await this.actionResource.doAction('removeNic', { interfaceName: this.name });
}
if (res._status === 200 || res._status === 204) {

View File

@ -1,198 +0,0 @@
<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>

View File

@ -1,12 +1,15 @@
<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'],
@ -59,46 +62,28 @@ export default {
return this.resources[0];
},
anyCpuPinning() {
return this.resources.some((r) => r.isCpuPinning);
},
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;
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));
return resource;
},
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 matchingCpuManagerConfig = !this.anyCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
const isCpuPinning = this.actionResource?.isCpuPinning;
const matchingCpuManagerConfig = !isCpuPinning || n.isCPUManagerEnabled; // If cpu-pinning is enabled, filter-out non-enabled CPU manager nodes.
return isNotWitnessNode && matchingCpuManagerConfig;
return isNotSelfNode && isNotWitnessNode && matchingCpuManagerConfig;
}).map((n) => {
let label = n?.metadata?.name;
const value = n?.metadata?.name;
@ -117,10 +102,10 @@ export default {
},
methods: {
close(data) {
close() {
this.nodeName = '';
this.errors = [];
this.$emit('close', data);
this.$emit('close');
},
async apply(buttonDone) {
@ -141,32 +126,10 @@ export default {
}
try {
// 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;
}
await this.actionResource.doAction('migrate', { nodeName: this.nodeName }, {}, false);
buttonDone(true);
this.close({ performCallback: true, clearTableSelection: true });
this.close();
} catch (err) {
const error = err?.data || err;
const message = exceptionToErrorsArray(error);
@ -183,35 +146,17 @@ export default {
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.migration.vmMigrationTitle', { count: resources.length }) }}
{{ t('harvester.modal.migration.title') }}
</template>
<template #body>
<Banner
v-if="anyCpuPinning"
v-if="actionResource?.isCpuPinning"
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"
@ -238,7 +183,7 @@ export default {
<AsyncButton
mode="apply"
:disabled="!nodeName || allVmsOnTargetNode"
:disabled="!nodeName"
@click="apply"
/>
</div>
@ -256,16 +201,4 @@ 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>

View File

@ -1,239 +0,0 @@
<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>

View File

@ -277,9 +277,8 @@ export default {
:label="t('harvester.modal.bundle.namespaces.label')"
:clearable="true"
:multiple="true"
:append-to-body="false"
:options="namespaceOptions"
class="mb-10 namespace-select"
class="mb-10 label-select"
:tooltip="t('harvester.modal.bundle.namespaces.tooltip', _, true)"
@update:value="updateNamespaces"
/>
@ -373,11 +372,6 @@ export default {
padding: 10px 0;
height: 160px;
}
.namespace-select {
:deep(.vs__dropdown-menu) {
max-height: 210px;
}
}
}
div {

View File

@ -5,10 +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 { HCI } from '../types';
import { BACKUP_TYPE } from '../config/types';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default {
name: 'HarvesterVMSnapshotDialog',
@ -19,7 +17,6 @@ export default {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
@ -34,8 +31,7 @@ export default {
return {
snapshotName: '',
snapshotNamespace: '',
errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
errors: []
};
},
@ -44,23 +40,13 @@ 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');
},
@ -74,8 +60,7 @@ export default {
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
},
spec: {
fsFreezeDeadline: this.fsFreezeDeadline,
source: {
source: {
apiGroup: 'kubevirt.io',
kind: 'VirtualMachine',
name: this.actionResource.metadata.name
@ -147,14 +132,6 @@ 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"

View File

@ -1,24 +0,0 @@
<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>

View File

@ -108,7 +108,6 @@ export default {
<YamlEditor
ref="yamlUser"
v-model:value="config"
:mode="mode"
class="yaml-editor"
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
@onChanges="update"

View File

@ -71,38 +71,21 @@ 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 = [];
@ -300,6 +283,20 @@ 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;

View File

@ -468,15 +468,10 @@ 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 }`;
}
@ -486,7 +481,7 @@ export default {
value: d.id,
action: this.addDisk,
kind: !parentDevice ? 'group' : '',
disabled: !!isChildAdded || !!mountPoint,
disabled: !!isChildAdded,
group: parentDevice || devPath,
isParent: !!parentDevice,
};

View File

@ -8,7 +8,6 @@ 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',
@ -64,14 +63,6 @@ export default {
}
},
computed: {
normalizedErrors() {
const message = this.t('harvester.virtualMachine.genericLoginError');
return getLoginAwareErrors(this.errors, message);
}
},
methods: { onKeySelected: createOnSelected('publicKey') },
};
</script>
@ -81,9 +72,10 @@ export default {
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="normalizedErrors"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<div class="header mb-20">
<FileSelector

View File

@ -9,7 +9,6 @@ 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';
@ -21,7 +20,6 @@ const { ACCESS, TRUNK } = L2VLAN_MODE;
const AUTO = 'auto';
const MANUAL = 'manual';
const KUBE_SYSTEM = 'kube-system';
export default {
emits: ['update:value'],
@ -72,12 +70,7 @@ export default {
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
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 }),
});
await allHash({ clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }) });
},
created() {
@ -206,80 +199,6 @@ 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;
}
}
},
@ -295,7 +214,6 @@ export default {
this.config.ipam = {};
this.config.bridge = '';
delete this.config.provider;
delete this.config.master;
delete this.config.server_socket;
}
},
@ -312,13 +230,6 @@ 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: {
@ -413,10 +324,6 @@ 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) {
@ -443,7 +350,6 @@ export default {
ref="nd"
:value="value"
:mode="mode"
:namespace-options="namespaceOptions"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
@ -615,25 +521,6 @@ 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>

View File

@ -1,5 +1,4 @@
<script>
import { mapGetters } from 'vuex';
import { RadioGroup } from '@components/Form/Radio';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
@ -17,7 +16,6 @@ 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',
@ -30,7 +28,6 @@ export default {
LabeledSelect,
MessageLink,
Banner,
CronExpressionEditorModal
},
mixins: [CreateEditView],
@ -89,12 +86,10 @@ export default {
}
}
return { settings: [], showModel: false };
return { settings: [] };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
backupTargetResource() {
return this.settings.find( (O) => O.id === 'backup-target');
},
@ -177,9 +172,6 @@ export default {
this.value.spec['maxFailure'] = this.value.spec.retain;
}
},
openModal() {
this.showModel = true;
},
},
};
</script>
@ -264,28 +256,16 @@ export default {
:weight="99"
class="bordered-table"
>
<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="value.spec.cron"
class="mb-30"
type="cron"
required
:mode="mode"
:label="t('harvester.schedule.cron')"
placeholder="0 * * * *"
:disabled="isBackupTargetUnAvailable || isView"
/>
<LabeledInput
v-model:value.number="value.spec.retain"
class="mb-30"
@ -312,16 +292,3 @@ 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>

View File

@ -1,5 +1,5 @@
<script>
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox';
@ -10,10 +10,6 @@ import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params';
const {
READ_WRITE_ONCE, READ_ONLY_MANY, READ_WRITE_MANY, READ_WRITE_ONCE_POD
} = ACCESS_MODE;
export default {
name: 'CDISettings',
@ -94,10 +90,10 @@ export default {
accessModeOptions() {
return [
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY },
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY },
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
];
},

View File

@ -3,9 +3,8 @@ 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, _EDIT } from '@shell/config/query-params';
import { _CREATE, _VIEW } 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';
@ -17,9 +16,7 @@ const {
CSI_NODE_PUBLISH_SECRET_NAME,
CSI_NODE_PUBLISH_SECRET_NAMESPACE,
CSI_NODE_STAGE_SECRET_NAME,
CSI_NODE_STAGE_SECRET_NAMESPACE,
CSI_NODE_EXPAND_SECRET_NAME,
CSI_NODE_EXPAND_SECRET_NAMESPACE
CSI_NODE_STAGE_SECRET_NAMESPACE
} = CSI_SECRETS;
export default {
@ -30,7 +27,6 @@ export default {
LabeledSelect,
LabeledInput,
RadioGroup,
Checkbox,
},
props: {
@ -61,10 +57,7 @@ export default {
};
}
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 };
return { };
},
computed: {
@ -105,11 +98,8 @@ export default {
}, []);
},
isEdit() {
return this.realMode === _EDIT;
},
isView() {
return this.realMode === _VIEW;
return this.mode === _VIEW;
},
migratableOptions() {
@ -162,10 +152,6 @@ export default {
}
},
enableOnlineExpansionVolumeEncryption() {
return this.value.expandOnlineEncryptedVolumeFeatureEnabled;
},
volumeEncryption: {
set(neu) {
this.value['parameters'] = {
@ -194,11 +180,6 @@ 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,
@ -206,8 +187,7 @@ 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,
...expandSecretParams,
[CSI_NODE_STAGE_SECRET_NAMESPACE]: namespace
};
}
},
@ -260,32 +240,6 @@ 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>
@ -383,16 +337,6 @@ 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

View File

@ -254,24 +254,15 @@ 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

View File

@ -6,7 +6,6 @@ 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';
@ -23,10 +22,8 @@ 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',
@ -41,7 +38,6 @@ export default {
Network,
Checkbox,
CpuMemory,
CpuModel,
CruResource,
CloudConfig,
LabeledSelect,
@ -52,7 +48,6 @@ export default {
UnitInput,
Banner,
KeyValue,
Filesystem,
},
mixins: [CreateEditView, VM_MIXIN],
@ -75,12 +70,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,
};
},
@ -97,10 +92,6 @@ export default {
secretNamePrefix() {
return this.templateValue?.metadata?.name;
},
filesystemEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem');
},
},
watch: {
@ -160,22 +151,9 @@ 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();
@ -356,19 +334,6 @@ 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')"
@ -471,17 +436,6 @@ 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"

View File

@ -2,7 +2,6 @@
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';
@ -33,7 +32,6 @@ const createObject = {
export default {
name: 'CreateRestore',
components: {
Checkbox,
Footer,
RadioGroup,
LabeledInput,
@ -251,17 +249,9 @@ 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

View File

@ -9,11 +9,8 @@ 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 { clone, get } from '@shell/utils/object';
import { 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';
@ -26,11 +23,9 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { isInternalStorageClass } from '../utils/storage-class';
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
export default {
name: 'HarvesterVolume',
@ -38,7 +33,6 @@ export default {
components: {
Banner,
Checkbox,
Tab,
UnitInput,
CruResource,
@ -85,7 +79,7 @@ export default {
if (this.mode === _CREATE) {
// default volumeMode to Block
this.value.spec.volumeMode = VOLUME_MODE.BLOCK;
this.value.spec.accessModes = [READ_WRITE_MANY];
this.value.spec.accessModes = ['ReadWriteMany'];
}
const storage = this.value?.spec?.resources?.requests?.storage || null;
@ -96,34 +90,14 @@ export default {
source,
storage,
imageId,
showAdvanced: false,
createWithDataVolume: false,
snapshots: [],
images: [],
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: {
@ -161,10 +135,6 @@ export default {
return Object.values(VOLUME_MODE);
},
accessModeOptions() {
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
},
imageOption() {
return sortBy(
this.images
@ -305,10 +275,6 @@ export default {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
},
isCreatePVCWithDataVolumeFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
},
isResizeDisabled() {
return (
!this.isLHV2VolExpansionFeatureEnabled &&
@ -357,7 +323,7 @@ export default {
getAccessMode() {
if (!this.longhornV2LVMSupport) {
return [READ_WRITE_MANY];
return ['ReadWriteMany'];
}
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@ -373,60 +339,8 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
}
return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
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();
},
@ -469,17 +383,9 @@ export default {
this.update();
},
generateYaml() {
this.update();
const out = saferDump(this.value);
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
return saferDump(this.buildDataVolumeObj());
}
const plain = clone(this.value);
delete plain.saveYaml;
return saferDump(plain);
return out;
},
}
};
@ -552,6 +458,18 @@ 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')"
@ -572,44 +490,6 @@ 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"

View File

@ -1,190 +0,0 @@
<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>

View File

@ -1,168 +0,0 @@
<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>

View File

@ -1,140 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
ResourceTabs,
Tab,
LabeledInput,
LabeledSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
eip: this.value?.spec?.eip || '',
internalCIDR: this.value?.spec?.internalCIDR || '',
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({ eips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IPTABLES_EIP }) });
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
eipOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const eips = this.$store.getters[`${ inStore }/all`](HCI.IPTABLES_EIP) || [];
return eips.map((eip) => ({
label: eip.id,
value: eip.id,
}));
},
},
methods: {
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.eip = this.eip;
this.value.spec.internalCIDR = this.internalCIDR;
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="basic"
:label="t('generic.basic')"
:weight="99"
>
<div class="mt-20">
<div class="row">
<div class="col span-12">
<LabeledSelect
v-model:value="eip"
class="mb-20"
:options="eipOptions"
:mode="mode"
:label="t('harvester.snat.eip.label')"
:placeholder="t('harvester.snat.eip.placeholder')"
required
/>
</div>
</div>
<div class="row">
<div class="col span-12">
<LabeledInput
v-model:value="internalCIDR"
class="mb-20"
:mode="mode"
:label="t('harvester.snat.internalCIDR.label')"
:placeholder="t('harvester.snat.internalCIDR.placeholder')"
required
/>
</div>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>

View File

@ -1,384 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import ArrayList from '@shell/components/form/ArrayList';
import InfoBox from '@shell/components/InfoBox';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import Tab from '@shell/components/Tabbed/Tab';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import ArrayListSelect from '@shell/components/form/ArrayListSelect';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { NODE } from '@shell/config/types';
import { HCI } from '../types';
export default {
emits: ['update:value'],
components: {
CruResource,
ArrayList,
InfoBox,
NameNsDescription,
ResourceTabs,
Tab,
LabeledSelect,
ArrayListSelect,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
}
},
data() {
return {
defaultInterface: this.value?.spec?.defaultInterface || '',
excludedNodes: this.value?.spec?.excludeNodes || [],
customInterfaces: this.value?.spec?.customInterfaces || [],
};
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
linkMonitors: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.LINK_MONITOR }),
});
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.updateBeforeSave);
}
},
computed: {
nodes() {
const inStore = this.$store.getters['currentProduct'].inStore;
const nodes = this.$store.getters[`${ inStore }/all`](NODE);
return nodes.filter((n) => n.isEtcd !== 'true');
},
nics() {
const inStore = this.$store.getters['currentProduct'].inStore;
const linkMonitor = this.$store.getters[`${ inStore }/byId`](HCI.LINK_MONITOR, 'nic') || {};
const linkStatus = linkMonitor?.status?.linkStatus || {};
const nodes = this.nodes.map((n) => n.id);
const out = [];
// Collect all nics from all nodes
Object.keys(linkStatus).map((nodeName) => {
if (nodes.includes(nodeName)) {
const nics = linkStatus[nodeName] || [];
nics.map((nic) => {
out.push({
...nic,
nodeName,
});
});
}
});
return out;
},
nicOptions() {
const out = [];
const seen = new Set();
(this.nics || []).forEach((nic) => {
if (!seen.has(nic.name)) {
seen.add(nic.name);
out.push({
label: nic.name,
value: nic.name,
});
}
});
return out.sort((a, b) => a.label.localeCompare(b.label));
},
nodeOptions() {
return this.nodes.map((node) => ({
label: node.id,
value: node.id,
}));
},
},
methods: {
removeCustomInterface(index) {
this.customInterfaces.splice(index, 1);
},
updateBeforeSave() {
if (!this.value.spec) {
this.value.spec = {};
}
this.value.spec.defaultInterface = this.defaultInterface;
this.value.spec.excludeNodes = this.excludedNodes;
this.value.spec.customInterfaces = (this.customInterfaces || [])
.filter((item) => item?.interface || (item?.nodes || []).length)
.map((item) => ({
interface: item.interface || '',
nodes: (item.nodes || []).filter((node) => !!node),
}))
.filter((item) => item.interface && item.nodes.length > 0);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
ref="nd"
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-conditions="false"
:need-related="false"
:need-events="false"
:side-tabs="true"
:mode="mode"
>
<Tab
name="Interfaces"
label="Interfaces"
:weight="99"
>
<LabeledSelect
v-model:value="defaultInterface"
class="mb-20"
required
:options="nicOptions"
:mode="mode"
:label="t('harvester.providerNetwork.defaultInterface.label')"
:placeholder="t('harvester.providerNetwork.defaultInterface.placeholder')"
/>
<hr class="section-divider" />
<ArrayList
v-model:value="customInterfaces"
class="mb-20 custom-interface-list"
:mode="mode"
:title="t('harvester.providerNetwork.customInterfaces.label')"
:protip="false"
:remove-allowed="false"
:initial-empty-row="true"
:default-add-value="{ interface: '', nodes: [] }"
>
<template #add="{ add }">
<div class="custom-interface-primary-add">
<button
type="button"
class="btn role-primary"
:disabled="mode === 'view'"
@click="add"
>
{{ t('harvester.providerNetwork.customInterfaces.addLabel') }}
</button>
</div>
</template>
<template #column-headers>
<div class="row custom-interface-header">
<div class="col span-6">
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
</div>
<div class="col span-6">
{{ t('harvester.providerNetwork.customInterfaces.nodes.label') }}
</div>
</div>
</template>
<template #columns="scope">
<InfoBox class="custom-interface-box">
<button
v-if="mode !== 'view'"
type="button"
class="role-link btn btn-sm remove"
@click="removeCustomInterface(scope.i)"
>
<i class="icon icon-x" />
</button>
<div class="custom-interface-content">
<div class="row custom-interface-row interface-row">
<div class="col span-12 interface-col">
<h3 class="mb-10">
{{ t('harvester.providerNetwork.customInterfaces.interface.label') }}
</h3>
<LabeledSelect
v-model:value="scope.row.value.interface"
class="mb-20"
:label="''"
:options="nicOptions"
:mode="mode"
:placeholder="t('harvester.providerNetwork.customInterfaces.interface.placeholder')"
/>
</div>
</div>
<div class="row custom-interface-row nodes-row">
<div class="col span-12">
<ArrayListSelect
v-model:value="scope.row.value.nodes"
:options="nodeOptions"
:mode="mode"
:disabled="mode === 'view'"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.providerNetwork.customInterfaces.nodes.addLabel'),
initialEmptyRow: true,
title: t('harvester.providerNetwork.customInterfaces.nodes.label'),
required: false,
protip: false,
}"
:select-props="{
placeholder: t('harvester.providerNetwork.customInterfaces.nodes.placeholder'),
disabled: mode === 'view',
}"
>
<template #add="{ add }">
<div class="custom-interface-add">
<button
type="button"
class="btn role-tertiary add"
:disabled="mode === 'view'"
@click="add"
>
{{ t('harvester.providerNetwork.customInterfaces.nodes.addLabel') }}
</button>
</div>
</template>
</ArrayListSelect>
</div>
</div>
</div>
</InfoBox>
</template>
</ArrayList>
</Tab>
<Tab
name="excludedNodes"
:label="t('harvester.providerNetwork.excludedNodes.label')"
:weight="98"
>
<div class="row">
<div class="col span-12">
<ArrayListSelect
v-model:value="excludedNodes"
:options="nodeOptions"
:disabled="mode === 'view'"
:mode="mode"
:enable-default-add-value="false"
:array-list-props="{
addLabel: t('harvester.providerNetwork.excludedNodes.addLabel'),
initialEmptyRow: true,
required: false,
protip: false,
}"
:select-props="{
placeholder: t('harvester.providerNetwork.excludedNodes.placeholder'),
disabled: mode === 'view',
}"
/>
</div>
</div>
</Tab>
</ResourceTabs>
</CruResource>
</template>
<style lang="scss" scoped>
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0 20px;
}
.custom-interface-header {
margin-bottom: 10px;
font-weight: 600;
}
.custom-interface-row {
align-items: flex-start;
}
.interface-row {
width: calc(100% - 90px);
margin-bottom: 10px;
}
.nodes-row {
align-items: flex-start;
}
.custom-interface-add {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
:deep(.nodes-row .array-list-select .box) {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
:deep(.nodes-row .array-list-select .box .remove) {
align-self: center;
}
.custom-interface-primary-add {
max-width: 100%;
}
.custom-interface-box {
position: relative;
width: 100%;
padding: 20px;
margin-bottom: 5px;
}
:deep(.custom-interface-list .box) {
grid-template-columns: 1fr;
}
.remove {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
padding: 0;
}
</style>

View File

@ -9,7 +9,7 @@ import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types';
import { set, remove } from '@shell/utils/object';
import { set } from '@shell/utils/object';
import ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types';
@ -43,20 +43,18 @@ export default {
created() {
const vpc = this.$route.query.vpc || '';
const enableDHCP = this.value?.spec?.enableDHCP || false;
const natOutgoing = this.value?.spec?.natOutgoing || false;
set(this.value.spec, 'enableDHCP', enableDHCP);
set(this.value, 'spec', this.value.spec || {
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
vpc,
gateway: '',
excludeIps: [],
private: false,
gatewayIP: '',
excludeIps: [],
private: false,
enableDHCP,
natOutgoing,
acls: []
acls: []
});
},
@ -64,9 +62,8 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
vlans: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN }),
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
};
await allHash(hash);
@ -132,22 +129,6 @@ export default {
label: n.id,
value: n.id,
}));
},
natOutgoingDisabled() {
// Disable the NAT Outgoing option when the subnet belongs to the ovn-cluster VPC and its name is join or ovn-default.
return this.value?.spec?.vpc === 'ovn-cluster' && ['join', 'ovn-default'].includes(this.value?.metadata?.name);
},
vlanOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
const options = vlans.map((vlan) => ({
label: vlan.id,
value: vlan.id,
}));
return [{ label: this.t('generic.none'), value: '' }, ...options];
}
},
@ -162,14 +143,6 @@ export default {
}
},
methods: {
onVlanChange(value) {
if (value === '') {
remove(this.value.spec, 'vlan');
} else {
set(this.value, 'spec.vlan', value);
}
},
async saveSubnet(buttonCb) {
const errors = [];
const name = this.value?.metadata?.name;
@ -291,17 +264,6 @@ export default {
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.vlan ?? ''"
class="mb-20"
:options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')"
:mode="mode"
@update:value="onVlanChange"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
@ -342,20 +304,6 @@ export default {
</Banner>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
v-model:value="value.spec.natOutgoing"
name="enableExternalConnectivity"
:disabled="natOutgoingDisabled"
:options="[true, false]"
:label="t('harvester.subnet.externalConnectivity.label')"
:labels="[t('generic.enabled'), t('generic.disabled')]"
:mode="mode"
:tooltip="t('harvester.subnet.externalConnectivity.tooltip')"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup

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