Compare commits

..
Author SHA1 Message Date
Alejandro Bonilla 28abf8922c fix: resolve backport conflict and keep highPerformanceStorage on v1.9.1
Keep the 'ReadWriteMany' literal for the root disk accessMode because
READ_WRITE_MANY is not defined in the mixin on release-harvester-v1.9,
and drop the duplicate v1.9.0 feature-flag entry added by the
cherry-pick; #1183 already gates highPerformanceStorage on v1.9.1.

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
2026-10-12 10:06:57 -04:00
Alejandro Bonilla d0bda99f00 feat: expose KubeVirt high-performance storage features in VM and Volume UI (#1133)
* feat: expose KubeVirt high-performance storage features in VM and Volume UI

Adds UI to configure KubeVirt high-performance disk/storage settings that
were previously only reachable via VMBuilder/Terraform:

- Per-volume "Storage Performance Options": a performance profile
  (Default / High Performance / Custom) exposing per-disk cache, io and
  dedicatedIOThread on all disk types (VM image/root, new, existing,
  container) and on the standalone Volume form.
- VM-wide "High Performance (I/O Threads and Multi-Queue)": blockMultiQueue
  and ioThreadsPolicy (+ supplemental-pool thread count), shown only in the
  VM section since these are domain-level and cannot be set per-PVC.
- Compatibility guardrails: io=native forces cache=none; cache=none on
  Filesystem volumeMode warns; blockMultiQueue requires a virtio disk.

Ref: harvester/harvester#11550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix: address review feedback on high-performance storage UI

Resolves the Copilot review comments on #1133:

- Gate the whole feature behind a new `highPerformanceStorage` release
  feature flag (v1.9.0) so older supported clusters never render the
  controls or receive unsupported KubeVirt fields.
- Force Cache Mode to "none" whenever Native I/O is selected, including
  from the "Default" ('') cache value, and disable every non-"none" cache
  option (Default included) while Native is active. Previously the empty
  cache value slipped past the guardrail and produced an invalid combo.
- Clear an already-enabled blockMultiQueue when the last virtio disk is
  removed, so a disabled checkbox can no longer persist an invalid setting.
- Strip stale cache/io/dedicatedIOThread fields from the merged disk spec
  when a regenerated disk no longer requests them, so switching a disk back
  to "Default" (or unchecking Dedicated I/O Thread) while editing no longer
  silently preserves the previous values.
- Expose the disclosure toggle's state to assistive tech via aria-expanded
  and an expand/collapse aria-label.
- Add unit tests for the profile transitions and the Native/cache guardrails.

Ref: harvester/harvester#11550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix: address second-round review on high-performance storage UI

Resolves the two Copilot findings on #1133 and votdev's UX feedback on
harvester/harvester#11550:

- Restore the disk's original bus when the performance profile goes back
  to "Default". The "High Performance" preset switches the bus to virtio,
  but leaving the preset only cleared cache/io/dedicatedIOThread, so a
  SATA/SCSI disk was silently left on virtio — a bus change that can make
  an existing guest unbootable. The pre-preset bus is now remembered and
  put back, unless the user picked a different bus themselves in the
  meantime.
- Pass the VM-wide performance values from the VM detail page to the
  shared Volume component. The mixin already parsed them via
  getInitConfig(), but the detail caller never forwarded them, so
  VMPerformanceOptions fell back to its prop defaults and hid itself in
  view mode — configured blockMultiQueue / ioThreadsPolicy settings were
  invisible. They are also refreshed in the value watcher so the panel
  does not go stale.
- Make the VM-wide "High Performance" panel expandable/collapsible,
  matching the per-volume "Storage Performance Options" disclosure. It
  stays collapsed unless the VM already has something configured, so the
  average user is not faced with specialist tuning controls by default.
- Fix the unit tests to use Vue Test Utils v2 mount options. They used
  the v1 top-level `mocks` key, which VTU 2.x ignores, so every case
  failed on mount. The repo has no jest config or test script, so this
  was not caught. Add coverage for the bus save/restore behaviour.

Ref: harvester/harvester#11550

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

---------

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 528255dea4)

# Conflicts:
#	pkg/harvester/mixins/harvester-vm/index.js
2026-10-12 02:52:38 +00:00
Harvester Bot fb190996b6 chore: bump version to 1.9.1-rc3 (#1188)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-10-08 15:28:10 +08:00
mergify[bot]andAlejandro Bonilla 1c8880d9b0 fix: gate high-performance storage on v1.9.1 (#1181) (#1183)
The storage performance options ship in Harvester v1.9.1, not v1.9.0.
Move highPerformanceStorage to the v1.9.1 feature flags so the UI does
not offer them on v1.9.0 clusters.


(cherry picked from commit da36db143f)

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Alejandro Bonilla <abonilla@suse.com>
2026-10-07 13:34:54 +08:00
mergify[bot]andfreeze bcd8929ba3 feat: drop striped LVM volume group type from v1.9.1 (#1167) (#1168)
The LVM add-on reaches GA in v1.9.1 and only supports dm-thin.
    Gate the striped option behind a v1.9.1 feature flag, default new
    LVM storage classes to dm-thin, and reset a cloned striped type so
    older clusters keep the current behaviour.

    Related issue: harvester/harvester#11731


(cherry picked from commit 3c92a06268)

Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-09-23 16:34:58 +08:00
Harvester Bot 24834396b9 chore: bump version to 1.9.1-rc2 (#1169)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-09-23 16:34:18 +08:00
mergify[bot]andfreeze 280878ebb3 ci: pin package version to the git tag for tag builds (#1165) (#1166)
Sprint tags (vX.Y.Z-dev-YYYYMMDD) only share a prefix with the
    x.y.z-dev version in package.json, so every sprint plugin reported
    the same version and the catalog image push hit "Cannot overwrite
    production image". Bump the tree to the tag before building.

    Related issue: harvester/harvester#11047


(cherry picked from commit 6256cdc97a)

Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
2026-09-22 19:42:17 +08:00
mergify[bot]andAndy Lee 51da6a551c chore: add v1.10.0 and v1.9.1 feature flags (#1156) (#1158)
(cherry picked from commit d05ec68e16)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-09-18 16:51:15 +08:00
Harvester Bot 010dd710fc chore: bump version to 1.9.1-rc1 (#1157)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-09-17 22:40:41 +08:00
Andy Leeandrenovate[bot] 788c583210 deps: update dependency qs to v6.16.0 [security] (backport #1134) (#1143)
* deps: update dependency qs to v6.16.0 [security] (#1134)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit bbe0c4643f)

# Conflicts:
#	yarn.lock

* fix: yarn.lock conflict

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 09:40:35 +08:00
mergify[bot]andAndy Lee ee8f214b7f fix: avoid userpreferences PUT flood from SideNav refresh trigger (#1119) (#1155)
kickSideNav toggled a dummy favorite via addFavorite/removeFavorite, which persist through prefs/set and issue a PUT /v1/userpreferences on every toggle, flooding the backend and surfacing 500 errors. Switch to the in-memory prefs/load mutation so the SideNav still re-renders without any network request.


(cherry picked from commit cb2cef5e73)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-09-16 09:35:45 +08:00
mergify[bot]andAndy Lee 27c1e64a05 feat: change logo based on server-flavor setting (#1151) (#1154)
* feat: use prime logo when server-flavor is prime



* feat: expose server-flavor setting



* refactor: copilot review



---------


(cherry picked from commit 45116b568a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-09-16 09:35:15 +08:00
mergify[bot]andVictor Chang 68d67a7cbc fix: require names for cloud configuration templates (#1138) (#1153)
(cherry picked from commit f42d798d10)

Signed-off-by: Victor Chang <t7902195204@gmail.com>
Co-authored-by: Victor Chang <t7902195204@gmail.com>
2026-09-16 09:29:39 +08:00
mergify[bot]andAndy Lee 6a7691456f feat: add rancher k3k addon UI page (#1137) (#1148)
* feat: add k3k addon UI



* feat: add rancher-k3k addon UI



* refactor: copilot review



* refactor: copilot review



* refactor: remove k3k edit page



* refactor: k3k addon description



---------


(cherry picked from commit 50b4cf5248)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-09-16 08:54:09 +08:00
Harvester Bot 66f6c36759 chore: bump version to 1.9.0 (#1152)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-09-15 21:24:41 +08:00
Harvester Bot eb77d03b97 chore: bump version to 1.9.0-rc7 (#1132)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-09-01 18:47:34 +08:00
22deb0e1f6 feat: LHv2: graduate from "Experimental" to "Preview" (#1129) (#1131)
In Harvester v1.9.0, we're moving LHv2 from "Experimental" to "Preview",
so I've added a new preview label to the settings interface.  I figure
given Experimental had the "error" color, I should make "Preview" use
the warning color, to make it a bit less alarming :-)

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



(cherry picked from commit 4fa6470343)

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Tim Serong <tserong@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-09-01 12:13:39 +10:00
mergify[bot]andAndy Lee f096147aa3 fix: vGPU doesn't display when creating harvester downstream cluster (#1123) (#1128)
* fix: vGPU doesn't display in edit VM page



* fix: align PCI passthrough action buttons height

Match the enable/disable passthrough and Actions dropdown buttons to 40px and move the selected-count label below them.



---------


(cherry picked from commit d550046189)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-25 16:50:27 +08:00
Harvester Bot d73a9413be chore: bump version to 1.9.0-rc6 (#1122)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-19 18:40:46 +08:00
mergify[bot]andPo Han Huang 63ff8ce8fe feat: improve readability for vm migration UI (#1112) (#1120)
(cherry picked from commit 5f32a9ade5)

Signed-off-by: pohanhuang <pohan.huang@suse.com>
Co-authored-by: Po Han Huang <pohan.huang@suse.com>
2026-08-18 12:41:02 +08:00
mergify[bot] b3b467f7b5 fix: exclude forklift namespace from system classification (#1114) (#1118) 2026-08-17 13:37:43 +08:00
mergify[bot]andAndy Lee 20557ee788 fix: ACCESS_MODE undefined error in forklift mapping page (#1111) (#1113)
(cherry picked from commit f63be73b11)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-14 22:49:01 +08:00
Harvester Bot 8ab432fb52 chore: bump version to 1.9.0-rc5 (#1107)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-12 14:54:23 +08:00
mergify[bot]andAndy Lee 61624dc0ff fix: prevent duplicated cloud-config for empty user data (#1109) (#1110)
* fix: prevent duplicated cloud-config for empty user data



* refactor: AI review



---------


(cherry picked from commit fabc1cec21)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-08-12 14:54:09 +08:00
mergify[bot] d1367fc9a9 fix: remove map ownerref writeback and delete maps via plan refs (#1105) (#1106) 2026-08-10 20:58:35 +08:00
mergify[bot]andrenovate[bot] 18c973c8c4 deps: update dependency @types/lodash to v4.17.25 (#1099) (#1104)
(cherry picked from commit 72282ae50e)

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

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


(cherry picked from commit 8fba4e7050)

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

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

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



* fix: move filter labels/schedule buttons position



* feat: improve serial console



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



* refactor: clear searach result after close



* refactor: fine tune serial console



---------


(cherry picked from commit f97f14c811)

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



* fix: guard onQuotasInput against undefined payload



---------


(cherry picked from commit f8a29a1c54)

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

    Related issue: harvester/harvester#9650


(cherry picked from commit 9a935434c2)

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

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



* feat: add access/ volume mode model



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



* fix: bugs in VM selection table



* refactor: copilot review



---------


(cherry picked from commit afe632b723)

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


(cherry picked from commit b87f367583)

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

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

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

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




(cherry picked from commit e013534373)

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

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

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

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

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

---------

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

* fix: CI_BRANCH

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

---------

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

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



* refactor: copilot review



---------


(cherry picked from commit a6bd7ec00e)

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


(cherry picked from commit 790cade04d)

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



* refactor: customize empty table rows message



* refactor: fixed the table column



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



* style: remove unneeded overflow css



---------


(cherry picked from commit 2903e86c02)

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



* refactor: wordings



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

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

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



---------


(cherry picked from commit 2d747e435a)

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


(cherry picked from commit a178302f73)

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

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

Related issue: harvester/harvester#10417


(cherry picked from commit 7a84e08f99)

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



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

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



---------


(cherry picked from commit 3d39626b93)

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

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



* refactor: add noopener noreferrer rel



---------


(cherry picked from commit 1c3b40b631)

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


(cherry picked from commit 87baf75301)

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

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



* refactor: modify duration column width



* refactor: copilot review comment



---------


(cherry picked from commit a10d651827)

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

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

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

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

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

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

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

Fixes harvester/harvester#11174




* refactor: copilot review



* refactor: remove console



---------



(cherry picked from commit 429d9e18ad)

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



* refactor: review comment



---------


(cherry picked from commit dc166d8fc9)

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



* refactor: not pre-select target namespace



* refactor: ignore option



* refactor: copilot review



---------


(cherry picked from commit 3ff4534294)

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



* refactor: copilot review



---------


(cherry picked from commit d4223ad2ae)

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



* feat: allow duration column to be sorted



---------


(cherry picked from commit 44297ada16)

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

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


(cherry picked from commit 2cefd7cb78)

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



* refactor: copilot review



* refactor: copilot review



---------


(cherry picked from commit fb6ffa3c0f)

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



* refactor: copilot review



---------


(cherry picked from commit e0436bff28)

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

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



* fix: rename advanced to configurations



* fix: fixed column width



* refactor: review comment



* revert: power on background color



---------


(cherry picked from commit 41cf44d231)

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

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



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



* feat(forklift): UI based on the FIGMA



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



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



* feat(forklift): Add the succeded state



* Merge the new APIs



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



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



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



* feat(forklift): Added pagination UI



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



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



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



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



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



* feat(forklift): Remove test flags



* feat(forklift): Changed based on review



* feat(forklift): fixed title and button



* feat(forklift): Design changes



* feat(forklift): Changed to Wizard



* feat(forklift): added the provider wizard



* feat(forklift): Fixed some UI changes



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



* feat(forklift): update size



* feat(forklift): small fixes



* feat(forklift): small fixes after Copilot



* feat(forklift): small fixes after Copilot



* feat(forklift): reverted settings.json changes



* feat(forklift): small fixes after Copilot



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



* feat(forklift): Fixed the type for VMware



* feat(forklift): small changes before rebase



* feat: Added new API for the vms



* feat: Fix some labels



* feat: Added some final fixes to structure



* feat: added loading to provider steps



* feat: added comments from copilot review



---------


(cherry picked from commit 560643d299)

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

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

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

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



* feat: Allow user to customize snapshot/backup deadline

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



---------


(cherry picked from commit d98db6ba49)

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



* feat: add feature flag



---------


(cherry picked from commit 531ed03796)

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

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-08 13:50:17 +08:00
48 changed files with 3375 additions and 2385 deletions
+3 -7
View File
@@ -8,16 +8,12 @@
- [ ] Yes, the backend owner is @ - [ ] Yes, the backend owner is @
- [ ] No, frontend-only. - [ ] No, frontend-only.
### Related Issue ### Related Issue #
<!-- Link the issue as harvester/harvester#<issue_number> --> <!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number> harvester/harvester#<issue_number>
### Test Steps ### Test screenshot or video (Required)
<!-- Provide the test steps for reviewer to verify the issue --> <!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
1. Go to ... page
### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison -->
@@ -125,6 +125,13 @@ jobs:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'yarn' cache: 'yarn'
# Tag builds (RC, formal and sprint releases) ship the tag as the package
# version. RC/formal tags already match package.json; sprint tags
# (vX.Y.Z-dev-YYYYMMDD) only share its prefix, so pin it here for the build.
- name: Pin package version to tag
if: ${{ inputs.CI_BUILD_TAG != '' }}
run: ./scripts/bump_version.sh "${{ inputs.CI_BUILD_TAG }}"
- id: ci-build-pkg - id: ci-build-pkg
name: Build pkg name: Build pkg
run: ./scripts/ci-build-pkg.sh harvester run: ./scripts/ci-build-pkg.sh harvester
@@ -58,6 +58,12 @@ jobs:
- name: Setup Nodejs with yarn install - name: Setup Nodejs with yarn install
uses: ./.github/actions/setup uses: ./.github/actions/setup
# The catalog image is tagged with the package.json version. Sprint tags
# (vX.Y.Z-dev-YYYYMMDD) only share its prefix, so pin it to the tag here.
- name: Pin package version to tag
if: ${{ github.ref_type == 'tag' }}
run: ./scripts/bump_version.sh "${{ github.ref_name }}"
- name: Build and push UI image - name: Build and push UI image
run: | run: |
publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}" publish="yarn publish-pkgs -cp -r ${{ inputs.registry_target }} -o ${{ inputs.registry_user }}"
@@ -1,7 +1,6 @@
name: Build Standalone on PR Merge name: Build Standalone on PR Merge
on: on:
workflow_dispatch: # manually trigger on Github Actions only support PR branch pushed to harvester/harvester-ui-extension
push: push:
branches: branches:
- main - main
@@ -30,35 +29,3 @@ jobs:
id-token: write id-token: write
with: with:
CI_BRANCH: ${{github.ref_name}} 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"
+5 -9
View File
@@ -1,14 +1,13 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.10.0-dev", "version": "1.9.1-rc3",
"private": false, "private": false,
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-class-static-block": "7.29.7", "@babel/plugin-transform-class-static-block": "7.29.7",
"@novnc/novnc": "1.7.0", "@rancher/shell": "3.0.12-rc.7",
"@rancher/shell": "3.0.13",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1", "@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5", "@vue-flow/core": "^1.33.5",
@@ -28,7 +27,6 @@
"yaml": "^2.5.1" "yaml": "^2.5.1"
}, },
"resolutions": { "resolutions": {
"@novnc/novnc": "1.7.0",
"@types/node": "25.9.5", "@types/node": "25.9.5",
"cronstrue": "2.59.0", "cronstrue": "2.59.0",
"d3-color": "3.1.0", "d3-color": "3.1.0",
@@ -50,17 +48,15 @@
"dev": "NODE_ENV=dev ./node_modules/.bin/vue-cli-service serve", "dev": "NODE_ENV=dev ./node_modules/.bin/vue-cli-service serve",
"build": "./node_modules/.bin/vue-cli-service build", "build": "./node_modules/.bin/vue-cli-service build",
"clean": "./node_modules/@rancher/shell/scripts/clean", "clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/@rancher/shell/scripts/lint --max-warnings 0 --ext .js,.ts,.vue .", "lint": "./node_modules/.bin/eslint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/@rancher/shell/scripts/lint --fix --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", "build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs", "serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish", "publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
"parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name", "parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name",
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"prepare": "husky", "prepare": "husky",
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh", "agents:generate": "./scripts/generate-agent-and-persona-mds.sh"
"prelint": "npm --prefix node_modules/@rancher/shell/eslint-legacy install eslint-plugin-jest@^24 --no-save --no-audit --no-fund --legacy-peer-deps --loglevel=error",
"prelint:fix": "npm --prefix node_modules/@rancher/shell/eslint-legacy install eslint-plugin-jest@^24 --no-save --no-audit --no-fund --legacy-peer-deps --loglevel=error"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/load": "^19.8.1", "@commitlint/load": "^19.8.1",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

+4 -2
View File
@@ -10,6 +10,7 @@ import { docLink } from '../utils/feature-flags';
const CATEGORY = { const CATEGORY = {
ui: [ ui: [
'branding', 'branding',
HCI_SETTING.SERVER_FLAVOR,
'ui-source', 'ui-source',
'ui-index', 'ui-index',
] ]
@@ -53,7 +54,7 @@ export default {
}; };
}, },
computed: { ...mapGetters({ t: 'i18n/t' }) }, computed: { ...mapGetters({ t: 'i18n/t', isHarvesterPrime: 'harvester-common/isHarvesterPrime' }) },
watch: { watch: {
settings: { settings: {
@@ -269,7 +270,8 @@ export default {
v-if="!setting.hide" v-if="!setting.hide"
class="settings-value" class="settings-value"
> >
<pre v-if="setting.kind === 'json'">{{ setting.json }}</pre> <pre v-if="setting.id === 'branding' && isHarvesterPrime">{{ $store.getters['harvester-common/privateLabel'] }}</pre>
<pre v-else-if="setting.kind === 'json'">{{ setting.json }}</pre>
<pre v-else-if="setting.kind === 'multiline'">{{ setting.data.value || setting.data.default }}</pre> <pre v-else-if="setting.kind === 'multiline'">{{ setting.data.value || setting.data.default }}</pre>
<pre v-else-if="setting.kind === 'enum'">{{ t(setting.enum) }}</pre> <pre v-else-if="setting.kind === 'enum'">{{ t(setting.enum) }}</pre>
<pre v-else-if="setting.kind === 'custom' && setting.custom">{{ setting.custom }}</pre> <pre v-else-if="setting.kind === 'custom' && setting.custom">{{ setting.custom }}</pre>
+120 -374
View File
@@ -1,333 +1,8 @@
<script>
import BrandImage from '@shell/components/BrandImage';
import ZoomableRFB from './ZoomableRFB';
export const CONSOLE_STATUS = {
CONNECTING: 'connecting',
CONNECTED: 'connected',
RECONNECTING: 'reconnecting',
DISCONNECTED: 'disconnected',
FAILED: 'failed',
};
const MAXIMUM_RETRY_TIMES = 10;
const BASE_RECONNECT_DELAY = 1000;
const MAXIMUM_RECONNECT_DELAY = 30000;
const clampLevel = (value, fallback) => {
const level = Number.parseInt(value, 10);
if (Number.isNaN(level)) {
return fallback;
}
return Math.min(Math.max(level, 0), 9);
};
export default {
name: 'NovncConsole',
components: { BrandImage },
props: {
url: {
type: String,
default: ''
},
settings: {
type: Object,
default: () => ({})
},
},
emits: ['status-changed', 'capabilities'],
data() {
return {
rfb: null,
status: CONSOLE_STATUS.CONNECTING,
statusDetail: '',
desktopName: '',
retryTimes: 0,
maximumRetryTimes: MAXIMUM_RETRY_TIMES,
reconnectTimer: null,
isClosed: false,
};
},
computed: {
statusInfo() {
return {
status: this.status,
detail: this.statusDetail,
desktopName: this.desktopName,
retryTimes: this.retryTimes,
maximumRetryTimes: this.maximumRetryTimes,
};
},
showError() {
return this.status === CONSOLE_STATUS.FAILED;
},
showReconnecting() {
return this.status === CONSOLE_STATUS.RECONNECTING;
},
},
watch: {
url() {
this.reconnect();
},
settings: {
deep: true,
handler: 'applySettings'
},
statusInfo: {
immediate: true,
handler(neu) {
this.$emit('status-changed', neu);
}
},
},
mounted() {
this.$nextTick(() => {
this.connect();
});
},
beforeUnmount() {
this.isClosed = true;
this.clearReconnectTimer();
this.teardown();
},
methods: {
connect(takeOver = false) {
if (!this.url || this.isClosed) {
return;
}
this.teardown();
this.status = this.retryTimes > 0 ? CONSOLE_STATUS.RECONNECTING : CONSOLE_STATUS.CONNECTING;
let rfb;
try {
const url = new URL(this.url);
url.searchParams.set('preserveSession', takeOver === true ? 'false' : 'true');
rfb = new ZoomableRFB(this.$refs.view, url.href, { shared: true });
} catch (err) {
this.statusDetail = err?.message || `${ err }`;
this.status = CONSOLE_STATUS.FAILED;
return;
}
rfb.addEventListener('connect', this.onConnect);
rfb.addEventListener('disconnect', this.onDisconnect);
rfb.addEventListener('securityfailure', this.onSecurityFailure);
rfb.addEventListener('credentialsrequired', this.onCredentialsRequired);
rfb.addEventListener('desktopname', this.onDesktopName);
rfb.addEventListener('capabilities', this.onCapabilities);
this.rfb = rfb;
this.applySettings();
},
reconnect() {
this.clearReconnectTimer();
this.isClosed = false;
this.retryTimes = 0;
this.statusDetail = '';
this.connect();
},
takeOver() {
if (!window.confirm(this.t('harvester.virtualMachine.detail.console.takeOver.confirm'))) {
return;
}
this.clearReconnectTimer();
this.isClosed = false;
this.retryTimes = 0;
this.statusDetail = '';
this.connect(true);
},
disconnect() {
this.isClosed = true;
this.clearReconnectTimer();
this.teardown();
this.status = CONSOLE_STATUS.DISCONNECTED;
},
onConnect() {
this.clearReconnectTimer();
this.retryTimes = 0;
this.statusDetail = '';
this.status = CONSOLE_STATUS.CONNECTED;
this.applySettings();
},
onDisconnect() {
const wasConnected = this.status === CONSOLE_STATUS.CONNECTED;
this.teardown(false);
if (this.isClosed) {
if (this.status !== CONSOLE_STATUS.FAILED) {
this.status = CONSOLE_STATUS.DISCONNECTED;
}
return;
}
if (wasConnected) {
this.scheduleReconnect();
} else {
this.clearReconnectTimer();
this.statusDetail = this.t('harvester.virtualMachine.detail.console.status.unavailable');
this.status = CONSOLE_STATUS.FAILED;
}
},
// Authentication problems will not fix themselves, so stop retrying.
onSecurityFailure(e) {
this.statusDetail = e?.detail?.reason || '';
this.isClosed = true;
this.clearReconnectTimer();
this.status = CONSOLE_STATUS.FAILED;
},
onCredentialsRequired() {
this.statusDetail = this.t('harvester.virtualMachine.detail.console.status.credentialsRequired');
this.isClosed = true;
this.clearReconnectTimer();
this.status = CONSOLE_STATUS.FAILED;
},
onDesktopName(e) {
this.desktopName = e?.detail?.name || '';
},
onCapabilities(e) {
this.$emit('capabilities', e?.detail?.capabilities || {});
},
scheduleReconnect() {
this.clearReconnectTimer();
if (this.isClosed) {
return;
}
if (this.retryTimes >= this.maximumRetryTimes) {
this.status = CONSOLE_STATUS.FAILED;
return;
}
this.retryTimes += 1;
this.status = CONSOLE_STATUS.RECONNECTING;
const delay = Math.min(BASE_RECONNECT_DELAY * (2 ** (this.retryTimes - 1)), MAXIMUM_RECONNECT_DELAY);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
},
clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
},
teardown(disconnect = true) {
const rfb = this.rfb;
if (!rfb) {
return;
}
this.rfb = null;
rfb.removeEventListener('connect', this.onConnect);
rfb.removeEventListener('disconnect', this.onDisconnect);
rfb.removeEventListener('securityfailure', this.onSecurityFailure);
rfb.removeEventListener('credentialsrequired', this.onCredentialsRequired);
rfb.removeEventListener('desktopname', this.onDesktopName);
rfb.removeEventListener('capabilities', this.onCapabilities);
if (!disconnect) {
return;
}
try {
rfb.disconnect();
} catch (err) {
// The socket may already be gone, nothing left to clean up.
}
},
applySettings() {
const rfb = this.rfb;
if (!rfb) {
return;
}
const settings = this.settings || {};
rfb.viewOnly = !!settings.viewOnly;
rfb.scaleViewport = !!settings.scaleViewport;
rfb.resizeSession = !!settings.resizeSession;
rfb.clipViewport = !!settings.clipViewport;
rfb.dragViewport = !!settings.dragViewport;
rfb.zoomPercent = settings.zoomPercent;
rfb.focusOnClick = settings.focusOnClick !== false;
rfb.qualityLevel = clampLevel(settings.qualityLevel, 6);
rfb.compressionLevel = clampLevel(settings.compressionLevel, 2);
},
ctrlAltDelete() {
this.rfb?.sendCtrlAltDel();
},
sendKey(keysym, code, down) {
this.rfb?.sendKey(keysym, code, down);
},
}
};
</script>
<template> <template>
<div class="novnc-console"> <div>
<div <div v-if="connected && disconnected">
ref="view" <main class="main-layout error">
class="novnc-screen" <div class="text-center">
/>
<div
v-if="showError || showReconnecting"
class="novnc-overlay"
>
<div
v-if="showError"
class="text-center"
>
<BrandImage <BrandImage
file-name="error-desert-landscape.svg" file-name="error-desert-landscape.svg"
width="900" width="900"
@@ -339,69 +14,140 @@ export default {
<h2 class="text-secondary mt-20"> <h2 class="text-secondary mt-20">
{{ t('vncConsole.error.message') }} {{ t('vncConsole.error.message') }}
</h2> </h2>
<p
v-if="statusDetail"
class="text-muted mt-10"
>
{{ statusDetail }}
</p>
<button
class="btn role-primary mt-20"
@click="reconnect"
>
{{ t('harvester.action.reconnect') }}
</button>
<button
v-if="!isClosed"
class="btn role-secondary mt-20 ml-10"
@click="takeOver"
>
{{ t('harvester.virtualMachine.detail.console.takeOver.label') }}
</button>
</div> </div>
</main>
<h2 </div>
v-else <div v-if="reconnecting">
class="text-secondary" <main class="main-layout">
> <div class="text-center">
<h2 class="text-secondary mt-20">
{{ t('vncConsole.reconnecting.message') }}:{{ retryTimes }} of {{ maximumRetryTimes }} {{ t('vncConsole.reconnecting.message') }}:{{ retryTimes }} of {{ maximumRetryTimes }}
</h2> </h2>
</div> </div>
</main>
</div>
<div
ref="view"
/>
</div> </div>
</template> </template>
<script>
import RFB from '@novnc/novnc/core/rfb';
import BrandImage from '@shell/components/BrandImage';
export default {
props: {
url: {
type: String,
default: ''
}
},
components: { BrandImage },
data() {
return {
rfb: null,
connected: false,
disconnected: false,
reconnectDelay: 3000,
reconnecting: false,
maximumRetryTimes: 10,
retryTimes: 0,
setTimeout: null,
};
},
mounted() {
this.$nextTick(() => {
this.connect();
});
},
beforeUnmount() {
this.clearTimeout();
},
methods: {
connect() {
const rfb = new RFB(this.$refs.view, this.url);
rfb.addEventListener('connect', () => {
this.clearTimeout();
this.connected = true;
this.retryTimes = 0;
this.reconnecting = false;
});
rfb.addEventListener('disconnect', (e) => {
this.clearTimeout();
this.disconnected = true;
this.rfb = null;
this.reconnect();
});
this.rfb = rfb;
},
reconnect() {
if (this.retryTimes >= this.maximumRetryTimes) {
this.reconnecting = false;
this.connected = true;
this.disconnected = true;
return;
}
this.retryTimes += 1;
this.reconnecting = true;
this.connected = false;
this.disconnected = false;
this.setTimeout = setTimeout(() => {
this.connect();
}, this.reconnectDelay);
},
clearTimeout() {
if (this.setTimeout) {
clearTimeout(this.setTimeout);
}
},
disconnect() {
this.rfb.disconnect();
},
ctrlAltDelete() {
this.rfb.sendCtrlAltDel();
},
sendKey(keysym, code, down) {
this.rfb.sendKey(keysym, code, down);
}
}
};
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.novnc-console { .error {
position: relative;
box-sizing: border-box;
height: 100%;
padding: 8px;
overflow: hidden; overflow: hidden;
}
.novnc-screen { .row {
height: 100%;
}
.novnc-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center; align-items: center;
justify-content: center; }
overflow: auto;
background: rgba(0, 0, 0, 0.75);
h1 { h1 {
font-size: 5rem; font-size: 5rem;
} }
.desert-landscape {
img { img {
max-width: 100%; max-width: 100%;
} }
} }
}
</style> </style>
@@ -0,0 +1,264 @@
<script>
import { STEVE } from '@shell/config/types';
import Banner from '@components/Banner/Banner.vue';
import AsyncButton from '@shell/components/AsyncButton';
import ModalWithCard from '@shell/components/ModalWithCard';
const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
export default {
name: 'NovncConsoleCustomKeys',
emits: ['close'],
components: {
ModalWithCard, Banner, AsyncButton
},
data() {
return {
keysRecord: [],
addedShortcutKeys: [],
preferredShortcutKeys: [],
isRecording: false,
};
},
computed: {
savedShortcutKeys() {
const preference = this.$store.getters['management/all'](STEVE.PREFERENCE);
const preferedShortcutKeys = preference?.[0]?.data?.[PREFERED_SHORTCUT_KEYS];
let out = [];
if (!preferedShortcutKeys) {
return out;
}
try {
out = JSON.parse(preferedShortcutKeys);
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: this.t('harvester.virtualMachine.detail.console.customShortcutKeys') }),
err,
}, { root: true });
}
return out;
},
displayedKeys() {
const out = this.addedShortcutKeys.concat(this.preferredShortcutKeys).map((item) => {
const out = item.map((K) => ` <code>${ K.key.charAt(0).toUpperCase() + K.key.slice(1) }</code>`);
return out.join(',');
});
return out;
},
recordButton() {
if (this.isRecording) {
return 'harvester.virtualMachine.detail.console.record.stop';
}
return 'harvester.virtualMachine.detail.console.record.start';
},
keysRecordFormat() {
if (!this.isRecording && this.keysRecord.length === 0) {
return this.t('harvester.virtualMachine.detail.console.record.tips');
}
const out = this.keysRecord.map((item) => ` <code>${ item.key.charAt(0).toUpperCase() + item.key.slice(1) }</code>`);
return `Keys: ${ out.join(',') }`;
},
canAdd() {
const hasRecord = this.keysRecord.length > 0;
let validationList = [].concat(this.preferredShortcutKeys, this.addedShortcutKeys);
if (!hasRecord) {
return false;
}
validationList.push(this.keysRecord);
validationList = validationList.map((item) => {
const out = item.map((K) => K.key);
return out.join(',');
});
return validationList.length === new Set(validationList).size;
},
},
watch: {
savedShortcutKeys: {
handler() {
this.preferredShortcutKeys = [].concat(this.savedShortcutKeys) || [];
},
immediate: true
},
},
methods: {
closeRecordingModal() {
window.removeEventListener('keydown', this.handleShortcut);
this.$emit('close');
},
toggleRecording() {
this.isRecording = !this.isRecording;
if (this.isRecording) {
this.keysRecord = [];
window.addEventListener('keydown', this.handleShortcut);
} else {
window.removeEventListener('keydown', this.handleShortcut);
}
},
handleShortcut(event) {
event.preventDefault();
const {
key, keyCode, code, location, charCode
} = event;
this.keysRecord.push({
key, keyCode, code, location, charCode
});
},
addShortcutKey() {
this.addedShortcutKeys.push([].concat(this.keysRecord));
},
removeKey(keys) {
const key = keys.replace(/(\s*)<code>|<\/code>/g, '').replace(/\s*,\s*/g, ',');
this.addedShortcutKeys = this.addedShortcutKeys.filter((item) => {
const formatkey = item.map((K) => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join(',');
return formatkey !== key;
});
this.preferredShortcutKeys = this.preferredShortcutKeys.filter((item) => {
const formatkey = item.map((K) => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join(',');
return formatkey !== key;
});
},
async saveKeys(buttonCb) {
const out = [].concat(this.preferredShortcutKeys, this.addedShortcutKeys);
const preference = this.$store.getters['management/all'](STEVE.PREFERENCE)?.[0];
try {
preference.data[PREFERED_SHORTCUT_KEYS] = JSON.stringify(out);
await preference.save();
this.closeRecordingModal();
buttonCb(true);
} catch (err) {
buttonCb(false);
}
},
}
};
</script>
<template>
<ModalWithCard
name="recordShortcutKeys"
:width="550"
>
<template #title>
<t k="harvester.virtualMachine.detail.console.customShortcutKeys" />
</template>
<template #content>
<div class="row">
<div class="col span-12">
<Banner color="info">
<span v-clean-html="keysRecordFormat"></span>
</Banner>
</div>
</div>
<div class="row">
<div class="col span-12">
<button
class="btn bg-primary"
@click="toggleRecording"
>
<t :k="recordButton" />
<i
class="icon icon-fw"
:class="isRecording ? 'icon-dot-open' : 'icon-dot'"
/>
</button>
<button
:disabled="!canAdd"
class="btn bg-primary"
@click="addShortcutKey"
>
<t k="generic.add" />
</button>
</div>
</div>
<hr>
<div class="displayed-keys mt-20">
<h4
v-clean-html="t('harvester.virtualMachine.detail.console.record.preferredKeys')"
class="text-default-text"
/>
<div class="displayed-banners">
<Banner
v-for="(keys,index) in displayedKeys"
:key="index"
>
<span v-clean-html="keys"></span>
</Banner>
</div>
</div>
</template>
<template #footer>
<div class="actions">
<button
class="btn role-secondary mr-20"
@click.prevent="closeRecordingModal"
>
<t k="generic.close" />
</button>
<AsyncButton
mode="done"
@click="saveKeys"
/>
</div>
</template>
</ModalWithCard>
</template>
<style lang="scss" scoped>
.displayed-keys {
.banner {
margin: 0;
}
}
.displayed-banners {
max-height: 155px;
overflow: auto;
}
.actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,26 +1,15 @@
<script> <script>
import { STEVE } from '@shell/config/types';
import { escapeHtml } from '@shell/utils/string'; import { escapeHtml } from '@shell/utils/string';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import KeyTable from '@novnc/novnc/core/input/keysym'; import KeyTable from '@novnc/novnc/core/input/keysym';
import * as KeyboardUtil from '@novnc/novnc/core/input/util';
import { HCI } from '../../types'; import { HCI } from '../../types';
import NovncConsole from './NovncConsole'; import NovncConsole from './NovncConsole';
import NovncConsoleItem from './NovncConsoleItem'; import NovncConsoleItem from './NovncConsoleItem';
import { normalizeZoom } from './ZoomableRFB'; import NovncConsoleCustomKeys from './NovncConsoleCustomKeys';
const CONSOLE_SETTINGS_KEY = 'harvester-vnc-console-settings'; const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
const VIEW_MODE = {
SCALE: 'scale',
NONE: 'none',
};
const DEFAULT_SETTINGS = {
viewMode: VIEW_MODE.NONE,
zoomPercent: 100,
viewOnly: false,
qualityLevel: 6,
compressionLevel: 2,
};
const SHORT_KEYS = { const SHORT_KEYS = {
ControlLeft: { ControlLeft: {
@@ -116,7 +105,9 @@ const F_KEYS = {
export default { export default {
name: 'NovncConsoleWrapper', name: 'NovncConsoleWrapper',
components: { NovncConsole, NovncConsoleItem }, components: {
NovncConsole, NovncConsoleItem, NovncConsoleCustomKeys
},
async fetch() { async fetch() {
const _hash = { vmResource: this.$store.dispatch('harvester/find', { type: HCI.VM, id: this.value.id }) }; const _hash = { vmResource: this.$store.dispatch('harvester/find', { type: HCI.VM, id: this.value.id }) };
@@ -140,21 +131,39 @@ export default {
return { return {
keysRecord: [], keysRecord: [],
vmResource: {}, vmResource: {},
settings: { ...DEFAULT_SETTINGS }, renderKeysModal: false,
capabilities: {}, currentUser: null,
isFullscreen: false, hideCustomKeysBar: false,
consoleStatus: {
status: 'connecting',
retryTimes: 0,
maximumRetryTimes: 0,
desktopName: '',
},
}; };
}, },
computed: { computed: {
canSendKeys() { savedShortcutKeys() {
return this.consoleStatus.status === 'connected' && !this.settings.viewOnly; const preference = this.$store.getters['management/all'](STEVE.PREFERENCE);
const preferedShortcutKeys = preference?.[0]?.data?.[PREFERED_SHORTCUT_KEYS];
let out = [];
if (!preference?.[0]?.data) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.hideCustomKeysBar = true;
return out;
}
if (!preferedShortcutKeys) {
return out;
}
try {
out = JSON.parse(preferedShortcutKeys);
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.value.metadata.name) }),
err,
}, { root: true });
}
return out;
}, },
isDown() { isDown() {
@@ -183,16 +192,13 @@ export default {
...F_KEYS, ...F_KEYS,
}; };
out.AltLeft = { ...SHORT_KEYS.AltLeft, keys: { PrintScreen: FUNCTION_KEYS.PrintScreen, ...F_KEYS } }; out.AltLeft.keys = { PrintScreen: FUNCTION_KEYS.PrintScreen, ...F_KEYS };
out.ControlLeft = { out.ControlLeft.keys = {
...SHORT_KEYS.ControlLeft,
keys: {
AltLeft: { AltLeft: {
...SHORT_KEYS.AltLeft, ...Object.assign(SHORT_KEYS.AltLeft, {}),
keys: { Delete: FUNCTION_KEYS.Delete } keys: { Delete: FUNCTION_KEYS.Delete }
}, },
...NORMAL_KEYS, ...NORMAL_KEYS,
},
}; };
return out; return out;
@@ -202,53 +208,14 @@ export default {
return !!this.vmResource?.actions?.softreboot; return !!this.vmResource?.actions?.softreboot;
}, },
rfbSettings() { preferredShortcutKeys() {
return (this.savedShortcutKeys || []).map((item) => {
return { return {
viewOnly: this.settings.viewOnly, label: item.map((K) => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join('+'),
scaleViewport: this.settings.viewMode === VIEW_MODE.SCALE, value: item
zoomPercent: normalizeZoom(this.settings.zoomPercent),
resizeSession: false,
clipViewport: false,
dragViewport: false,
qualityLevel: this.settings.qualityLevel,
compressionLevel: this.settings.compressionLevel,
}; };
});
}, },
viewModeOptions() {
return Object.values(VIEW_MODE).map((value) => ({
value,
label: this.t(`harvester.virtualMachine.detail.console.settings.viewMode.${ value }`)
}));
},
statusLabel() {
const { status, retryTimes, maximumRetryTimes } = this.consoleStatus;
if (status === 'reconnecting') {
return this.t('harvester.virtualMachine.detail.console.status.reconnecting', { retryTimes, maximumRetryTimes });
}
return this.t(`harvester.virtualMachine.detail.console.status.${ status }`);
},
},
watch: {
settings: {
deep: true,
handler(neu) {
window.localStorage.setItem(CONSOLE_SETTINGS_KEY, JSON.stringify(neu));
}
},
},
mounted() {
this.loadSettings();
document.addEventListener('fullscreenchange', this.onFullscreenChange);
},
beforeUnmount() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}, },
methods: { methods: {
@@ -266,97 +233,53 @@ export default {
// Send function key, e.g. ALT + F // Send function key, e.g. ALT + F
sendKeys() { sendKeys() {
if (!this.canSendKeys || !this.$refs.novncConsole) {
return;
}
this.keysRecord.forEach((key) => { this.keysRecord.forEach((key) => {
this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, true); this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, true);
}); });
[...this.keysRecord].reverse().forEach((key) => { this.keysRecord.reverse().forEach((key) => {
this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, false); this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, false);
}); });
this.$refs.popover.hide(); this.$refs.popover.isOpen = false;
this.keysRecord = []; this.keysRecord = [];
}, },
sendCustomKeys(keys) {
const keyList = [].concat(keys);
keyList.forEach((K) => {
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), true);
});
keyList.reverse().forEach((K) => {
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), false);
});
},
reconnect() { reconnect() {
this.$refs.novncConsole.reconnect(); this.$refs.novncConsole.reconnect();
}, },
loadSettings() {
try {
const saved = JSON.parse(window.localStorage.getItem(CONSOLE_SETTINGS_KEY) || '{}');
this.settings = {
viewMode: Object.values(VIEW_MODE).includes(saved?.viewMode) ? saved.viewMode : DEFAULT_SETTINGS.viewMode,
zoomPercent: normalizeZoom(saved?.zoomPercent ?? DEFAULT_SETTINGS.zoomPercent),
viewOnly: saved?.viewOnly === true,
qualityLevel: saved?.qualityLevel ?? DEFAULT_SETTINGS.qualityLevel,
compressionLevel: saved?.compressionLevel ?? DEFAULT_SETTINGS.compressionLevel,
};
} catch (err) {
this.settings = { ...DEFAULT_SETTINGS };
}
},
onStatusChanged(info) {
this.consoleStatus = info;
},
onCapabilities(capabilities) {
this.capabilities = capabilities;
},
ctrlAltDelete() {
if (this.canSendKeys) {
this.$refs.novncConsole?.ctrlAltDelete();
}
},
async toggleFullscreen() {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.$refs.consoleContainer.requestFullscreen();
// Keyboard lock lets shortcuts such as Ctrl+W and F11 reach the guest.
await navigator.keyboard?.lock?.(['KeyW', 'F11']);
}
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.value.metadata.name) }),
err,
}, { root: true });
}
},
onFullscreenChange() {
this.isFullscreen = !!document.fullscreenElement;
if (!this.isFullscreen) {
navigator.keyboard?.unlock?.();
}
},
softReboot() { softReboot() {
this.vmResource.doSoftReboot(); this.vmResource.doSoftReboot();
}, },
showKeysModal() {
this.renderKeysModal = true;
},
hideKeysModal() {
this.renderKeysModal = false;
},
} }
}; };
</script> </script>
<template> <template>
<div id="app"> <div id="app">
<div <div class="vm-console">
ref="consoleContainer"
class="vm-console"
>
<div class="combination-keys"> <div class="combination-keys">
<div class="toolbar-group">
<v-dropdown <v-dropdown
ref="popover" ref="popover"
placement="top" placement="top"
@@ -364,10 +287,7 @@ export default {
:container="false" :container="false"
@auto-hide="keysRecord = []" @auto-hide="keysRecord = []"
> >
<button <button class="btn btn-sm bg-primary">
class="btn btn-sm bg-primary"
:disabled="!canSendKeys"
>
{{ t("harvester.virtualMachine.detail.console.shortcutKeys") }} {{ t("harvester.virtualMachine.detail.console.shortcutKeys") }}
</button> </button>
@@ -382,74 +302,6 @@ export default {
</template> </template>
</v-dropdown> </v-dropdown>
<button
class="btn btn-sm bg-primary"
:disabled="!canSendKeys"
@click="ctrlAltDelete"
>
Ctrl+Alt+Del
</button>
<v-dropdown
placement="top"
trigger="click"
:container="false"
>
<button class="btn btn-sm bg-primary">
{{ t("harvester.virtualMachine.detail.console.settings.label") }}
</button>
<template #popper>
<div class="console-panel">
<label>{{ t('harvester.virtualMachine.detail.console.settings.viewMode.label') }}</label>
<div
v-for="option in viewModeOptions"
:key="option.value"
>
<label class="console-panel__option">
<input
v-model="settings.viewMode"
type="radio"
:value="option.value"
>
{{ option.label }}
</label>
</div>
<label
for="vnc-zoom"
class="console-panel__zoom-label"
>
<span>{{ t('harvester.virtualMachine.detail.console.settings.zoom') }}</span>
<span>{{ settings.zoomPercent }}%</span>
</label>
<input
id="vnc-zoom"
v-model.number="settings.zoomPercent"
class="console-panel__zoom"
type="range"
min="50"
max="200"
step="10"
:disabled="settings.viewMode === 'scale'"
:aria-valuetext="`${ settings.zoomPercent }%`"
>
<label class="console-panel__option">
<input
v-model="settings.viewOnly"
type="checkbox"
>
{{ t('harvester.virtualMachine.detail.console.settings.viewOnly') }}
</label>
</div>
</template>
</v-dropdown>
</div>
<div class="toolbar-group">
<span class="console-status">{{ statusLabel }}</span>
<button <button
v-if="hasSoftRebootAction" v-if="hasSoftRebootAction"
class="btn btn-sm bg-primary" class="btn btn-sm bg-primary"
@@ -465,22 +317,53 @@ export default {
{{ t("harvester.action.reconnect") }} {{ t("harvester.action.reconnect") }}
</button> </button>
<v-dropdown
v-if="!hideCustomKeysBar"
ref="customKeyPopover"
placement="top"
trigger="click"
:container="false"
>
<button class="btn btn-sm bg-primary">
{{ t("harvester.virtualMachine.detail.console.customShortcutKeys") }}
</button>
<template #popper>
<div>
<button <button
class="btn btn-sm bg-primary" class="btn btn-sm bg-primary"
@click="toggleFullscreen" @click="showKeysModal"
> >
{{ isFullscreen ? t('harvester.virtualMachine.detail.console.fullscreen.exit') : t('harvester.virtualMachine.detail.console.fullscreen.enter') }} {{ t("harvester.virtualMachine.detail.console.management") }}
</button> </button>
</div> </div>
</div>
<hr>
<div
v-for="(keys, index) in preferredShortcutKeys"
:key="index"
>
<button
class="btn btn-sm bg-primary"
@click="sendCustomKeys(keys.value)"
>
{{ keys.label }}
</button>
</div>
</template>
</v-dropdown>
<NovncConsoleCustomKeys
v-if="renderKeysModal"
:current-user="currentUser"
@close="hideKeysModal"
/>
</div>
<NovncConsole <NovncConsole
v-if="url && !isDown" v-if="url && !isDown"
ref="novncConsole" ref="novncConsole"
:url="url" :url="url"
:settings="rfbSettings"
@status-changed="onStatusChanged"
@capabilities="onCapabilities"
/> />
<p v-if="isDown"> <p v-if="isDown">
{{ t("harvester.virtualMachine.detail.console.down") }} {{ t("harvester.virtualMachine.detail.console.down") }}
@@ -491,56 +374,14 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.vm-console { .vm-console {
height: 100vh; height: 100%;
height: 100dvh;
display: grid; display: grid;
grid-template-rows: 32px minmax(0, 1fr); grid-template-rows: 30px auto;
} }
.combination-keys { .combination-keys {
display: flex;
align-items: center;
justify-content: space-between;
background: rgb(40, 40, 40); background: rgb(40, 40, 40);
} }
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.console-status {
color: #fff;
font-size: 12px;
margin-right: 8px;
}
.console-panel {
display: flex;
flex-direction: column;
width: 260px;
padding: 4px;
&__zoom-label {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-variant-numeric: tabular-nums;
}
&__zoom {
width: 100%;
margin: 4px 0 8px;
}
&__option {
display: flex;
align-items: center;
gap: 6px;
margin: 2px 0;
}
}
</style> </style>
<style lang="scss"> <style lang="scss">
@@ -1,31 +0,0 @@
import RFB from '@novnc/novnc/core/rfb';
export function normalizeZoom(value) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return 100;
}
return Math.min(200, Math.max(50, Math.round(percent / 10) * 10));
}
export default class ZoomableRFB extends RFB {
get zoomPercent() {
return this._zoomPercent ?? 100;
}
set zoomPercent(value) {
this._zoomPercent = normalizeZoom(value);
this._updateScale();
}
_updateScale() {
if (this.scaleViewport) {
super._updateScale();
} else {
this._display.scale = this.zoomPercent / 100;
this._fixScrollbars();
}
}
}
+5
View File
@@ -77,6 +77,11 @@ const FEATURE_FLAGS = {
'staticIPForVM', 'staticIPForVM',
'fsFreezeDeadline', 'fsFreezeDeadline',
], ],
'v1.9.1': [
'lvmDmThinOnly',
'highPerformanceStorage',
],
'v1.10.0': [],
}; };
const generateFeatureFlags = () => { const generateFeatureFlags = () => {
+41 -3
View File
@@ -57,6 +57,8 @@ import {
} from './table-headers'; } from './table-headers';
import { ADD_ONS } from './harvester-map'; import { ADD_ONS } from './harvester-map';
import { registerAddonSideNav } from '../utils/dynamic-nav'; import { registerAddonSideNav } from '../utils/dynamic-nav';
import { setVendor } from '@shell/config/private-label';
import { createCssVars } from '@shell/utils/color';
const TEMPLATE = HCI.VM_VERSION; const TEMPLATE = HCI.VM_VERSION;
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring'; const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
@@ -109,15 +111,51 @@ export function init($plugin, store) {
} }
}; };
store.watch(
(_state, getters) => JSON.stringify([
!!getters['harvester-common/isHarvesterPrime'],
getters['prefs/theme'] === 'dark',
getters['harvester-common/privateLabel']
]),
(value) => {
const [isHarvesterPrime, isDark, privateLabel] = JSON.parse(value);
const primeLogo = isDark ? require('../assets/images/dark/suse-virtualization.svg') : require('../assets/images/suse-virtualization.svg');
setVendor(privateLabel || '');
let primaryColorStyle = document.getElementById('harvester-prime-primary-color');
// set harvester prime primary color style to #4dd192 (Cyan Green)
if (isHarvesterPrime) {
if (!primaryColorStyle) {
primaryColorStyle = document.createElement('style');
primaryColorStyle.id = 'harvester-prime-primary-color';
document.head.appendChild(primaryColorStyle);
primaryColorStyle.sheet.insertRule('body.theme-light, body.theme-dark {}', 0);
}
const colors = createCssVars('#4dd192', isDark ? 'dark' : 'light');
const rule = primaryColorStyle.sheet.cssRules[0];
Object.entries(colors).forEach(([property, color]) => rule.style.setProperty(property.trim(), color));
} else {
primaryColorStyle?.remove();
}
const primeLabelKey = privateLabel ? 'harvester.branding.primeLabel' : 'harvester.branding.primeEmptyLabel';
store.dispatch('setIsSingleProduct', { store.dispatch('setIsSingleProduct', {
productName: PRODUCT_NAME, productName: PRODUCT_NAME,
logo: require(`@shell/assets/images/providers/harvester.svg`), logo: isHarvesterPrime ? primeLogo : require(`@shell/assets/images/providers/harvester.svg`),
productNameKey: 'harvester.productLabel', productNameKey: isHarvesterPrime ? primeLabelKey : 'harvester.productLabel',
getVersionInfo: (store) => store.getters[`${ PRODUCT_NAME }/byId`]?.(HCI.SETTING, 'server-version')?.value || 'unknown', getVersionInfo: (store) => store.getters[`${ PRODUCT_NAME }/byId`]?.(HCI.SETTING, 'server-version')?.value || 'unknown',
afterLoginRoute: home, afterLoginRoute: home,
logoRoute: home, logoRoute: home,
supportCustomLogo: true supportCustomLogo: !isHarvesterPrime
}); });
},
{ immediate: true }
);
} }
product({ product({
+15
View File
@@ -39,6 +39,21 @@ export const VOLUME_TYPE = [{
value: 'cd-rom' value: 'cd-rom'
}]; }];
// KubeVirt high-performance disk features
// https://kubevirt.io/user-guide/storage/disks_and_volumes/#high-performance-features
export const DISK_CACHE_MODE = ['', 'none', 'writeback', 'writethrough'];
export const DISK_IO_MODE = ['', 'native', 'threads'];
export const IO_THREADS_POLICY = ['', 'shared', 'auto', 'supplementalPool'];
// "Easy-to-consume" presets layered on top of the raw KubeVirt fields.
export const DISK_PERFORMANCE_PROFILE = {
DEFAULT: 'default',
HIGH: 'highPerformance',
CUSTOM: 'custom',
};
export const VOLUME_HOTPLUG_ACTION = { export const VOLUME_HOTPLUG_ACTION = {
INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE', INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE',
EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE', EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE',
@@ -33,6 +33,11 @@ export const HCI = {
CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status', CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status',
MIGRATION_STATE: 'harvesterhci.io/migrationState', MIGRATION_STATE: 'harvesterhci.io/migrationState',
VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates', VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates',
// Default KubeVirt high-performance disk settings, applied when the volume is
// attached to a VM as a disk. See disks_and_volumes.md#high-performance-features.
DISK_CACHE_MODE: 'harvesterhci.io/disk-cache-mode',
DISK_IO_MODE: 'harvesterhci.io/disk-io-mode',
DISK_DEDICATED_IOTHREAD: 'harvesterhci.io/disk-dedicated-iothread',
IMAGE_NAME: 'harvesterhci.io/image-name', IMAGE_NAME: 'harvesterhci.io/image-name',
INIT_IP: 'etcd.rke2.cattle.io/node-address', INIT_IP: 'etcd.rke2.cattle.io/node-address',
NODE_SCHEDULABLE: 'kubevirt.io/schedulable', NODE_SCHEDULABLE: 'kubevirt.io/schedulable',
+2
View File
@@ -3,6 +3,7 @@ export const HCI_SETTING = {
CONTAINERD_REGISTRY: 'containerd-registry', CONTAINERD_REGISTRY: 'containerd-registry',
LOG_LEVEL: 'log-level', LOG_LEVEL: 'log-level',
SERVER_VERSION: 'server-version', SERVER_VERSION: 'server-version',
SERVER_FLAVOR: 'server-flavor',
UI_INDEX: 'ui-index', UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled', UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url', UPGRADE_CHECKER_URL: 'upgrade-checker-url',
@@ -68,6 +69,7 @@ export const HCI_ALLOWED_SETTINGS = {
kind: 'json', from: 'import', featureFlag: 'csiOnlineExpandValidation' kind: 'json', from: 'import', featureFlag: 'csiOnlineExpandValidation'
}, },
[HCI_SETTING.SERVER_VERSION]: { readOnly: true }, [HCI_SETTING.SERVER_VERSION]: { readOnly: true },
[HCI_SETTING.SERVER_FLAVOR]: { readOnly: true },
[HCI_SETTING.UPGRADE_CHECKER_ENABLED]: { kind: 'boolean' }, [HCI_SETTING.UPGRADE_CHECKER_ENABLED]: { kind: 'boolean' },
[HCI_SETTING.UPGRADE_CHECKER_URL]: { kind: 'url' }, [HCI_SETTING.UPGRADE_CHECKER_URL]: { kind: 'url' },
[HCI_SETTING.HTTP_PROXY]: { kind: 'json', from: 'import' }, [HCI_SETTING.HTTP_PROXY]: { kind: 'json', from: 'import' },
-7
View File
@@ -15,13 +15,6 @@ export const VOLUME_MODE = {
FILE_SYSTEM: 'Filesystem' 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 = { export const NETWORK_PROTOCOL = {
IPv4: 'IPv4', IPv4: 'IPv4',
IPv6: 'IPv6', IPv6: 'IPv6',
@@ -214,6 +214,14 @@ export default {
this['diskRows'] = diskRows; this['diskRows'] = diskRows;
this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false }); this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false });
// getInitConfig() seeds these on created(); refresh them here too so the
// VM-wide performance panel does not go stale when the spec changes.
const domain = neu?.spec?.template?.spec?.domain;
this['blockMultiQueue'] = domain?.devices?.blockMultiQueue || false;
this['ioThreadsPolicy'] = domain?.ioThreadsPolicy || '';
this['ioThreadCount'] = domain?.ioThreads?.supplementalPoolThreadCount || 2;
}, },
deep: true deep: true
} }
@@ -254,6 +262,9 @@ export default {
:namespace="value.metadata.namespace" :namespace="value.metadata.namespace"
:vm="value" :vm="value"
:resource-type="value.type" :resource-type="value.type"
:block-multi-queue="blockMultiQueue"
:io-threads-policy="ioThreadsPolicy"
:io-thread-count="ioThreadCount"
/> />
</Tab> </Tab>
+164
View File
@@ -0,0 +1,164 @@
<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 = {
rancher: {
hostname: '',
version: '',
bootstrapPassword: '',
},
};
export default {
name: 'HarvesterEnableK3k',
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 = merge({}, DEFAULT_VALUE);
}
return { valuesContentJson };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
buttonDisabled() {
const { rancher } = this.valuesContentJson;
return !(rancher?.hostname || '').trim() || !(rancher?.version || '').trim() || !(rancher?.bootstrapPassword || '').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.rancherK3k.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.rancher.hostname"
:required="true"
:label="t('harvester.addons.rancherK3k.hostname')"
placeholder="rancher.$vip.nip.io"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.version"
:required="true"
:label="t('harvester.addons.rancherK3k.version')"
placeholder="v1.14.0"
/>
</div>
</div>
<div class="row mb-15">
<div class="col span-12">
<LabeledInput
v-model:value="valuesContentJson.rancher.bootstrapPassword"
:required="true"
type="password"
:label="t('harvester.addons.rancherK3k.password')"
:tooltip="t('harvester.addons.rancherK3k.passwordTooltip')"
/>
</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>
@@ -0,0 +1,167 @@
<script>
import merge from 'lodash/merge';
import jsyaml from 'js-yaml';
import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
const DEFAULT_VALUE = {
certManagerVersion: 'v1.20.2',
rancher: {
hostname: '',
version: '',
bootstrapPassword: '',
},
k3kCluster: {
servers: 1,
version: 'v1.35.4-k3s1',
storageClassName: 'harvester-longhorn',
},
};
export default {
name: 'EditAddonRancherK3k',
components: { LabeledInput, RadioGroup },
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true
},
registerBeforeHook: {
type: Function,
required: true,
},
},
data() {
let valuesContentJson = {};
try {
valuesContentJson = merge({}, DEFAULT_VALUE, jsyaml.load(this.value.spec.valuesContent));
} catch (err) {
valuesContentJson = merge({}, DEFAULT_VALUE);
this.$store.dispatch('growl/fromError', {
title: this.$store.getters['i18n/t']('generic.notification.title.error'),
err: err.data || err,
}, { root: true });
}
return { valuesContentJson };
},
created() {
if (this.registerBeforeHook) {
this.registerBeforeHook(this.willSave, 'willSave');
}
},
methods: {
willSave() {
const errors = [];
if (!this.value.spec.enabled) {
return Promise.resolve();
}
if (!this.valuesContentJson.rancher.hostname) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherK3k.hostname') }, true));
}
if (!this.valuesContentJson.rancher.version) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherK3k.version') }, true));
}
if (!this.valuesContentJson.rancher.bootstrapPassword) {
errors.push(this.t('validation.required', { key: this.t('harvester.addons.rancherK3k.password') }, true));
}
if (errors.length > 0) {
return Promise.reject(errors);
} else {
return Promise.resolve();
}
},
},
watch: {
valuesContentJson: {
handler(neu) {
this.value.spec['valuesContent'] = jsyaml.dump(neu);
},
deep: true,
immediate: true
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col span-12">
<RadioGroup
v-model:value="value.spec.enabled"
class="mb-20"
name="model"
:mode="mode"
:options="[true,false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
/>
</div>
</div>
<template v-if="value.spec.enabled">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.hostname"
label-key="harvester.addons.rancherK3k.hostname"
:required="true"
:mode="mode"
placeholder="rancher.$vip.nip.io"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.version"
label-key="harvester.addons.rancherK3k.version"
:required="true"
:mode="mode"
placeholder="v1.14.0"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<LabeledInput
v-model:value="valuesContentJson.rancher.bootstrapPassword"
label-key="harvester.addons.rancherK3k.password"
:mode="mode"
:required="true"
type="password"
:tooltip="t('harvester.addons.rancherK3k.passwordTooltip')"
/>
</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
:deep() .radio-group {
display: flex;
.radio-container {
margin-right: 30px;
}
}
</style>
@@ -7,6 +7,7 @@ import NameNsDescription from '@shell/components/form/NameNsDescription';
import YamlEditor from '@shell/components/YamlEditor'; import YamlEditor from '@shell/components/YamlEditor';
import CreateEditView from '@shell/mixins/create-edit-view'; import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { HCI } from '@pkg/harvester/config/labels-annotations'; import { HCI } from '@pkg/harvester/config/labels-annotations';
export default { export default {
@@ -23,7 +24,7 @@ export default {
NameNsDescription, NameNsDescription,
}, },
mixins: [CreateEditView], mixins: [CreateEditView, FormValidation],
inheritAttrs: false, inheritAttrs: false,
@@ -31,6 +32,11 @@ export default {
return { return {
config: this.value.data?.cloudInit || '', config: this.value.data?.cloudInit || '',
type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user', type: this.value?.metadata?.labels?.[HCI.CLOUD_INIT] || 'user',
fvFormRuleSets: [{
path: 'metadata.name',
rules: ['required'],
translationKey: 'nameNsDescription.name.label',
}],
}; };
}, },
@@ -78,6 +84,7 @@ export default {
:resource="value" :resource="value"
:errors="errors" :errors="errors"
:apply-hooks="applyHooks" :apply-hooks="applyHooks"
:validation-passed="fvFormIsValid"
@finish="save" @finish="save"
@cancel="done" @cancel="done"
@error="e=>errors=e" @error="e=>errors=e"
@@ -86,6 +93,7 @@ export default {
:value="value" :value="value"
:mode="mode" :mode="mode"
:namespaced="true" :namespaced="true"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)" @update:value="$emit('update:value', $event)"
/> />
@@ -1,5 +1,5 @@
<script> <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 { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList'; import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';
@@ -10,10 +10,6 @@ import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params'; 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 { export default {
name: 'CDISettings', name: 'CDISettings',
@@ -94,10 +90,10 @@ export default {
accessModeOptions() { accessModeOptions() {
return [ return [
{ label: READ_WRITE_ONCE, value: READ_WRITE_ONCE }, { label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: READ_ONLY_MANY, value: READ_ONLY_MANY }, { label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: READ_WRITE_MANY, value: READ_WRITE_MANY }, { label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD }, { label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
]; ];
}, },
@@ -7,8 +7,12 @@ import { allHash } from '@shell/utils/promise';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
import { HCI } from '../../../types'; import { HCI } from '../../../types';
import { NODE } from '@shell/config/types'; import { NODE } from '@shell/config/types';
import { _CREATE, _CLONE } from '@shell/config/query-params';
import { LVM_TOPOLOGY_LABEL } from '../index.vue'; import { LVM_TOPOLOGY_LABEL } from '../index.vue';
const STRIPED_TYPE = 'striped';
const DM_THIN_TYPE = 'dm-thin';
const DEFAULT_PARAMETERS = [ const DEFAULT_PARAMETERS = [
'type', 'type',
'vgName' 'vgName'
@@ -54,8 +58,17 @@ export default {
data() { data() {
const node = (this.value.allowedTopologies?.[0]?.matchLabelExpressions || []).find((t) => t.key === LVM_TOPOLOGY_LABEL)?.values[0]; const node = (this.value.allowedTopologies?.[0]?.matchLabelExpressions || []).find((t) => t.key === LVM_TOPOLOGY_LABEL)?.values[0];
// striped is dropped once the LVM add-on reaches GA (v1.9.1); dm-thin is the only supported type.
const dmThinOnly = this.$store.getters['harvester-common/getFeatureEnabled']('lvmDmThinOnly');
const volumeGroupTypes = dmThinOnly ? [DM_THIN_TYPE] : [STRIPED_TYPE, DM_THIN_TYPE];
// Default new storage classes to dm-thin, and reset a cloned striped type that is no longer selectable.
if (dmThinOnly && [_CREATE, _CLONE].includes(this.realMode) && !volumeGroupTypes.includes(this.value.parameters.type)) {
this.value.parameters.type = DM_THIN_TYPE;
}
return { return {
volumeGroupTypes: ['striped', 'dm-thin'], volumeGroupTypes,
node, node,
}; };
}, },
@@ -308,6 +308,9 @@ export default {
> >
<Volume <Volume
v-model:value="diskRows" v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode" :mode="mode"
:namespace="value.metadata.namespace" :namespace="value.metadata.namespace"
:existing-volume-disabled="true" :existing-volume-disabled="true"
+45 -9
View File
@@ -8,6 +8,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription'; import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions'; import Conditions from '@shell/components/form/Conditions';
import DiskPerformanceOptions from './kubevirt.io.virtualmachine/VirtualMachineVolume/DiskPerformanceOptions';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';
import jsyaml from 'js-yaml'; import jsyaml from 'js-yaml';
@@ -26,11 +27,9 @@ import { HCI, VOLUME_SNAPSHOT } from '../types';
import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim'; import { DATA_ENGINE_V2 } from '../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../utils/unit'; 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'; import { isInternalStorageClass } from '../utils/storage-class';
const { READ_WRITE_MANY, READ_WRITE_ONCE, READ_ONLY_MANY } = ACCESS_MODE;
export default { export default {
name: 'HarvesterVolume', name: 'HarvesterVolume',
@@ -47,7 +46,8 @@ export default {
LabeledSelect, LabeledSelect,
LabeledInput, LabeledInput,
NameNsDescription, NameNsDescription,
Conditions Conditions,
DiskPerformanceOptions
}, },
mixins: [CreateEditView], mixins: [CreateEditView],
@@ -85,7 +85,7 @@ export default {
if (this.mode === _CREATE) { if (this.mode === _CREATE) {
// default volumeMode to Block // default volumeMode to Block
this.value.spec.volumeMode = VOLUME_MODE.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; const storage = this.value?.spec?.resources?.requests?.storage || null;
@@ -100,7 +100,15 @@ export default {
createWithDataVolume: false, createWithDataVolume: false,
snapshots: [], snapshots: [],
images: [], images: [],
GIBIBYTE GIBIBYTE,
// Default KubeVirt high-performance disk profile, stored as annotations and
// applied when this volume is later attached to a VM as a disk.
perf: {
cache: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_CACHE_MODE }"`) || '',
io: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_IO_MODE }"`) || '',
dedicatedIOThread: get(this.value, `metadata.annotations."${ HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD }"`) === 'true',
volumeMode: this.value?.spec?.volumeMode,
},
}; };
}, },
@@ -162,7 +170,7 @@ export default {
}, },
accessModeOptions() { accessModeOptions() {
return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY]; return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
}, },
imageOption() { imageOption() {
@@ -332,6 +340,7 @@ export default {
} }
}, },
'value.spec.volumeMode'(neu) { 'value.spec.volumeMode'(neu) {
this.perf.volumeMode = neu;
if (neu === VOLUME_MODE.FILE_SYSTEM) { if (neu === VOLUME_MODE.FILE_SYSTEM) {
this.setVolumeForVmAnnotation(); this.setVolumeForVmAnnotation();
} else if (neu === VOLUME_MODE.BLOCK ) { } else if (neu === VOLUME_MODE.BLOCK ) {
@@ -357,7 +366,7 @@ export default {
getAccessMode() { getAccessMode() {
if (!this.longhornV2LVMSupport) { if (!this.longhornV2LVMSupport) {
return [READ_WRITE_MANY]; return ['ReadWriteMany'];
} }
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) { if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@@ -373,7 +382,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.value.thirdPartyStorageFeatureEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2); 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() { buildDataVolumeObj() {
const storage = { const storage = {
@@ -446,6 +455,23 @@ export default {
imageAnnotations = { ...this.value.metadata.annotations }; imageAnnotations = { ...this.value.metadata.annotations };
} }
// Persist the default high-performance disk profile as annotations.
if (this.perf.cache) {
imageAnnotations[HCI_ANNOTATIONS.DISK_CACHE_MODE] = this.perf.cache;
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_CACHE_MODE];
}
if (this.perf.io) {
imageAnnotations[HCI_ANNOTATIONS.DISK_IO_MODE] = this.perf.io;
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_IO_MODE];
}
if (this.perf.dedicatedIOThread) {
imageAnnotations[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] = 'true';
} else {
delete imageAnnotations[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD];
}
const spec = { const spec = {
...this.value.spec, ...this.value.spec,
resources: { requests: { storage: this.storage } }, resources: { requests: { storage: this.storage } },
@@ -610,6 +636,16 @@ export default {
class="mb-20" class="mb-20"
@update:value="update" @update:value="update"
/> />
<Banner
color="info"
:label="t('harvester.volume.performance.pvcTip')"
/>
<DiskPerformanceOptions
:value="perf"
:mode="mode"
@update="update"
/>
</Tab> </Tab>
<Tab <Tab
v-if="!isCreate" v-if="!isCreate"
@@ -0,0 +1,321 @@
<script>
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import { _VIEW } from '@shell/config/query-params';
import { DISK_CACHE_MODE, DISK_IO_MODE, DISK_PERFORMANCE_PROFILE } from '../../../config/harvester-map';
const { DEFAULT, HIGH, CUSTOM } = DISK_PERFORMANCE_PROFILE;
export default {
name: 'DiskPerformanceOptions',
components: {
LabeledSelect, Checkbox, Banner
},
emits: ['update'],
props: {
// The disk row. This component reads/writes value.cache, value.io,
// value.dedicatedIOThread and (for the "High Performance" preset) value.bus.
value: {
type: Object,
default: () => ({})
},
mode: {
type: String,
default: 'create'
},
},
data() {
const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread);
return {
DISK_PERFORMANCE_PROFILE,
expanded: hasPerf,
profile: this.detectProfile(),
// Bus the disk used before the "High Performance" preset switched it to
// virtio, so returning to "Default" can put it back.
busBeforeHighProfile: null,
};
},
computed: {
isView() {
return this.mode === _VIEW;
},
storagePerformanceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage');
},
isCdRom() {
return this.value.type === 'cd-rom';
},
isCustom() {
return this.profile === CUSTOM;
},
isNativeIo() {
return this.value.io === 'native';
},
profileOptions() {
return [DEFAULT, HIGH, CUSTOM].map((value) => ({
label: this.t(`harvester.virtualMachine.volume.performance.profile.${ value }`),
value,
}));
},
cacheOptions() {
return DISK_CACHE_MODE.map((value) => ({
label: this.cacheLabel(value),
value,
// Native AIO only works with an uncached (O_DIRECT) disk, so while it is
// selected the only valid cache option is "none" — disable everything
// else, including "Default", to prevent an invalid combination.
disabled: this.isNativeIo && value !== 'none',
}));
},
ioOptions() {
return DISK_IO_MODE.map((value) => ({
label: value === '' ? this.t('harvester.virtualMachine.volume.performance.ioMode.default') : this.t(`harvester.virtualMachine.volume.performance.ioMode.${ value }`),
value,
}));
},
showFilesystemCacheWarning() {
return this.value.cache === 'none' && this.value.volumeMode === 'Filesystem';
},
showBusTip() {
const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread);
return hasPerf && this.value.bus && this.value.bus !== 'virtio';
},
},
watch: {
// Keep the profile selector in sync if the row is repopulated (e.g. editing an existing VM).
'value.cache'() {
this.profile = this.detectProfile();
},
'value.io'() {
this.profile = this.detectProfile();
},
'value.dedicatedIOThread'() {
this.profile = this.detectProfile();
},
},
methods: {
detectProfile() {
const { cache, io, dedicatedIOThread } = this.value;
if (!cache && !io && !dedicatedIOThread) {
return DEFAULT;
}
if (cache === 'none' && io === 'native' && dedicatedIOThread) {
return HIGH;
}
return CUSTOM;
},
cacheLabel(value) {
if (value === '') {
return this.t('harvester.virtualMachine.volume.performance.cacheMode.default');
}
return this.t(`harvester.virtualMachine.volume.performance.cacheMode.${ value }`);
},
onProfileChange(profile) {
const previous = this.profile;
this.profile = profile;
if (profile === DEFAULT) {
this.value.cache = '';
this.value.io = '';
this.value.dedicatedIOThread = false;
// "Default" means no overrides, so also undo the virtio bus that the
// "High Performance" preset applied. Skip it if the user has since
// picked a different bus themselves — that choice is theirs to keep.
if (this.busBeforeHighProfile !== null) {
if (this.value.bus === 'virtio') {
this.value.bus = this.busBeforeHighProfile;
}
this.busBeforeHighProfile = null;
}
} else if (profile === HIGH) {
this.value.cache = 'none';
this.value.io = 'native';
this.value.dedicatedIOThread = true;
if (previous !== HIGH) {
this.busBeforeHighProfile = this.value.bus || '';
}
this.value.bus = 'virtio';
}
// CUSTOM keeps whatever is currently set, including the bus.
this.update();
},
onIoChange(io) {
this.value.io = io;
// Native AIO requires an uncached disk; force cache to "none" to keep the
// combo valid — including when cache is still on its "Default" ('') value,
// which libvirt would otherwise reject at domain start.
if (io === 'native' && this.value.cache !== 'none') {
this.value.cache = 'none';
}
this.update();
},
update() {
this.$emit('update');
},
},
};
</script>
<template>
<div
v-if="!isCdRom && storagePerformanceEnabled"
class="disk-performance"
>
<button
v-if="!isView"
type="button"
class="btn btn-sm role-link expand-toggle"
:aria-expanded="expanded"
:aria-label="expanded ? t('harvester.virtualMachine.volume.performance.collapse') : t('harvester.virtualMachine.volume.performance.expand')"
@click.prevent="expanded = !expanded"
>
<i
class="icon"
:class="expanded ? 'icon-chevron-down' : 'icon-chevron-right'"
/>
{{ t('harvester.virtualMachine.volume.performance.title') }}
</button>
<div
v-if="expanded || isView"
class="perf-body mt-10"
>
<div class="row mb-20">
<div
class="col span-6"
data-testid="input-disk-perf-profile"
>
<LabeledSelect
:value="profile"
:label="t('harvester.virtualMachine.volume.performance.profile.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.profile.tip')"
:options="profileOptions"
:mode="mode"
@update:value="onProfileChange"
/>
</div>
</div>
<Banner
v-if="profile === DISK_PERFORMANCE_PROFILE.HIGH"
color="info"
:label="t('harvester.virtualMachine.volume.performance.highProfileTip')"
/>
<div
v-if="isCustom || isView"
class="row mb-20"
>
<div
class="col span-6"
data-testid="input-disk-cache"
>
<LabeledSelect
v-model:value="value.cache"
:label="t('harvester.virtualMachine.volume.performance.cacheMode.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.cacheMode.tip')"
:options="cacheOptions"
:mode="mode"
@update:value="update"
/>
</div>
<div
class="col span-6"
data-testid="input-disk-io"
>
<LabeledSelect
:value="value.io"
:label="t('harvester.virtualMachine.volume.performance.ioMode.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.ioMode.tip')"
:options="ioOptions"
:mode="mode"
@update:value="onIoChange"
/>
</div>
</div>
<div
v-if="isCustom || isView"
class="row mb-10"
>
<div
class="col span-12"
data-testid="input-disk-dedicated-iothread"
>
<Checkbox
v-model:value="value.dedicatedIOThread"
:label="t('harvester.virtualMachine.volume.performance.dedicatedIOThread.label')"
:tooltip="t('harvester.virtualMachine.volume.performance.dedicatedIOThread.tip')"
:mode="mode"
@update:value="update"
/>
</div>
</div>
<Banner
v-if="isNativeIo"
color="info"
:label="t('harvester.virtualMachine.volume.performance.ioMode.nativeRequiresNoCacheTip')"
/>
<Banner
v-if="showFilesystemCacheWarning"
color="warning"
:label="t('harvester.virtualMachine.volume.performance.cacheMode.filesystemWarning')"
/>
<Banner
v-if="showBusTip"
color="warning"
:label="t('harvester.virtualMachine.volume.performance.busTip')"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.disk-performance {
border-top: 1px solid var(--border);
margin-top: 10px;
padding-top: 10px;
.expand-toggle {
padding: 0;
font-weight: 600;
.icon {
margin-right: 4px;
}
}
}
</style>
@@ -0,0 +1,191 @@
<script>
import InfoBox from '@shell/components/InfoBox';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import { _VIEW } from '@shell/config/query-params';
import { IO_THREADS_POLICY } from '../../../config/harvester-map';
export default {
name: 'VMPerformanceOptions',
components: {
InfoBox, LabeledSelect, LabeledInput, Checkbox, Banner
},
emits: ['update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'],
props: {
blockMultiQueue: {
type: Boolean,
default: false
},
ioThreadsPolicy: {
type: String,
default: ''
},
ioThreadCount: {
type: Number,
default: 2
},
mode: {
type: String,
default: 'create'
},
// Whether the current disk set contains a virtio disk (VM context only).
hasVirtioDisk: {
type: Boolean,
default: true
},
// Whether any disk requests a dedicated I/O thread (drives the auto-enable hint).
hasDedicatedIothread: {
type: Boolean,
default: false
},
},
data() {
// These are specialist tuning options, so keep the panel collapsed unless the
// VM already has something configured.
return { expanded: !!(this.blockMultiQueue || this.ioThreadsPolicy) };
},
computed: {
isView() {
return this.mode === _VIEW;
},
storagePerformanceEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage');
},
ioThreadsPolicyOptions() {
return IO_THREADS_POLICY.map((value) => ({
label: value === '' ? this.t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.default') : this.t(`harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.${ value }`),
value,
}));
},
},
watch: {
// Block Multi-Queue only applies to virtio disks. If the last virtio disk is
// removed while it is enabled, clear it so the VM is not left with an invalid
// setting the disabled checkbox can no longer surface.
hasVirtioDisk(neu) {
if (!neu && this.blockMultiQueue) {
this.$emit('update:blockMultiQueue', false);
}
},
},
};
</script>
<template>
<InfoBox
v-if="storagePerformanceEnabled && (!isView || blockMultiQueue || !!ioThreadsPolicy)"
class="vm-perf mt-10"
>
<button
v-if="!isView"
type="button"
class="btn btn-sm role-link expand-toggle"
:aria-expanded="expanded"
:aria-label="expanded ? t('harvester.virtualMachine.volume.diskPerformance.collapse') : t('harvester.virtualMachine.volume.diskPerformance.expand')"
@click.prevent="expanded = !expanded"
>
<i
class="icon"
:class="expanded ? 'icon-chevron-down' : 'icon-chevron-right'"
/>
{{ t('harvester.virtualMachine.volume.diskPerformance.title') }}
</button>
<h3
v-else
class="mb-5"
>
{{ t('harvester.virtualMachine.volume.diskPerformance.title') }}
</h3>
<template v-if="expanded || isView">
<p class="text-muted mb-15 mt-10">
{{ t('harvester.virtualMachine.volume.diskPerformance.description') }}
</p>
<div class="row mb-10">
<div
class="col span-12"
data-testid="input-vm-block-multi-queue"
>
<Checkbox
:value="blockMultiQueue"
:label="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.tip')"
:mode="mode"
:disabled="!hasVirtioDisk"
@update:value="$emit('update:blockMultiQueue', $event)"
/>
</div>
</div>
<Banner
v-if="!hasVirtioDisk && !isView"
color="info"
:label="t('harvester.virtualMachine.volume.diskPerformance.blockMultiQueue.noVirtioTip')"
/>
<div class="row mb-10">
<div
class="col span-6"
data-testid="input-vm-iothreads-policy"
>
<LabeledSelect
:value="ioThreadsPolicy"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.tip')"
:options="ioThreadsPolicyOptions"
:mode="mode"
@update:value="$emit('update:ioThreadsPolicy', $event)"
/>
</div>
<div
v-if="ioThreadsPolicy === 'supplementalPool'"
class="col span-6"
data-testid="input-vm-iothread-count"
>
<LabeledInput
type="number"
min="1"
:value="ioThreadCount"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadCount.label')"
:tooltip="t('harvester.virtualMachine.volume.diskPerformance.ioThreadCount.tip')"
:mode="mode"
@update:value="$emit('update:ioThreadCount', Number($event))"
/>
</div>
</div>
<Banner
v-if="hasDedicatedIothread && !ioThreadsPolicy"
color="info"
:label="t('harvester.virtualMachine.volume.diskPerformance.ioThreadsPolicy.autoEnabledTip')"
/>
</template>
</InfoBox>
</template>
<style lang="scss" scoped>
.vm-perf {
.expand-toggle {
padding: 0;
font-weight: 600;
.icon {
margin-right: 4px;
}
}
}
</style>
@@ -0,0 +1,125 @@
import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import DiskPerformanceOptions from '../DiskPerformanceOptions.vue';
const mountOptions = (value, { featureEnabled = true } = {}) => ({
props: { value, mode: _EDIT },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => featureEnabled,
'i18n/t': (key) => key,
'i18n/exists': () => true,
}
},
t: (key) => key,
},
}
});
const hardDisk = (overrides = {}) => ({
type: 'disk', bus: 'virtio', cache: '', io: '', dedicatedIOThread: false, ...overrides
});
describe('component: DiskPerformanceOptions', () => {
it('is hidden when the highPerformanceStorage feature is disabled', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk(), { featureEnabled: false }));
expect(wrapper.find('.disk-performance').exists()).toBe(false);
});
it('renders for a hard disk when the feature is enabled', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk()));
expect(wrapper.find('.disk-performance').exists()).toBe(true);
});
it('applies the High Performance preset and emits update', () => {
const value = hardDisk();
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
expect(value.cache).toBe('none');
expect(value.io).toBe('native');
expect(value.dedicatedIOThread).toBe(true);
expect(value.bus).toBe('virtio');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('clears all overrides when switching back to Default', () => {
const value = hardDisk({
cache: 'none', io: 'native', dedicatedIOThread: true
});
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('default');
expect(value.cache).toBe('');
expect(value.io).toBe('');
expect(value.dedicatedIOThread).toBe(false);
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('restores the original bus when leaving High Performance for Default', () => {
const value = hardDisk({ bus: 'sata' });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
expect(value.bus).toBe('virtio');
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('sata');
});
it('keeps a bus the user picked themselves after High Performance', () => {
const value = hardDisk({ bus: 'sata' });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('highPerformance');
value.bus = 'scsi';
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('scsi');
});
it('leaves the bus alone for a disk that never used the High Performance preset', () => {
const value = hardDisk({
bus: 'sata', cache: 'writeback', io: 'threads'
});
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onProfileChange('default');
expect(value.bus).toBe('sata');
});
it.each([
['a cached state', 'writeback'],
['the Default cache', ''],
])('forces cache to none when Native I/O is selected from %s', (_label, cache) => {
const value = hardDisk({ cache });
const wrapper = mount(DiskPerformanceOptions, mountOptions(value));
wrapper.vm.onIoChange('native');
expect(value.io).toBe('native');
expect(value.cache).toBe('none');
expect(wrapper.emitted('update')).toHaveLength(1);
});
it('disables every cache option except none while Native I/O is active', () => {
const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ io: 'native' })));
const disabledByValue = wrapper.vm.cacheOptions.reduce((acc, opt) => {
acc[opt.value] = opt.disabled;
return acc;
}, {});
expect(disabledByValue['none']).toBe(false);
expect(disabledByValue['']).toBe(true);
expect(disabledByValue['writeback']).toBe(true);
expect(disabledByValue['writethrough']).toBe(true);
});
});
@@ -7,26 +7,25 @@ import UnitInput from '@shell/components/form/UnitInput';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import ModalWithCard from '@shell/components/ModalWithCard'; import ModalWithCard from '@shell/components/ModalWithCard';
import VMPerformanceOptions from './VMPerformanceOptions';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PVC, STORAGE_CLASS } from '@shell/config/types'; import { PVC, STORAGE_CLASS } from '@shell/config/types';
import { clone } from '@shell/utils/object'; import { clone } from '@shell/utils/object';
import { ucFirst, randomStr } from '@shell/utils/string'; import { ucFirst, randomStr } from '@shell/utils/string';
import { removeObject } from '@shell/utils/array'; import { removeObject } from '@shell/utils/array';
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params'; import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs'; import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types'; import { HCI } from '../../../types';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types'; import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { OFF } from '../../../models/kubevirt.io.virtualmachine'; import { OFF } from '../../../models/kubevirt.io.virtualmachine';
import { EMPTY_IMAGE } from '../../../utils/vm'; import { EMPTY_IMAGE } from '../../../utils/vm';
const { READ_WRITE_MANY } = ACCESS_MODE;
export default { export default {
emits: ['update:value'], emits: ['update:value', 'update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'],
components: { components: {
Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard, VMPerformanceOptions
}, },
props: { props: {
@@ -71,12 +70,28 @@ export default {
customAccessMode: { customAccessMode: {
type: String, type: String,
default: READ_WRITE_MANY default: 'ReadWriteMany'
}, },
resourceType: { resourceType: {
type: String, type: String,
default: '' default: ''
},
// KubeVirt high-performance VM-level features
blockMultiQueue: {
type: Boolean,
default: false
},
ioThreadsPolicy: {
type: String,
default: ''
},
ioThreadCount: {
type: Number,
default: 2
} }
}, },
@@ -147,6 +162,14 @@ export default {
isLHV2VolExpansionFeatureEnabled() { isLHV2VolExpansionFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion'); return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
}, },
hasVirtioDisk() {
return this.rows.some((R) => R.type === 'disk' && R.bus === 'virtio');
},
hasDedicatedIOThread() {
return this.rows.some((R) => R.dedicatedIOThread);
},
}, },
watch: { watch: {
@@ -508,6 +531,18 @@ export default {
</button> </button>
</div> </div>
<VMPerformanceOptions
:block-multi-queue="blockMultiQueue"
:io-threads-policy="ioThreadsPolicy"
:io-thread-count="ioThreadCount"
:has-virtio-disk="hasVirtioDisk"
:has-dedicated-iothread="hasDedicatedIOThread"
:mode="mode"
@update:block-multi-queue="$emit('update:blockMultiQueue', $event)"
@update:io-threads-policy="$emit('update:ioThreadsPolicy', $event)"
@update:io-thread-count="$emit('update:ioThreadCount', $event)"
/>
<ModalWithCard <ModalWithCard
v-if="isOpen" v-if="isOpen"
name="deleteTip" name="deleteTip"
@@ -4,6 +4,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default { export default {
name: 'HarvesterEditContainer', name: 'HarvesterEditContainer',
@@ -11,7 +12,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
LabeledInput, LabeledSelect, InputOrDisplay, Banner LabeledInput, LabeledSelect, InputOrDisplay, Banner, DiskPerformanceOptions
}, },
props: { props: {
@@ -153,5 +154,11 @@ export default {
class="mb-20" class="mb-20"
:label="value.volumeBackups.error.message" :label="value.volumeBackups.error.message"
/> />
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div> </div>
</template> </template>
@@ -14,6 +14,7 @@ import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI } from '../../../../types'; import { HCI } from '../../../../types';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { GIBIBYTE } from '../../../../utils/unit'; import { GIBIBYTE } from '../../../../utils/unit';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default { export default {
name: 'HarvesterEditExisting', name: 'HarvesterEditExisting',
@@ -21,7 +22,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox, DiskPerformanceOptions
}, },
props: { props: {
@@ -172,6 +173,14 @@ export default {
this.value.storageClassName = pvcResource.spec.storageClassName; this.value.storageClassName = pvcResource.spec.storageClassName;
this.value.volumeMode = pvcResource.spec.volumeMode; this.value.volumeMode = pvcResource.spec.volumeMode;
this.value.shareable = false; this.value.shareable = false;
// Adopt the volume's default high-performance profile (if any) as a starting point.
const ann = pvcResource.metadata?.annotations || {};
this.value.cache = ann[HCI_ANNOTATIONS.DISK_CACHE_MODE] || '';
this.value.io = ann[HCI_ANNOTATIONS.DISK_IO_MODE] || '';
this.value.dedicatedIOThread = ann[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] === 'true';
this.update(); this.update();
}, },
@@ -379,5 +388,11 @@ export default {
class="mb-20" class="mb-20"
:label="value.volumeBackups.error.message" :label="value.volumeBackups.error.message"
/> />
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div> </div>
</template> </template>
@@ -15,6 +15,7 @@ import LabelValue from '@shell/components/LabelValue';
import { ucFirst } from '@shell/utils/string'; import { ucFirst } from '@shell/utils/string';
import { GIBIBYTE } from '../../../../utils/unit'; import { GIBIBYTE } from '../../../../utils/unit';
import { EMPTY_IMAGE } from '../../../../utils/vm'; import { EMPTY_IMAGE } from '../../../../utils/vm';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
export default { export default {
name: 'HarvesterEditVMImage', name: 'HarvesterEditVMImage',
@@ -22,7 +23,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, DiskPerformanceOptions
}, },
props: { props: {
@@ -482,5 +483,11 @@ export default {
color="error" color="error"
:label="t('harvester.virtualMachine.volume.vmImageVolumeTip', {diskSize: diskSize, imageVirtualSize: imageVirtualSize})" :label="t('harvester.virtualMachine.volume.vmImageVolumeTip', {diskSize: diskSize, imageVirtualSize: imageVirtualSize})"
/> />
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
</div> </div>
</template> </template>
@@ -9,14 +9,13 @@ import { formatSi, parseSi } from '@shell/utils/units';
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
import { _VIEW } from '@shell/config/query-params'; import { _VIEW } from '@shell/config/query-params';
import LabelValue from '@shell/components/LabelValue'; import LabelValue from '@shell/components/LabelValue';
import DiskPerformanceOptions from '../DiskPerformanceOptions';
import { ucFirst } from '@shell/utils/string'; import { ucFirst } from '@shell/utils/string';
import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass';
import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim'; import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim';
import { GIBIBYTE } from '../../../../utils/unit'; import { GIBIBYTE } from '../../../../utils/unit';
import { isInternalStorageClass } from '../../../../utils/storage-class'; import { isInternalStorageClass } from '../../../../utils/storage-class';
import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types'; import { VOLUME_MODE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
export default { export default {
name: 'HarvesterEditVolume', name: 'HarvesterEditVolume',
@@ -24,7 +23,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue, DiskPerformanceOptions
}, },
props: { props: {
@@ -207,7 +206,7 @@ export default {
getAccessMode(storageClassName) { getAccessMode(storageClassName) {
if (!this.longhornV2LVMSupport) { if (!this.longhornV2LVMSupport) {
return READ_WRITE_MANY; return 'ReadWriteMany';
} }
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName); const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
@@ -218,7 +217,7 @@ export default {
readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2); readWriteOnce = storageClass.provisioner === LVM_DRIVER || (!this.thirdPartyStorageClassEnabled && storageClass.parameters?.dataEngine === DATA_ENGINE_V2);
} }
return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY; return readWriteOnce ? 'ReadWriteOnce' : 'ReadWriteMany';
}, },
update() { update() {
@@ -362,6 +361,11 @@ export default {
</InputOrDisplay> </InputOrDisplay>
</div> </div>
</div> </div>
<DiskPerformanceOptions
:value="value"
:mode="mode"
@update="update"
/>
<div class="row mb-20"> <div class="row mb-20">
<div <div
v-if="value.volumeEncryptionFeatureEnabled && isView" v-if="value.volumeEncryptionFeatureEnabled && isView"
@@ -772,6 +772,9 @@ export default {
> >
<Volume <Volume
v-model:value="diskRows" v-model:value="diskRows"
v-model:block-multi-queue="blockMultiQueue"
v-model:io-threads-policy="ioThreadsPolicy"
v-model:io-thread-count="ioThreadCount"
:mode="mode" :mode="mode"
:custom-volume-mode="customVolumeMode" :custom-volume-mode="customVolumeMode"
:namespace="value.metadata.namespace" :namespace="value.metadata.namespace"
+74 -27
View File
@@ -84,6 +84,8 @@ dialog:
harvester: harvester:
branding: branding:
primeLabel: '{vendor}'
primeEmptyLabel: ''
logos: logos:
tip: 'Upload a logo to replace the Harvester logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.' tip: 'Upload a logo to replace the Harvester logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.'
favicon: favicon:
@@ -798,6 +800,56 @@ harvester:
setFirst: Set as root volume setFirst: Set as root volume
saveVolume: Update Volume saveVolume: Update Volume
encryption: Encryption encryption: Encryption
performance:
title: Storage Performance Options
expand: Show high-performance options
collapse: Hide high-performance options
profile:
label: Performance Profile
tip: "\"High Performance\" applies a tuned preset (no host cache, native AIO, a dedicated I/O thread on the virtio bus). Choose \"Custom\" to set each option yourself."
default: Default (Balanced)
highPerformance: High Performance
custom: Custom
highProfileTip: This disk will use the virtio bus with host cache disabled, native AIO and a dedicated I/O thread.
cacheMode:
label: Cache Mode
tip: "How the host caches disk I/O. \"None\" (host cache disabled) is best for high-throughput guests on block storage; \"WriteBack\" favours performance; \"WriteThrough\" favours durability."
default: Default (hypervisor)
none: None (no host cache)
writeback: WriteBack
writethrough: WriteThrough
filesystemWarning: "\"None\" (O_DIRECT) requires storage that supports direct I/O. It is recommended only for Block volume mode; on Filesystem volumes the VM may fail to start."
ioMode:
label: I/O Mode
tip: "How QEMU submits I/O. \"Native\" (Linux AIO) offers the lowest overhead but requires host cache set to \"None\"; \"Threads\" uses a host thread pool."
default: Default (hypervisor)
native: Native (AIO)
threads: Threads
nativeRequiresNoCacheTip: Native I/O mode requires cache mode "None"; it has been set for you.
dedicatedIOThread:
label: Dedicated I/O Thread
tip: Allocate an exclusive libvirt I/O thread to this disk instead of sharing one. Useful for disks with heavy I/O. Enabling this on any disk turns on the VM's I/O threads policy.
busTip: High-performance features work best on the virtio bus.
diskPerformance:
title: High Performance (I/O Threads and Multi-Queue)
description: These settings apply to all volumes in the Virtual Machine and complement the per-volume options above.
expand: Show VM-wide high-performance options
collapse: Hide VM-wide high-performance options
blockMultiQueue:
label: Virtio Block Multi-Queue
tip: Map disk I/O to multiple queues so it is processed across multiple CPUs. Applies to virtio disks and requires a fixed CPU allocation (Harvester always sets one).
noVirtioTip: Add at least one disk on the virtio bus to enable Block Multi-Queue.
ioThreadsPolicy:
label: I/O Threads Policy
tip: "How libvirt allocates I/O threads. \"Shared\" uses one thread for all disks; \"Auto\" pools threads round-robin (up to ~2x vCPUs); \"Supplemental Pool\" lets you pick a fixed thread count and adds matching CPUs to the pod."
default: "Disabled (default)"
shared: Shared
auto: Auto
supplementalPool: Supplemental Pool
autoEnabledTip: A disk requests a dedicated I/O thread, so the policy is enabled (defaults to "Shared").
ioThreadCount:
label: I/O Thread Count
tip: Number of I/O threads for the supplemental pool. 4-8 is a common range depending on workload.
shareable: shareable:
label: Shareable label: Shareable
tip: Allow multiple virtual machines to attach and write to this volume simultaneously. tip: Allow multiple virtual machines to attach and write to this volume simultaneously.
@@ -953,33 +1005,15 @@ harvester:
console: console:
down: This virtual machine is down. Please start it to access its console. down: This virtual machine is down. Please start it to access its console.
shortcutKeys: Shortcut Keys shortcutKeys: Shortcut Keys
settings: customShortcutKeys: Custom Shortcut Keys
label: Settings management: Management Shortcut Keys
viewMode: record:
label: Display start: Record
scale: Scale the display to fit the window recording: Recording
resize: Resize the virtual machine display to the window stop: Stop Recording
none: Manual zoom tips: Press the record button to capture your keyboard inputs.
zoom: Zoom send: Send
clipViewport: Clip to the window instead of showing scrollbars preferredKeys: Preferred Custom Shortcut Keys
viewOnly: View only
quality: JPEG quality
compression: Compression level
reset: Reset to defaults
fullscreen:
enter: Full Screen
exit: Exit Full Screen
takeOver:
label: Take Over
confirm: Take over this console? Any existing VNC session for this virtual machine will be disconnected.
status:
connecting: Connecting
connected: Connected
disconnected: Disconnected
failed: Connection failed
reconnecting: 'Reconnecting {retryTimes} of {maximumRetryTimes}'
credentialsRequired: This console requires credentials that the dashboard cannot provide.
unavailable: Unable to connect. Another VNC session may be active, or the console may be unavailable.
terminationGracePeriodSeconds: terminationGracePeriodSeconds:
label: Termination Grace Period label: Termination Grace Period
affinity: affinity:
@@ -1013,6 +1047,8 @@ harvester:
createWithDataVolumeTooltip: Create Volume with Kubevirt/Containerized Data Importer way. It can fill accessMode/volumeMode automatically. createWithDataVolumeTooltip: Create Volume with Kubevirt/Containerized Data Importer way. It can fill accessMode/volumeMode automatically.
showAdvanced: Show Advanced Options showAdvanced: Show Advanced Options
hideAdvanced: Hide Advanced Options hideAdvanced: Hide Advanced Options
performance:
pvcTip: These high-performance options are saved as defaults on the volume and applied when it is attached to a virtual machine as a disk.
source: Source source: Source
kind: Kind kind: Kind
sourceOptions: sourceOptions:
@@ -1850,6 +1886,8 @@ harvester:
'rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the Harvester cluster and route them to many kinds of servers based on flows. 'rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the Harvester cluster and route them to many kinds of servers based on flows.
'rancher-monitoring': rancher-monitoring is an add-on to collect Harvester cluster and virtual machine metrics, view them on the embedded dashboard, and send alert(s) to remote servers. 'rancher-monitoring': rancher-monitoring is an add-on to collect Harvester cluster and virtual machine metrics, view them on the embedded dashboard, and send alert(s) to remote servers.
'rancher-vcluster': rancher-vcluster deploys a virtual cluster (vcluster) with Rancher installed. 'rancher-vcluster': rancher-vcluster deploys a virtual cluster (vcluster) with Rancher installed.
'k3k': k3k is an add-on to deploy the k3k controller.
'rancher-k3k': rancher-k3k deploys a k3k virtual cluster with Rancher installed.
'harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations. 'harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations.
'harvester-system/harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations. 'harvester-system/harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations.
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices. 'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
@@ -2154,6 +2192,14 @@ harvester:
hostname: Hostname hostname: Hostname
rancherVersion: Rancher Version rancherVersion: Rancher Version
password: Bootstrap Password password: Bootstrap Password
rancherK3k:
accessRancher: Access the Rancher Dashboard
hostname: Hostname
version: Rancher Version
password: Bootstrap Password
passwordTooltip: The initial password in Rancher for the first time login.
enable:
title: Enable K3k
switchFailed: "{action} {name} failed!" switchFailed: "{action} {name} failed!"
nvidiaDriverToolkit: nvidiaDriverToolkit:
titles: titles:
@@ -2453,6 +2499,7 @@ advancedSettings:
'harv-containerd-registry': Containerd Registry Configuration to connect private registries. 'harv-containerd-registry': Containerd Registry Configuration to connect private registries.
'harv-log-level': Configure Harvester server log level. Defaults to Info. 'harv-log-level': Configure Harvester server log level. Defaults to Info.
'harv-server-version': Harvester server version. 'harv-server-version': Harvester server version.
'harv-server-flavor': "Indicates the Harvester edition: Prime or Community."
'harv-upgrade-checker-enabled': Specifies whether to enable Harvester upgrade check or not. Default is True. 'harv-upgrade-checker-enabled': Specifies whether to enable Harvester upgrade check or not. Default is True.
'harv-upgrade-checker-url': Default Harvester upgrade check url. Only used when the <code>upgrade-checker-enabled</code> is equal to True. 'harv-upgrade-checker-url': Default Harvester upgrade check url. Only used when the <code>upgrade-checker-enabled</code> is equal to True.
'harv-ui-source': Configure how to load the UI source. The `External` option is used to load the UI from a ui-index URL, while the `Bundled` option loads the UI from the local Harvester server. 'harv-ui-source': Configure how to load the UI source. The `External` option is used to load the UI from a ui-index URL, while the `Bundled` option loads the UI from the local Harvester server.
+1 -1
View File
@@ -72,7 +72,7 @@ export default {
/> />
<a <a
v-if="scope.row.metadata.name === 'rancher-vcluster' && scope.row.spec.enabled" v-if="['rancher-vcluster', 'rancher-k3k'].includes(scope.row.metadata.name) && scope.row.spec.enabled"
v-tooltip="t('harvester.addons.rancherVcluster.accessRancher')" v-tooltip="t('harvester.addons.rancherVcluster.accessRancher')"
class="ml-5" class="ml-5"
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
+81 -7
View File
@@ -25,9 +25,7 @@ import { HCI } from '../../types';
import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm'; import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm';
import impl, { QGA_JSON, USB_TABLET } from './impl'; import impl, { QGA_JSON, USB_TABLET } from './impl';
import { GIBIBYTE } from '../../utils/unit'; import { GIBIBYTE } from '../../utils/unit';
import { VOLUME_MODE, ACCESS_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types'; import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY } = ACCESS_MODE;
const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine'; const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine';
@@ -195,6 +193,10 @@ export default {
cpuPinning: false, cpuPinning: false,
cpuModel: '', cpuModel: '',
sysprep: { secretName: '', xmlContent: '' }, sysprep: { secretName: '', xmlContent: '' },
// KubeVirt high-performance VM-level features
blockMultiQueue: false,
ioThreadsPolicy: '',
ioThreadCount: 2,
}; };
}, },
@@ -278,7 +280,7 @@ export default {
}, },
customAccessMode() { customAccessMode() {
return this.storageClassSetting.accessModes || READ_WRITE_MANY; return this.storageClassSetting.accessModes || 'ReadWriteMany';
}, },
isWindows() { isWindows() {
@@ -409,6 +411,10 @@ export default {
const cpuPinning = this.isCpuPinning(spec); const cpuPinning = this.isCpuPinning(spec);
const cpuModel = spec.template.spec.domain.cpu?.model || ''; const cpuModel = spec.template.spec.domain.cpu?.model || '';
const blockMultiQueue = spec.template.spec.domain?.devices?.blockMultiQueue || false;
const ioThreadsPolicy = spec.template.spec.domain?.ioThreadsPolicy || '';
const ioThreadCount = spec.template.spec.domain?.ioThreads?.supplementalPoolThreadCount || 2;
const secretRef = this.getSecret(spec); const secretRef = this.getSecret(spec);
const accessCredentials = this.getAccessCredentials(spec); const accessCredentials = this.getAccessCredentials(spec);
@@ -447,6 +453,10 @@ export default {
this['cpuPinning'] = cpuPinning; this['cpuPinning'] = cpuPinning;
this['cpuModel'] = cpuModel; this['cpuModel'] = cpuModel;
this['blockMultiQueue'] = blockMultiQueue;
this['ioThreadsPolicy'] = ioThreadsPolicy;
this['ioThreadCount'] = ioThreadCount;
this['hasCreateVolumes'] = hasCreateVolumes; this['hasCreateVolumes'] = hasCreateVolumes;
this['networkRows'] = networkRows; this['networkRows'] = networkRows;
this['imageId'] = imageId; this['imageId'] = imageId;
@@ -506,7 +516,7 @@ export default {
id: randomStr(5), id: randomStr(5),
source: SOURCE_TYPE.IMAGE, source: SOURCE_TYPE.IMAGE,
name: 'disk-0', name: 'disk-0',
accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX accessMode: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX
bus, bus,
volumeName: '', volumeName: '',
size, size,
@@ -514,6 +524,9 @@ export default {
storageClassName: '', storageClassName: '',
image: this.imageId, image: this.imageId,
volumeMode: VOLUME_MODE.BLOCK, volumeMode: VOLUME_MODE.BLOCK,
cache: '',
io: '',
dedicatedIOThread: false,
isEncrypted, isEncrypted,
volumeBackups, volumeBackups,
}); });
@@ -572,7 +585,7 @@ export default {
const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`); const pvcResource = allPVCs.find( (O) => O.id === `${ namespace }/${ volume?.persistentVolumeClaim?.claimName }`);
source = SOURCE_TYPE.ATTACH_VOLUME; source = SOURCE_TYPE.ATTACH_VOLUME;
accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY; accessMode = pvcResource?.spec?.accessModes?.[0] || 'ReadWriteMany';
size = pvcResource?.spec?.resources?.requests?.storage || '10Gi'; size = pvcResource?.spec?.resources?.requests?.storage || '10Gi';
storageClassName = pvcResource?.spec?.storageClassName; storageClassName = pvcResource?.spec?.storageClassName;
volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK; volumeMode = pvcResource?.spec?.volumeMode || VOLUME_MODE.BLOCK;
@@ -618,6 +631,9 @@ export default {
type, type,
storageClassName, storageClassName,
hotpluggable, hotpluggable,
cache: DISK?.cache || '',
io: DISK?.io || '',
dedicatedIOThread: DISK?.dedicatedIOThread || false,
shareable: DISK.shareable || false, shareable: DISK.shareable || false,
volumeStatus, volumeStatus,
dataSource, dataSource,
@@ -973,9 +989,25 @@ export default {
// `shareable` is an attachment-level opt-in, remove the field // `shareable` is an attachment-level opt-in, remove the field
// inherited from the old spec when the disk row no longer requests it // inherited from the old spec when the disk row no longer requests it
mergedDisks.forEach((disk) => { mergedDisks.forEach((disk) => {
if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) { const parsed = disks.find((D) => D.name === disk.name);
if (disk.shareable && !parsed?.shareable) {
delete disk.shareable; delete disk.shareable;
} }
// High-performance fields are only serialized when requested (see
// parseDisk). When editing, mergeDeviceList would otherwise keep the
// previous spec's cache/io/dedicatedIOThread even after the row is
// switched back to "Default" or the dedicated I/O thread is unchecked,
// silently preserving stale values — strip them so the disk matches
// what the row actually requests.
if (parsed) {
['cache', 'io', 'dedicatedIOThread'].forEach((field) => {
if (!parsed[field] && field in disk) {
delete disk[field];
}
});
}
}); });
let spec = { let spec = {
@@ -1012,6 +1044,35 @@ export default {
delete spec.template.spec.volumes; delete spec.template.spec.volumes;
} }
// KubeVirt high-performance VM-level features (I/O threads & block multi-queue).
const domainRef = spec.template.spec.domain;
const hasDedicatedIOThread = mergedDisks.some((D) => D.dedicatedIOThread);
// If any disk requests a dedicated I/O thread, KubeVirt requires a policy;
// default it to "shared" so the dedicated thread is honored.
let ioThreadsPolicy = this.ioThreadsPolicy;
if (hasDedicatedIOThread && !ioThreadsPolicy) {
ioThreadsPolicy = 'shared';
}
if (ioThreadsPolicy) {
domainRef.ioThreadsPolicy = ioThreadsPolicy;
} else {
delete domainRef.ioThreadsPolicy;
}
if (ioThreadsPolicy === 'supplementalPool') {
domainRef.ioThreads = { supplementalPoolThreadCount: Number(this.ioThreadCount) || 1 };
} else {
delete domainRef.ioThreads;
}
if (this.blockMultiQueue) {
domainRef.devices.blockMultiQueue = true;
} else {
delete domainRef.devices.blockMultiQueue;
}
if (this.resourceType === HCI.VM) { if (this.resourceType === HCI.VM) {
if (!this.isSingle) { if (!this.isSingle) {
spec = this.multiVMScheduler(spec); spec = this.multiVMScheduler(spec);
@@ -1259,6 +1320,19 @@ export default {
if (R.type === HARD_DISK) { if (R.type === HARD_DISK) {
out.disk = { bus: R.bus }; out.disk = { bus: R.bus };
// KubeVirt high-performance per-disk features. `cache`, `io` and
// `dedicatedIOThread` live at the Disk level (siblings of `disk`), not
// inside the DiskTarget. See disks_and_volumes.md#high-performance-features.
if (R.cache) {
out.cache = R.cache;
}
if (R.io) {
out.io = R.io;
}
if (R.dedicatedIOThread) {
out.dedicatedIOThread = true;
}
} else if (R.type === CD_ROM) { } else if (R.type === CD_ROM) {
out.cdrom = { bus: R.bus }; out.cdrom = { bus: R.bus };
} }
+18 -6
View File
@@ -5,12 +5,14 @@ import HarvesterResource from './harvester';
import { HCI } from '../types'; import { HCI } from '../types';
const HARVESTER_NVIDIA_DRIVER_TOOLKIT = 'harvester-system/nvidia-driver-toolkit'; const HARVESTER_NVIDIA_DRIVER_TOOLKIT = 'harvester-system/nvidia-driver-toolkit';
const RANCHER_VCLUSTER = 'harvester-system/rancher-vcluster';
const RANCHER_K3K_ADDON = 'rancher-k3k/rancher-k3k';
export default class HciAddonConfig extends HarvesterResource { export default class HciAddonConfig extends HarvesterResource {
get availableActions() { get availableActions() {
const out = super._availableActions; const out = super._availableActions;
if (this.id === 'harvester-system/rancher-vcluster') { if (this.id === RANCHER_VCLUSTER || this.id === RANCHER_K3K_ADDON) {
const rancherDashboard = { const rancherDashboard = {
action: 'goToRancher', action: 'goToRancher',
enabled: this.spec.enabled, enabled: this.spec.enabled,
@@ -57,6 +59,15 @@ export default class HciAddonConfig extends HarvesterResource {
return; return;
} }
if (!this.spec.enabled && this.id === RANCHER_K3K_ADDON) {
this.$dispatch('promptModal', {
resources: [this],
component: 'HarvesterEnableK3k',
});
return;
}
this.spec.enabled = !this.spec.enabled; this.spec.enabled = !this.spec.enabled;
await this.save(); await this.save();
} catch (err) { } catch (err) {
@@ -69,18 +80,19 @@ export default class HciAddonConfig extends HarvesterResource {
} }
goToRancher() { goToRancher() {
const valuesContent = jsyaml.load(this.spec.valuesContent);
window.open( window.open(
`https://${ valuesContent.hostname }`, this.rancherHostname,
'_blank', '_blank',
); );
} }
get rancherHostname() { get rancherHostname() {
const valuesContent = jsyaml.load(this.spec.valuesContent); const valuesContent = jsyaml.load(this.spec.valuesContent) || {};
return `https://${ valuesContent.hostname }`; // rancher-k3k nests the hostname under `rancher`, rancher-vcluster keeps it at the top level
const hostname = this.id === RANCHER_K3K_ADDON ? valuesContent.rancher?.hostname : valuesContent.hostname;
return `https://${ hostname }`;
} }
get stateColor() { get stateColor() {
@@ -0,0 +1,14 @@
import ManagementSetting from '@shell/models/management.cattle.io.setting';
import { HCI_SETTING } from '../../config/settings';
export default class HarvesterManagementSetting extends ManagementSetting {
get _availableActions() {
const actions = super._availableActions;
if (this.$rootGetters['isStandaloneHarvester'] && this.id === HCI_SETTING.UI_PL) {
return actions.filter((action) => !['goToClone', 'promptRemove'].includes(action.action));
}
return actions;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "harvester", "name": "harvester",
"description": "Rancher UI Extension for Harvester", "description": "Rancher UI Extension for Harvester",
"version": "1.10.0-dev", "version": "1.9.1-rc3",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
+14 -2
View File
@@ -63,6 +63,7 @@ export default {
return { return {
vendor: getVendor(), vendor: getVendor(),
uiPLSetting: {}, uiPLSetting: {},
privateLabelDraft: null,
uiLogoDarkSetting: {}, uiLogoDarkSetting: {},
uiLogoDark: '', uiLogoDark: '',
uiLogoLightSetting: {}, uiLogoLightSetting: {},
@@ -81,6 +82,17 @@ export default {
}; };
}, },
computed: { computed: {
isHarvesterPrime() {
return !!this.$store.getters['harvester-common/isHarvesterPrime'];
},
privateLabel: {
get() {
return this.privateLabelDraft ?? (this.isHarvesterPrime ? this.$store.getters['harvester-common/privateLabel'] : this.uiPLSetting.value);
},
set(value) {
this.privateLabelDraft = value;
}
},
mode() { mode() {
const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.SETTING); const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.SETTING);
@@ -112,7 +124,7 @@ export default {
this.errors.push(e); this.errors.push(e);
}, },
async save(btnCB) { async save(btnCB) {
this.uiPLSetting.value = this.uiPLSetting.value.replaceAll(/[\<>&=#()"]/gm, ''); this.uiPLSetting.value = (this.privateLabel || '').replaceAll(/[\<>&=#()"]/gm, '');
if (this.customizeLogo) { if (this.customizeLogo) {
this.uiLogoLightSetting.value = this.uiLogoLight; this.uiLogoLightSetting.value = this.uiLogoLight;
this.uiLogoDarkSetting.value = this.uiLogoDark; this.uiLogoDarkSetting.value = this.uiLogoDark;
@@ -169,7 +181,7 @@ export default {
<div class="row mb-20"> <div class="row mb-20">
<div class="col span-6"> <div class="col span-6">
<LabeledInput <LabeledInput
v-model:value="uiPLSetting.value" v-model:value="privateLabel"
:label="t('branding.uiPL.label')" :label="t('branding.uiPL.label')"
:mode="mode" :mode="mode"
:maxlength="100" :maxlength="100"
@@ -1,18 +1,10 @@
<script> <script>
import { defineAsyncComponent } from 'vue';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import TabTitle from '@shell/components/TabTitle';
import { HCI } from '../../../../../types'; import { HCI } from '../../../../../types';
import NovncConsoleWrapper from '../../../../../components/novnc/NovncConsoleWrapper.vue';
export default { export default {
components: { components: { NovncConsoleWrapper, Loading },
NovncConsoleWrapper: defineAsyncComponent({
loader: () => import('../../../../../components/novnc/NovncConsoleWrapper.vue'),
loadingComponent: Loading,
}),
Loading,
TabTitle
},
async fetch() { async fetch() {
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI }); this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
@@ -36,21 +28,17 @@ export default {
mounted() { mounted() {
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
this.$refs.console?.close(); this.$refs.console.close();
}); });
}, },
head() {
return { title: this.vmi?.metadata?.name };
},
}; };
</script> </script>
<template> <template>
<TabTitle
v-if="vmi?.metadata?.name"
:breadcrumb="false"
:show-child="false"
>
{{ vmi.metadata.name }}
</TabTitle>
<Loading v-if="$fetchState.pending" /> <Loading v-if="$fetchState.pending" />
<NovncConsoleWrapper <NovncConsoleWrapper
v-else v-else
+19
View File
@@ -1,4 +1,6 @@
import Parse from 'url-parse'; import Parse from 'url-parse';
import { MANAGEMENT } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { HCI } from '../types'; import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester'; import { PRODUCT_NAME } from '../config/harvester';
import { featureEnabled, getVersion } from '../utils/feature-flags'; import { featureEnabled, getVersion } from '../utils/feature-flags';
@@ -64,6 +66,23 @@ const mutations = {
}; };
const getters = { const getters = {
isHarvesterPrime: (_state, _getters, _rootState, rootGetters) => {
const setting = rootGetters['harvester/byId']?.(HCI.SETTING, 'server-flavor');
return (setting?.value || setting?.default) === 'prime';
},
privateLabel: (_state, getters, _rootState, rootGetters) => {
const setting = rootGetters['management/byId']?.(MANAGEMENT.SETTING, SETTING.PL);
const value = setting?.value || '';
if (getters.isHarvesterPrime && value === (setting?.default || 'Harvester')) {
return '';
}
return value;
},
getBundleId(state) { getBundleId(state) {
return state.latestBundleId; return state.latestBundleId;
}, },
+7 -2
View File
@@ -1,3 +1,5 @@
import { FAVORITE_TYPES } from '@shell/store/prefs';
/** /**
* Dynamically toggles SideNav entries based on the enabled status of a specific Addon. * Dynamically toggles SideNav entries based on the enabled status of a specific Addon.
* *
@@ -26,10 +28,13 @@ export function registerAddonSideNav(store, productName, {
// Toggle the trigger a few times so an early kick (fired before the SideNav // Toggle the trigger a few times so an early kick (fired before the SideNav
// has mounted on first login) is retried once the component is listening. // has mounted on first login) is retried once the component is listening.
// Use the prefs/load mutation (in-memory only)
[0, 600, 1500].forEach((delay) => { [0, 600, 1500].forEach((delay) => {
setTimeout(() => { setTimeout(() => {
store.dispatch('type-map/addFavorite', TRIGGER); const base = (store.getters['prefs/get'](FAVORITE_TYPES) || []).filter((t) => t !== TRIGGER);
setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
store.commit('prefs/load', { key: FAVORITE_TYPES, value: [...base, TRIGGER] });
setTimeout(() => store.commit('prefs/load', { key: FAVORITE_TYPES, value: [...base] }), 300);
}, delay); }, delay);
}); });
}; };
+1 -24
View File
@@ -1,24 +1 @@
const path = require('path'); module.exports = require('@rancher/shell/pkg/vue.config')(__dirname);
const baseConfig = require('@rancher/shell/pkg/vue.config')(__dirname);
const baseConfigureWebpack = baseConfig.configureWebpack;
// noVNC >= 1.7 only publishes `./core/rfb.js` in its `exports` map, so resolve the
// package by path to keep the keysym/input helpers importable.
const NOVNC_DIR = path.resolve(__dirname, '..', '..', 'node_modules', '@novnc', 'novnc');
module.exports = {
...baseConfig,
configureWebpack(config) {
if (typeof baseConfigureWebpack === 'function') {
baseConfigureWebpack(config);
}
config.resolve.alias['@novnc/novnc'] = NOVNC_DIR;
// noVNC 1.7 uses top-level await for WebCodecs detection.
config.output = { ...config.output, environment: { ...config.output?.environment, asyncFunction: true } };
}
};
+1 -21
View File
@@ -1,26 +1,6 @@
const path = require('path');
const config = require('@rancher/shell/vue.config'); const config = require('@rancher/shell/vue.config');
const baseConfig = config(__dirname, { module.exports = config(__dirname, {
excludes: [], excludes: [],
// excludes: ['harvester'] // excludes: ['harvester']
}); });
const baseConfigureWebpack = baseConfig.configureWebpack;
module.exports = {
...baseConfig,
configureWebpack(webpackConfig) {
if (typeof baseConfigureWebpack === 'function') {
baseConfigureWebpack(webpackConfig);
}
// noVNC >= 1.7 only publishes `./core/rfb.js` in its `exports` map, so resolve the
// package by path to keep the keysym/input helpers importable.
webpackConfig.resolve.alias['@novnc/novnc'] = path.resolve(__dirname, 'node_modules/@novnc/novnc');
// noVNC 1.7 uses top-level await for WebCodecs detection.
webpackConfig.output = { ...webpackConfig.output, environment: { ...webpackConfig.output?.environment, asyncFunction: true } };
}
};
+1385 -1483
View File
File diff suppressed because it is too large Load Diff