Compare commits

...

56 Commits

Author SHA1 Message Date
Andy Lee
f63be73b11
fix: ACCESS_MODE undefined error in forklift mapping page (#1111) 2026-08-14 10:01:27 +08:00
Andy Lee
fabc1cec21
fix: prevent duplicated cloud-config for empty user data (#1109)
* fix: prevent duplicated cloud-config for empty user data

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

* refactor: AI review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-12 14:20:25 +08:00
Andy Lee
ed2b6f2551
fix: remove map ownerref writeback and delete maps via plan refs (#1105)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-10 18:14:48 +08:00
renovate[bot]
72282ae50e
deps: update dependency @types/lodash to v4.17.25 (#1099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 10:10:31 +08:00
Volker Theile
8fba4e7050
fix: Rename to Windows Unattended & Sysprep (#1096)
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

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-08-07 11:22:23 +08:00
Andy Lee
fbd4a1108f
ci: add test steps in PR template (#1093)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-05 15:53:40 +08:00
Andy Lee
823a88c85b
ci: not use actions/github-script@v7 (#1092)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-05 14:37:24 +08:00
Andy Lee
3a2c6e3a3d
ci: support manual standalone build trigger and PR comment (#1091)
* ci: add manually trigger build

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

* refactor: PR comment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-05 14:21:41 +08:00
Andy Lee
be6e9b5f06
fix(subnet): support None option for VLAN dropdown (#1085)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-05 12:23:57 +08:00
Andy Lee
a88bed9eb4
fix: clear keyword should clear highlight (#1088)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-05 00:24:34 +08:00
Andy Lee
f97f14c811
deps: bump @rancher/shell to 3.0.12-rc.7 (#1084)
* deps: update rancher/shell to 3.0.12-rc.7

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

* fix: move filter labels/schedule buttons position

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

* feat: improve serial console

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

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

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

* refactor: clear searach result after close

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

* refactor: fine tune serial console

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-04 19:53:24 +08:00
Andy Lee
f8a29a1c54
fix: project resource quota not set in POST API payload (#1082)
* fix: project resource quota not set in POST API payload

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

* fix: guard onQuotasInput against undefined payload

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-04 13:27:30 +08:00
freeze
9a935434c2
feat: support attaching volumes as shareable disks (#1026)
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

Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
2026-08-03 16:24:33 +08:00
Andy Lee
afe632b723
feat: add access/volume mode support and improvements for VM migration (#1074)
* fix: VM migration tab not appear when first login

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

* feat: add access/ volume mode model

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

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

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

* fix: bugs in VM selection table

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-03 12:58:37 +08:00
renovate[bot]
d050f21f9a
deps: update patch dependencies (#1075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 12:58:04 +08:00
Andy Lee
b87f367583
fix: prevent VM detail page hang when static IP assigned (#1072)
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.

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 16:55:25 +08:00
Andy Lee
92aa8ec603
ci: disable footer-max-line-length rule (#1069)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-29 14:46:07 +08:00
Alejandro Bonilla
e013534373
feat: allow Cloud Config for Windows guests (Cloudbase-Init) (#984)
* feat(vm): allow Cloud Config for Windows guests

Currently the Cloud Config editor is completely hidden when a VM's
OS Type is set to Windows, and no `cloudinitdisk` volume or secret
is written to the VM spec even if the user has entered content.

This blocks a common Windows-on-Harvester workflow: using
Cloudbase-Init to run first-boot configuration (install VMDP,
enable OpenSSH server, add authorized_keys, join a domain, run
`runcmd` steps, etc.). Users on Windows today have to hand-edit the
VM YAML after creation to attach a cloudinitdisk, which is fragile
and undiscoverable.

Changes:
- Remove the `v-if="!isWindows"` on the CloudConfig component so
  the editor is available for every OS type. The Sysprep editor
  continues to render only for Windows, so Windows now gets both
  panels; Linux is unchanged.
- Drop the `if (!this.isWindows)` guard around the cloudinitdisk
  volume serialization so the disk is attached whenever the user
  provided user-data or network-data, regardless of OS.
- Drop the `|| this.isWindows` early-return in `saveSecret` so the
  cloud-init secret is persisted for Windows VMs too.
- `getInitUserData` now returns a bare `#cloud-config\n` header for
  Windows instead of the Linux qemu-guest-agent runcmd template
  (which would fail on Cloudbase-Init). VMDP installs the QGA on
  Windows as a native service, so the runcmd path is not needed.

The "Install guest agent" checkbox stays disabled for Windows
because it specifically drives the Linux QGA-via-runcmd recipe.

Fixes point 2 of harvester/harvester#11124.

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix(vm): do not clear cloud-config on isWindows watcher fire

The isWindows watcher runs on every change of the isWindows computed
property, including on mount when editing an existing Windows VM. Setting
this['userScript'] and this['networkScript'] to undefined at that point
wipes the cloud-config data that was just loaded from the existing VM's
cloudinit secret, so the Advanced -> Cloud Config editor shows empty
even though the VM has data on the wire.

Remove the two clears - Cloud Config is now supported for Windows guests
by this PR, so there is no reason to unset the user/network scripts when
the OS type is Windows. sshKey and installAgent remain gated (SSH keys
are injected via cloud-init on Linux only, and the qemu-guest-agent
package install checkbox does not apply to Windows).

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>

* refactor(vm): Get CloudInit working for VM templates

- Remove unnecessary osType watcher in cloud config. The watcher (see `pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue`) that cleared the cloud config template for Windows VMs is no longer necessary.
- Automatically strip the CloudInit `User Data` properties `package_update` and `packages` for OS type `Windows`.

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

---------

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-28 11:32:30 +02:00
Andy Lee
d23c3d3b72
revert: PR build artifact (#1066)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 15:39:20 +08:00
Andy Lee
719c33fa12
ci: fix CI_BRANCH ternary so PR builds skip upload gate (#1062)
* ci: fix CI_BRANCH ternary so PR builds skip upload gate

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

* ci: simplify CI_BRANCH expression comment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:34:21 +08:00
Andy Lee
a6bd7ec00e
feat: add VMware VDDK image input field to VM migration provider (#1057)
* 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.

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:10:44 +08:00
Andy Lee
ac26fe2f0e
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>
2026-07-28 13:56:11 +08:00
Andy Lee
790cade04d
feat: validate migration plan name against existing providers and plans (#1053)
Show an error banner and disable the Create and Start action when the migration plan name matches an existing provider or migration plan name.

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-24 16:43:57 +08:00
Andy Lee
2903e86c02
refactor: new design for review page (#1051)
* refactor: new design for review page

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

* refactor: customize empty table rows message

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

* refactor: fixed the table column

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

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

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

* style: remove unneeded overflow css

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-24 10:24:22 +08:00
Andy Lee
2d747e435a
fix: forklift mapping UX and migration progress fixes (#1049)
* refactor: modify relatedResources using ownerReferences uid

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

* refactor: wordings

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

* 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.

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-23 14:38:49 +08:00
Copilot
eadda2a18b
refactor: extract access mode strings to static ACCESS_MODE constant (#1039)
* refactor: extract access mode strings to static ACCESS_MODE constant

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

* refactor: destructure ACCESS_MODE variables in CDISettings.vue accessModeOptions

* refactor: destructure ACCESS_MODE at top level in all consumer files

* fix(lint): reorder imports to top of module in CDISettings, harvesterhci.io.volume, and VirtualMachineVolume

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-21 11:07:48 +08:00
Volker Theile
a178302f73
fix: Creating a VM with no cloud init user data but network data only fails (#1044)
Related to: https://github.com/harvester/harvester/issues/11213

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-07-21 11:06:24 +08:00
Andy Lee
d376bfb3a5
docs: require scope-less conventional commit format in guides (#1042)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-20 20:05:36 +08:00
Andy Lee
7a84e08f99
fix(vm-migration): show elapsed duration for in-progress plans (#1037)
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

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-20 17:05:27 +08:00
Andy Lee
3d39626b93
docs(agents): clarify agent guide and share it across AI assistants (#1035)
* ci: disable header-max-length commit rule

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

* 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

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:51:12 +08:00
Andy Lee
1c3b40b631
refactor(host): update resource usage gauges and table column headers to fit small screen (#1031)
* 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

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

* refactor: add noopener noreferrer rel

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:29:12 +08:00
Andy Lee
87baf75301
fix(ci): support scoped commit prefixes in PR label extraction (#1032)
Strip the scope in parentheses so titles like 'refactor(host): ...'
resolve to the 'refactor' type instead of falling back to 'other'.

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-20 15:23:26 +08:00
Andy Lee
a10d651827
feat: improve VM migration dashboard mapping labels and VM detail link (#1029)
* 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

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

* refactor: modify duration column width

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

* refactor: copilot review comment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-20 14:30:26 +08:00
renovate[bot]
11ff7c8a7c
deps: update dependency @types/node to v25.9.5 (#1021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 12:20:34 +08:00
Jack Yu
f6dffe9816
feat: support skip MIG mode configuration in srivogpu (#1009)
Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-07-20 11:22:40 +08:00
Andy Lee
b813bc578e
docs: add XSS safety comment for VM display name annotation (#1019)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-17 17:30:59 +08:00
Andy Lee
7c3b2730ab
fix: remove bootable volume validation in VM disk validator (#1016)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-17 16:59:02 +08:00
Andy Lee
429d9e18ad
fix: stale cloud-init secret name reused after VM name change (#1012)
* 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

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

* refactor: copilot review

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

* refactor: remove console

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-17 14:29:01 +08:00
Andy Lee
dc166d8fc9
ci: modify PR template (#1013)
* ci: modify PR template

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

* refactor: review comment

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-17 14:22:32 +08:00
Andy Lee
3ff4534294
feat: allow user specify namespace for migrated VM (#1007)
* feat: allow user specify namespace for migrated VM

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

* refactor: not pre-select target namespace

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

* refactor: ignore option

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-16 15:29:39 +08:00
Andy Lee
d4223ad2ae
feat: keep upgrade button appear (#1003)
* feat: keep upgrade button appear

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-16 11:24:51 +08:00
Andy Lee
44297ada16
feat: add migration plan duration time column (#1005)
* feat: add migration plan duration time column

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

* feat: allow duration column to be sorted

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-15 18:42:01 +08:00
Andy Lee
aea53fdd65
chore: update main branch to 1.10.0-dev (#1004)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-15 18:23:32 +08:00
Andy Lee
9640c87947
fix: allow input vlan 1 in host network (#999)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-15 15:24:49 +08:00
Volker Theile
2cefd7cb78
fix: A disk entry must be either disk or cdrom, never both (#998)
Related to: https://github.com/harvester/harvester/issues/11154

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-07-15 09:11:58 +02:00
Andy Lee
fb6ffa3c0f
refactor: add customized migration plan delete model (#996)
* refactor: add customized migration plan delete model

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

* refactor: copilot review

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-14 17:36:25 +08:00
Andy Lee
e0436bff28
feat: add migration steps tooltip when mouse hover on progress bar (#994)
* feat: add progress bar tooltip

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

* refactor: copilot review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-14 14:37:13 +08:00
Andy Lee
219f39ae52
refactor: rename pod network to management network (#992) 2026-07-14 13:56:59 +08:00
Andy Lee
41cf44d231
refactor: forklift UI bug and enhancement (#989)
* fix: hide Virtul machine migration tabs if forklift addon is not enabled

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

* fix: rename advanced to configurations

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

* fix: fixed column width

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

* refactor: review comment

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

* revert: power on background color

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-14 12:04:00 +08:00
Jack Yu
7d09cb7142
feat: prevent users from selecting mounted devices (#987)
Signed-off-by: Jack Yu <jack.yu@suse.com>
2026-07-14 11:29:41 +08:00
marcelofukumoto
560643d299
feat: forklift UI (#891)
* feat: forklift poc first commit

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): UI based on the FIGMA

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Add the succeded state

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* Merge the new APIs

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Added pagination UI

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Remove test flags

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Changed based on review

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): fixed title and button

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Design changes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Changed to Wizard

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): added the provider wizard

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Fixed some UI changes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): update size

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): small fixes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): small fixes after Copilot

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): small fixes after Copilot

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): reverted settings.json changes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): small fixes after Copilot

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

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

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): Fixed the type for VMware

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat(forklift): small changes before rebase

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat: Added new API for the vms

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat: Fix some labels

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat: Added some final fixes to structure

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat: added loading to provider steps

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

* feat: added comments from copilot review

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>

---------

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-13 16:58:35 +08:00
Renuka Devi Rajendran
c3bb0ea4be
feat: Display mgmt interface details under mgmt cluster network (#974)
Signed-off-by: Renuka Devi Rajendran <renuka.rajendran@suse.com>
2026-07-10 11:40:57 +08:00
Andy Lee
3c37f04ef6
chore: limit the rancher to 2.15.0+ for harvester v1.9.0 (#982)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-10 10:52:33 +08:00
Volker Theile
d98db6ba49
feat: Allow user to customize snapshot/backup deadline (#866)
* 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

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

* 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

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

---------

Signed-off-by: Volker Theile <vtheile@suse.com>
2026-07-09 08:44:47 +02:00
Andy Lee
531ed03796
feat: allow user specify static IP when VM creation (#973)
* feat: allow suer give static IP for VM

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

* feat: add feature flag

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-09 13:17:19 +08:00
Andy Lee
59f260eab8
fix: vpc topology should consume NAD labels (#975)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-08 13:47:25 +08:00
78 changed files with 7421 additions and 664 deletions

View File

@ -1,14 +1,23 @@
<!-- This template is for Devs to give QA details before moving the issue To-Test -->
### Summary ### Summary
<!-- Give the fix root cause or feature requirement, at least list what this PR changes -->
### PR Checklists
### PR Checklist
<!-- Check Yes if there is backend PR related to this UI PR, tag the backend owner's Github account -->
- Are backend engineers aware of UI changes ? - Are backend engineers aware of UI changes ?
- [ ] Yes, the backend owner is: - [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
### Related Issue # ### Related Issue
<!-- Define findings related to the feature or bug issue. --> <!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
### Test Steps
<!-- Provide the test steps for reviewer to verify the issue -->
1. Go to ... page
### Test screenshot or video ### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary --> <!-- Attach screenshot or video of the changes and eventual comparison -->

View File

@ -1,6 +1,7 @@
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
@ -29,3 +30,35 @@ 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"

View File

@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. > This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
# Project Overview # Project Overview
@ -28,9 +28,7 @@ You are an expert Senior Software Engineer specializing in Vue.js and TypeScript
- Run `yarn lint:fix` before commits. - Run `yarn lint:fix` before commits.
- Use conventional commit format: - Use conventional commit format:
``` ```
<type>: <type>: <description>
<description>
``` ```
- Follow existing naming conventions (PascalCase for components, camelCase for functions). - Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint. - After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.
@ -130,8 +128,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`: - `Vue.js`:
- Composition API components are preferred over Options API. - Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components. - Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Place source tag above template above style. - Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- style tag should contain `lang='scss' scoped`. - The `<style>` tag should include `lang="scss" scoped`.
- `Linting`: Follow the ESLint configuration in the root. - `Linting`: Follow the ESLint configuration in the root.
- **File Structure:** - **File Structure:**
@ -163,24 +161,26 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide ## Harvester UI Extension Development Guide
1. Backward Compatibility ### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps: To add a feature in a new release, follow these steps:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration. 1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
Check: Use the following getter to verify if the feature is enabled for the current version: ```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
``` 3. **Render**: Use the result of the check to conditionally render the UI components.
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.
## E2E Tests (Cypress) ## E2E Tests (Cypress)
@ -228,7 +228,7 @@ For the Renovate config, see `.github/renovate.json`.
### Commit Message Format ### Commit Message Format
All commit messages must follow the conventional commit format: All commit messages must follow the conventional commit format:
``` ```
<type>[optional scope]: <description> <type>: <description>
[optional body] [optional body]

1
CLAUDE.md Symbolic link
View File

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

1
GEMINI.md Symbolic link
View File

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

View File

@ -25,7 +25,7 @@ For Harvester UI extension installation instructions, please refer to the page *
## Development Setup ## Development Setup
Ensure **Node.js v20 or later** is installed for development and debugging. Ensure **Node.js v24 or later** is installed for development and debugging.
### Standalone Mode ### Standalone Mode

View File

@ -32,10 +32,11 @@ module.exports = {
'subject-empty': [2, 'never'], 'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'], 'subject-full-stop': [2, 'never', '.'],
'subject-max-length': [0, 'never'], 'subject-max-length': [0, 'never'],
'header-max-length': [0, 'always', 100],
'body-leading-blank': [2, 'always'], 'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100], 'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'], 'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [2, 'always', 100], 'footer-max-line-length': [0, 'always', 100],
}, },
// Ignore merge commits and revert commits // Ignore merge commits and revert commits
ignores: [ ignores: [

View File

@ -6,9 +6,7 @@
- Run `yarn lint:fix` before commits. - Run `yarn lint:fix` before commits.
- Use conventional commit format: - Use conventional commit format:
``` ```
<type>: <type>: <description>
<description>
``` ```
- Follow existing naming conventions (PascalCase for components, camelCase for functions). - Follow existing naming conventions (PascalCase for components, camelCase for functions).
- After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint. - After changing a Vue, JS, or TS file, make sure it's automatically formatted with ESLint.

View File

@ -16,8 +16,8 @@ To get started, follow the `Development Setup` section.
- `Vue.js`: - `Vue.js`:
- Composition API components are preferred over Options API. - Composition API components are preferred over Options API.
- Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components. - Large pages with lots of code and styles should be avoided by breaking the page up into smaller Vue components.
- Place source tag above template above style. - Order the Single File Component blocks as `<script>`, then `<template>`, then `<style>`.
- style tag should contain `lang='scss' scoped`. - The `<style>` tag should include `lang="scss" scoped`.
- `Linting`: Follow the ESLint configuration in the root. - `Linting`: Follow the ESLint configuration in the root.
- **File Structure:** - **File Structure:**
@ -49,22 +49,24 @@ To get started, follow the `Development Setup` section.
## Harvester UI Extension Development Guide ## Harvester UI Extension Development Guide
1. Backward Compatibility ### Backward Compatibility
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in pkg/config/feature-flags to ensure the UI matches the cluster's specific version.
The Harvester UI Extension supports earlier cluster versions (e.g., UI Ext v1.8.0 works with clusters v1.7.0 and v1.6.0). It uses Feature Flags defined in `pkg/harvester/config/` to ensure the UI matches the cluster's specific version.
### Implementation Steps for New Features
2. Implementation Steps for New Features
To add a feature in a new release, follow these steps: To add a feature in a new release, follow these steps:
Register: Add a unique [Feature Name] to the corresponding release array in the configuration. 1. **Register**: Add a unique `[Feature Name]` to the corresponding release array in the configuration.
2. **Check**: Use the following getter to verify if the feature is enabled for the current version:
Check: Use the following getter to verify if the feature is enabled for the current version: ```js
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
``` 3. **Render**: Use the result of the check to conditionally render the UI components.
computed: {
newFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('[Feature Name]');
},
},
```
Render: Use the result of the check to conditionally render the UI components.

View File

@ -26,7 +26,7 @@
### Commit Message Format ### Commit Message Format
All commit messages must follow the conventional commit format: All commit messages must follow the conventional commit format:
``` ```
<type>[optional scope]: <description> <type>: <description>
[optional body] [optional body]

View File

@ -1,4 +1,4 @@
> This `./AGENTS.md` file is generated by running `yarn agents:generate`. > This `./AGENTS.md` file is generated by running `yarn agents:generate`. `CLAUDE.md` and `GEMINI.md` are symlinks to this file so Claude Code, GitHub Copilot and Gemini all read the same content.
# Project Overview # Project Overview

View File

@ -1,13 +1,13 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.9.0-dev", "version": "1.10.0-dev",
"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",
"@rancher/shell": "3.0.12-rc.3", "@rancher/shell": "3.0.12-rc.7",
"@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",
@ -15,13 +15,19 @@
"cache-loader": "^4.1.0", "cache-loader": "^4.1.0",
"color": "4.2.3", "color": "4.2.3",
"ip": "2.0.1", "ip": "2.0.1",
"@xterm/xterm": "6.0.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/addon-canvas": "0.7.0",
"node-polyfill-webpack-plugin": "^3.0.0", "node-polyfill-webpack-plugin": "^3.0.0",
"elkjs": "^0.11.0", "elkjs": "^0.11.0",
"vue-draggable-next": "^2.2.1", "vue-draggable-next": "^2.2.1",
"yaml": "^2.5.1" "yaml": "^2.5.1"
}, },
"resolutions": { "resolutions": {
"@types/node": "25.9.4", "@types/node": "25.9.5",
"cronstrue": "2.59.0", "cronstrue": "2.59.0",
"d3-color": "3.1.0", "d3-color": "3.1.0",
"ejs": "3.1.10", "ejs": "3.1.10",
@ -29,12 +35,12 @@
"glob": "7.2.3", "glob": "7.2.3",
"glob-parent": "6.0.2", "glob-parent": "6.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.25",
"merge": "2.1.1", "merge": "2.1.1",
"node-forge": "1.4.0", "node-forge": "1.4.0",
"nth-check": "2.1.1", "nth-check": "2.1.1",
"qs": "6.15.3", "qs": "6.15.3",
"roarr": "7.21.6", "roarr": "7.21.7",
"semver": "7.8.5", "semver": "7.8.5",
"@vue/cli-service/html-webpack-plugin": "^5.0.0" "@vue/cli-service/html-webpack-plugin": "^5.0.0"
}, },

View File

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

View File

@ -116,6 +116,11 @@ export default {
.vm-schedule-dropdown .v-popper__arrow-container { .vm-schedule-dropdown .v-popper__arrow-container {
display: none; display: none;
} }
.fixed-header-actions .middle .vm-schedule-filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -55,8 +55,12 @@ export default {
!this.latestUpgrade.isUpgradeFailed; !this.latestUpgrade.isUpgradeFailed;
}, },
canUpgrade() {
return this.versionOptions.length > 0 && !this.isUpgradeInProgress;
},
versionOptions() { versionOptions() {
const versions = this.$store.getters['harvester/all'](HCI.VERSION); const versions = this.$store.getters['harvester/all'](HCI.VERSION) || [];
return versions.map((V) => V.metadata.name); return versions.map((V) => V.metadata.name);
}, },
@ -154,14 +158,23 @@ export default {
:cluster="currentCluster.nameDisplay" :cluster="currentCluster.nameDisplay"
/> />
</h1> </h1>
<button <span
v-if="versionOptions.length && !isUpgradeInProgress" v-clean-tooltip="{
type="button" content: canUpgrade ? t('harvester.upgradePage.upgradeAvailable') : t('harvester.upgradePage.upgradeUnavailable'),
class="btn bg-warning btn-sm" triggers: ['hover', 'focus', 'touch'],
@click="open" }"
class="upgrade-btn-wrap"
:tabindex="canUpgrade ? -1 : 0"
> >
<t k="harvester.upgradePage.upgrade" /> <button
</button> :disabled="!canUpgrade"
type="button"
class="btn bg-warning btn-sm"
@click="open"
>
<t k="harvester.upgradePage.upgrade" />
</button>
</span>
</header> </header>
<ModalWithCard <ModalWithCard
@ -296,6 +309,18 @@ export default {
line-height: 23px; line-height: 23px;
} }
.upgrade-btn-wrap {
display: inline-flex;
button {
color: #000 !important;
}
button:hover {
color: #000 !important;
}
}
.currentVersion { .currentVersion {
margin-top: 10px; margin-top: 10px;
display: flex; display: flex;

View File

@ -0,0 +1,59 @@
<script setup>
defineProps({
networkEntries: {
type: Array,
default: () => [],
},
storageEntries: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div class="mappings-cell">
<div
v-for="(entry, idx) in networkEntries"
:key="'net-' + idx"
class="mapping-entry"
>
<i
class="icon icon-network"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
<div
v-for="(entry, idx) in storageEntries"
:key="'stor-' + idx"
class="mapping-entry"
>
<i
class="icon icon-datastore"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-cell {
display: flex;
flex-direction: column;
gap: 8px;
}
.mapping-entry {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
.icon {
font-size: 16px;
}
}
</style>

View File

@ -1,7 +1,6 @@
<script> <script>
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import Socket, { import Socket, {
EVENT_CONNECTED, EVENT_CONNECTED,
EVENT_CONNECTING, EVENT_CONNECTING,
@ -22,74 +21,98 @@ export default {
data() { data() {
return { return {
socket: null, socket: null,
terminal: null, terminal: null,
fitAddon: null, textDecoder: null,
searchAddon: null, fitAddon: null,
webglAddon: null, searchAddon: null,
isOpen: false, webglAddon: null,
isOpening: false, onResize: null,
backlog: [], isOpen: false,
firstTime: true, isOpening: false,
queue: [] backlog: [],
firstTime: true,
queue: [],
isDraining: false,
drainScheduled: false,
drainTimer: null,
showSearch: false,
searchQuery: '',
searchOptions: {
caseSensitive: false,
regex: false,
wholeWord: false,
incremental: true,
decorations: {
matchBackground: '#B45309',
matchBorder: '#FDBA74',
matchOverviewRuler: '#FDBA74',
activeMatchBackground: '#EA580C',
activeMatchBorder: '#FED7AA',
activeMatchColorOverviewRuler: '#FED7AA',
},
},
}; };
}, },
computed: { computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() { xtermConfig() {
return { return {
allowProposedApi: true, allowProposedApi: true,
cursorBlink: true, cursorBlink: true,
cursorStyle: 'bar',
cursorWidth: 2,
scrollback: 5000,
useStyle: true, useStyle: true,
fontSize: 12, fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 16,
lineHeight: 1.3,
}; };
}, },
}, },
watch: {
queue: {
handler: debounce(async function(neu) {
if (neu.length === 0) {
return;
}
const msg = await Promise.all(neu);
(msg || []).forEach((m) => {
this.terminal.write(m);
});
this.queue = [];
}, 10),
deep: true
}
},
beforeUnmount() { beforeUnmount() {
this.close(); this.close();
}, },
watch: {
serialConsoleUrl() {
if (this.terminal) {
this.connect();
}
},
},
async mounted() { async mounted() {
this.textDecoder = new TextDecoder('utf-8');
await this.setupTerminal(); await this.setupTerminal();
await this.connect(); await this.connect();
this.onResize = debounce(() => this.fit(), 100);
window.addEventListener('resize', this.onResize);
this.scheduleFit();
}, },
methods: { methods: {
async setupTerminal() { async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body')); const docStyle = getComputedStyle(document.querySelector('body'));
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm'); const xterm = await import(/* webpackChunkName: "xterm" */ '@xterm/xterm');
const addons = await allHash({ const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'), fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'), webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'), weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'), search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'),
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
}); });
const terminal = new xterm.Terminal({ const terminal = new xterm.Terminal({
theme: { theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(), background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(), cursor: docStyle.getPropertyValue('--terminal-text').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim() foreground: docStyle.getPropertyValue('--terminal-text').trim()
}, },
...this.xtermConfig, ...this.xtermConfig,
@ -97,25 +120,48 @@ export default {
this.fitAddon = new addons.fit.FitAddon(); this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon(); this.searchAddon = new addons.search.SearchAddon();
try {
this.webglAddon = new addons.webgl.WebGlAddon();
} catch (e) {
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
this.webglAddon = null;
}
terminal.loadAddon(this.fitAddon); terminal.loadAddon(this.fitAddon);
terminal.loadAddon(this.searchAddon); terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon()); terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm); terminal.open(this.$refs.xterm);
if ( this.webglAddon ) { // The webgl renderer can fail without throwing: the context may be lost
// mid-session, or it can attach but silently never paint, leaving the
// terminal's helper textarea at 0x0. Detect both cases and fall back to the
// canvas renderer so the terminal stays interactive.
try {
this.webglAddon = new addons.webgl.WebglAddon();
this.webglAddon.onContextLoss(() => {
if (!this.isUnmounted && this.terminal) {
this.fallbackToCanvas(terminal, addons);
}
});
terminal.loadAddon(this.webglAddon); terminal.loadAddon(this.webglAddon);
// Detect the silent "attaches but never paints" failure after a render frame.
requestAnimationFrame(() => {
if (this.isUnmounted || !this.terminal) {
return;
}
const textarea = this.$refs.xterm?.querySelector('.xterm-helper-textarea');
const hasPainted = textarea && textarea.getBoundingClientRect().height > 0;
if (this.webglAddon && !hasPainted) {
this.fallbackToCanvas(terminal, addons);
this.fit();
}
});
} catch (e) {
// Some browsers (e.g. Safari) don't support the webgl renderer, so don't use it.
this.fallbackToCanvas(terminal, addons);
} }
this.fit(); this.fit();
this.flush(); this.flush();
this.scheduleDrainQueue();
terminal.onData((input) => { terminal.onData((input) => {
const msg = this.str2ab(input); const msg = this.str2ab(input);
@ -123,9 +169,132 @@ export default {
this.write(msg); this.write(msg);
}); });
terminal.attachCustomKeyEventHandler((event) => {
const key = event.key?.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'f') {
event.preventDefault();
this.openSearch();
return false;
}
if (key === 'escape' && this.showSearch) {
event.preventDefault();
this.closeSearch();
return false;
}
return true;
});
this.terminal = terminal; this.terminal = terminal;
}, },
openSearch() {
this.showSearch = true;
this.$nextTick(() => {
const input = this.$refs.searchInput;
if (input) {
input.focus();
input.select();
}
});
},
closeSearch() {
this.showSearch = false;
this.searchQuery = '';
this.clearSearchHighlights();
this.terminal?.focus();
},
clearSearchHighlights() {
this.searchAddon?.clearActiveDecoration();
this.searchAddon?.clearDecorations();
this.terminal?.clearSelection();
// Disposing the search decorations doesn't repaint the webgl/canvas
// layer, so force a refresh to remove the lingering highlight.
if (this.terminal) {
this.terminal.refresh(0, this.terminal.rows - 1);
}
},
findNext() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findNext(this.searchQuery, this.searchOptions);
},
findPrevious() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findPrevious(this.searchQuery, this.searchOptions);
},
onSearchInput() {
if (!this.searchQuery) {
this.clearSearchHighlights();
return;
}
this.findNext();
},
onSearchKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
this.findPrevious();
} else {
this.findNext();
}
}
if (event.key === 'Escape') {
event.preventDefault();
this.closeSearch();
}
},
scheduleFit() {
this.$nextTick(() => {
requestAnimationFrame(() => {
this.fit();
// Re-fit after layout settles to avoid undersized rows on first paint.
setTimeout(() => this.fit(), 120);
});
});
},
// Dispose the webgl renderer (if any) and switch to the canvas renderer.
// Used when webgl fails to construct, loses its context, or silently never paints.
fallbackToCanvas(terminal, addons) {
if (this.isUnmounted) {
return;
}
this.webglAddon?.dispose();
this.webglAddon = null;
if (!this.canvasAddon) {
this.canvasAddon = new addons.canvas.CanvasAddon();
terminal.loadAddon(this.canvasAddon);
}
this.fit();
},
str2ab(str) { str2ab(str) {
const enc = new TextEncoder(); const enc = new TextEncoder();
@ -140,12 +309,131 @@ export default {
} }
}, },
enqueueMessage(messagePromise) {
this.queue.push(messagePromise);
this.scheduleDrainQueue();
},
scheduleDrainQueue() {
if (this.drainScheduled || this.isDraining || !this.terminal) {
return;
}
this.drainScheduled = true;
const shouldDrainImmediately = this.queue.length <= 3;
const runDrain = () => {
this.drainScheduled = false;
this.drainTimer = null;
this.drainQueue();
};
if (shouldDrainImmediately) {
runDrain();
return;
}
this.drainTimer = requestAnimationFrame(runDrain);
},
async decodeMessageData(data) {
if (typeof data === 'string') {
return data;
}
if (!this.textDecoder) {
this.textDecoder = new TextDecoder('utf-8');
}
if (data instanceof Blob) {
const buffer = await data.arrayBuffer();
return this.textDecoder.decode(new Uint8Array(buffer), { stream: true });
}
if (data instanceof ArrayBuffer) {
return this.textDecoder.decode(new Uint8Array(data), { stream: true });
}
if (ArrayBuffer.isView(data)) {
const view = data;
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
return this.textDecoder.decode(bytes, { stream: true });
}
return String(data || '');
},
async drainQueue() {
if (this.isDraining || !this.terminal) {
return;
}
this.isDraining = true;
try {
while (this.queue.length > 0) {
const pendingMessages = this.queue.splice(0, this.queue.length);
const settledMessages = await Promise.allSettled(pendingMessages);
const messages = settledMessages
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
const output = messages.filter(Boolean).join('');
if (output) {
await this.writeOutputInChunks(output);
}
if (this.queue.length > 0) {
await this.nextFrame();
}
}
} finally {
this.isDraining = false;
// If data arrived between the last while-check and releasing the lock,
// immediately schedule another drain so output never gets stuck.
if (this.queue.length > 0 && this.terminal) {
this.scheduleDrainQueue();
}
}
},
async writeOutputInChunks(output) {
if (!output) {
return;
}
const chunkSize = output.length > 20000 ? 16000 : output.length > 8000 ? 8000 : output.length;
if (chunkSize === output.length) {
this.terminal.write(output);
return;
}
for (let index = 0; index < output.length; index += chunkSize) {
this.terminal.write(output.slice(index, index + chunkSize));
if (index + chunkSize < output.length) {
await this.nextFrame();
}
}
},
nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
},
clear() { clear() {
this.terminal.clear(); this.terminal.clear();
}, },
getSocketUrl() { getSocketUrl() {
return `${ this.value?.getSerialConsolePath }`; return this.serialConsoleUrl;
}, },
async connect() { async connect() {
@ -181,6 +469,7 @@ export default {
this.fit(); this.fit();
this.flush(); this.flush();
} }
this.scheduleFit();
if (this.firstTime) { if (this.firstTime) {
this.socket.send(this.str2ab('\n')); this.socket.send(this.str2ab('\n'));
@ -195,7 +484,7 @@ export default {
}); });
this.socket.addEventListener(EVENT_MESSAGE, (e) => { this.socket.addEventListener(EVENT_MESSAGE, (e) => {
this.queue.push(e.detail.data.text()); this.enqueueMessage(this.decodeMessageData(e.detail.data));
}); });
this.socket.connect(); this.socket.connect();
@ -218,22 +507,19 @@ export default {
} }
this.fitAddon.fit(); this.fitAddon.fit();
const { rows, cols } = this.fitAddon.proposeDimensions();
if ( !this.isOpen ) {
return;
}
const message = JSON.stringify({
Width: cols,
Height: rows
});
this.socket.send(this.str2ab(message));
}, },
close() { close() {
if (this.onResize) {
window.removeEventListener('resize', this.onResize);
this.onResize = null;
}
if (this.drainTimer) {
cancelAnimationFrame(this.drainTimer);
this.drainTimer = null;
}
if ( this.socket ) { if ( this.socket ) {
this.socket.disconnect(); this.socket.disconnect();
} }
@ -248,6 +534,44 @@ export default {
<template> <template>
<div class="harvester-shell-container"> <div class="harvester-shell-container">
<div
v-if="showSearch"
class="shell-search"
>
<input
ref="searchInput"
v-model="searchQuery"
type="text"
class="shell-search-input"
placeholder="Search in terminal"
@input="onSearchInput"
@keydown="onSearchKeydown"
>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Shift+Enter"
@click="findPrevious"
>
Prev
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Enter"
@click="findNext"
>
Next
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Esc"
@click="closeSearch"
>
Close
</button>
</div>
<div <div
ref="xterm" ref="xterm"
class="shell-body" class="shell-body"
@ -257,18 +581,73 @@ export default {
</template> </template>
<style lang="scss"> <style lang="scss">
@import '../../../../node_modules/xterm/css/xterm.css'; @import '../../../../node_modules/@xterm/xterm/css/xterm.css';
body, #__nuxt, #__layout, MAIN { body, #__nuxt, #__layout, MAIN {
height: 100%; height: 100%;
} }
.harvester-shell-container { .harvester-shell-container {
display: flex;
flex-direction: column;
position: relative;
height: 100%; height: 100%;
min-height: 0;
overflow: hidden; overflow: hidden;
.shell-body, .terminal.xterm { .resize-observer {
display: none;
}
.shell-search {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
position: absolute;
top: 8px;
right: 10px;
z-index: 5;
padding: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--body-bg) 88%, transparent);
}
.shell-search-input {
min-width: 220px;
max-width: 420px;
flex: 0 1 320px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--body-bg);
color: var(--body-text);
padding: 6px 8px;
}
.shell-search-btn {
min-width: 64px;
}
.shell-body {
flex: 1 1 auto;
height: 100%; height: 100%;
width: 100%;
min-height: 0;
padding: 0 10px;
box-sizing: border-box;
}
.terminal.xterm,
.terminal.xterm .xterm-viewport,
.terminal.xterm .xterm-screen,
.terminal.xterm .xterm-scrollable-element {
width: 100%;
height: 100%;
}
.terminal.xterm .xterm-scrollable-element {
min-height: 100%;
} }
} }
</style> </style>

View File

@ -55,6 +55,10 @@ export default {
const host = window.location.host; const host = window.location.host;
const prefix = window.location.pathname.replace(this.$route.path, ''); const prefix = window.location.pathname.replace(this.$route.path, '');
const params = this.$route?.params; const params = this.$route?.params;
const popupWidth = Math.max(800, screen.width - 200);
const popupHeight = Math.max(600, screen.height - 200);
const popupLeft = Math.max(0, window.screenX + Math.floor((window.outerWidth - popupWidth) / 2));
const popupTop = Math.max(0, window.screenY + Math.floor((window.outerHeight - popupHeight) / 2));
const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`; const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
@ -63,7 +67,7 @@ export default {
window.open( window.open(
url, url,
'_blank', '_blank',
`toolbars=0,width=${ screen.width - 200 },height=${ screen.height - 200 },left=0,top=0,noreferrer` `toolbars=0,width=${ popupWidth },height=${ popupHeight },left=${ popupLeft },top=${ popupTop },noreferrer`
); );
}); });
}, },

View File

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

View File

@ -0,0 +1,573 @@
<script setup>
import { ref, computed, watch, toRefs } from 'vue';
import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { decodeSecretValue } from '../../utils/forklift';
const CREATE_NEW = '__create_new__';
const props = defineProps({
stepData: { type: Object, required: true },
createOnly: { type: Boolean, default: false },
editMode: { type: Boolean, default: false },
});
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
const store = useStore();
const { t } = useI18n(store);
const allProviders = ref([]);
const allSecrets = ref([]);
const errors = ref([]);
const testBtnRef = ref(null);
const loading = ref(true);
const testing = ref(false);
const {
selectedProvider,
providerName,
url,
username,
password,
skipTlsVerify,
vddkInitImage,
testPassed,
testResult,
testError,
createdProvider,
createdSecret,
} = toRefs(props.stepData);
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const providerOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
];
allProviders.value.forEach((p) => {
if (!p.spec?.url) {
return;
}
const secretRef = p.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (!secret?.data?.user || !secret?.data?.password) {
return;
}
} else {
return;
}
options.push({
label: p.metadata.name,
value: p.metadata.name,
});
});
return options;
});
// When the provider selection changes, populate or clear the fields
watch(selectedProvider, (val) => {
testPassed.value = false;
testResult.value = null;
testError.value = null;
if (val === CREATE_NEW) {
providerName.value = '';
url.value = '';
username.value = '';
password.value = '';
skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null;
createdSecret.value = null;
} else {
const provider = allProviders.value.find(
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
);
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
username.value = decodeSecretValue(secret.data.user);
password.value = decodeSecretValue(secret.data.password);
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
}
}
createdProvider.value = provider;
}
}
});
// Reset test state when any field changes (only for create new)
watch([providerName, url, username, password], () => {
if (!isExistingProvider.value) {
testPassed.value = false;
testResult.value = null;
}
});
// Emit ready/complete when testPassed changes
watch(testPassed, (val) => {
emit('ready', val);
if (val) {
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
}
}, { immediate: true });
// Emit form-valid when form validity changes
watch(isFormValid, (val) => {
emit('form-valid', val);
}, { immediate: true });
watch(testing, (val) => {
emit('testing', val);
}, { immediate: true });
const pollProviderReady = async(name) => {
const inStore = store.getters['currentProduct'].inStore;
const namespace = FORKLIFT_NAMESPACE;
const maxAttempts = 15;
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ name }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
const readyCondition = conditions.find((c) => c.type === 'Ready');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
return { connected: true };
}
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
}
if (readyCondition) {
if (readyCondition.status === 'True') {
return { connected: true };
} else if (readyCondition.status === 'False') {
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
}
}
}
return { connected: false, errorMsg: '' };
};
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
};
const cleanupCreatedResources = async() => {
if (createdProvider.value) {
try {
await createdProvider.value.remove();
} catch (e) {}
createdProvider.value = null;
createdSecret.value = null;
} else if (createdSecret.value) {
try {
await createdSecret.value.remove();
} catch (e) {}
createdSecret.value = null;
}
};
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
testing.value = false;
buttonCb(false);
return;
}
const inStore = store.getters['currentProduct'].inStore;
// Edit mode: update existing provider URL + secret, then poll
if (props.editMode && createdProvider.value) {
try {
createdProvider.value.spec.url = url.value;
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
createdProvider.value.spec.settings = {
...(createdProvider.value.spec.settings || {}),
vddkInitImage: vddkImage,
};
} else if (createdProvider.value.spec.settings) {
delete createdProvider.value.spec.settings.vddkInitImage;
}
await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret;
if (secretRef && createdSecret.value) {
createdSecret.value.data = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await createdSecret.value.save();
}
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For new providers, create provider + secret then poll
try {
await cleanupCreatedResources();
const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const spec = {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
};
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
spec.settings = { vddkInitImage: vddkImage };
}
const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec,
});
await provider.save();
createdProvider.value = provider;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
},
],
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await newSecret.save();
createdSecret.value = newSecret;
const result = await pollProviderReady(providerName.value);
if (!result.connected) {
await cleanupCreatedResources();
}
handlePollResult(result, buttonCb);
} catch (err) {
await cleanupCreatedResources();
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
}
loading.value = false;
};
init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-provider-step"
>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
</p>
<div class="configure-provider-step-content">
<h3 class="table-title m-0">
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
</h3>
<Banner
class="requirements-banner m-0"
color="info"
>
<div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
<br>
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
</div>
</Banner>
<div class="configure-provider-step-form">
<div v-if="!createOnly && !editMode">
<LabeledSelect
v-model:value="selectedProvider"
required
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider || editMode"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
:disabled="editMode"
required
/>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="vddkInitImage"
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
:disabled="isExistingProvider && !editMode"
/>
</div>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
</div>
<div>
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider && !editMode"
/>
<p class="text-deemphasized ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p>
</div>
<div>
<AsyncButton
ref="testBtnRef"
mode="test"
:disabled="!isFormValid"
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
:action-color="'role-secondary'"
:waiting-color="'role-disabled'"
@click="testConnection"
/>
</div>
<Banner
v-if="testResult"
color="success"
>
{{ testResult }}
</Banner>
<Banner
v-if="testError"
color="error"
>
{{ testError }}
</Banner>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
>
{{ err }}
</Banner>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.requirements-banner {
font-weight: 400;
.requirements-banner-title {
font-weight: 600;
}
}
.configure-provider-step {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
.configure-provider-step-content {
display: flex;
flex-direction: column;
gap: 12px;
.configure-provider-step-form {
display: flex;
flex-direction: column;
gap: 20px;
}
}
:deep(.checkbox-label) {
color: var(--body-text);
}
}
</style>

View File

@ -0,0 +1,252 @@
<script setup>
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE } from '../../config/types';
const store = useStore();
const { t } = useI18n(store);
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
// Storage-specific: show the volume/access mode defaults row with an Edit action.
showVolumeSettings: { type: Boolean, default: false },
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
inheritedProviderName: { type: String, default: '' },
});
const emit = defineEmits(['edit-defaults']);
// Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
return props.options;
}
return [
{
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
value: null,
kind: 'highlighted',
},
{
label: 'divider',
disabled: true,
kind: 'divider',
},
...props.options,
];
};
const formatModes = (entry) => {
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
const accessModes = (entry.accessModes || []).join(', ');
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
};
</script>
<template>
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ title }}
</h3>
<p
v-if="description"
class="text-deemphasized line-height-20"
>
{{ description }}
</p>
</div>
<RcItemCard
v-for="entry in entries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
>
<template #item-card-content>
<div class="card-content-column">
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<slot
name="source-detail"
:entry="entry"
/>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
<i
class="icon icon-right-arrow-alt"
aria-hidden="true"
/>
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="optionsFor(entry)"
:placeholder="placeholder+'...'"
:searchable="true"
/>
</div>
</div>
<div
v-if="showVolumeSettings && entry.target"
class="storage-defaults-row"
>
<div class="storage-defaults">
<div class="storage-defaults-info">
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
<span
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
class="text-deemphasized storage-defaults-inherited"
>
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
</span>
</div>
<a
role="button"
class="storage-defaults-edit"
@click.prevent="emit('edit-defaults', entry)"
>
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
</a>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
</template>
<style lang="scss" scoped>
.mapping-column {
display: flex;
gap: 12px;
flex-direction: column;
:deep(.item-card-header) {
display: none;
}
}
.mapping-section-title {
font-weight: 600;
margin: 0 0 5px 0;
line-height: 28px;
}
.card-content-row {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
flex: 2;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 3;
min-width: 0;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.storage-defaults-row {
width: 100%;
}
.storage-defaults {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 4px;
background-color: var(--body-bg);
.storage-defaults-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.storage-defaults-summary {
font-size: 13px;
line-height: 20px;
}
.storage-defaults-inherited {
font-size: 12px;
line-height: 18px;
}
.storage-defaults-edit {
flex-shrink: 0;
cursor: pointer;
}
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
</style>

View File

@ -0,0 +1,674 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import {
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
} from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
mappingEntries: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore();
const { t } = useI18n(store);
const vms = computed(() => props.selectedVms);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE;
const nameConflictError = computed(() => {
const name = (planName.value || '').trim();
if (!name) {
return '';
}
if (existingProviderNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
}
if (existingPlanNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
}
return '';
});
watch(planName, (val) => {
props.stepData.planName = val;
}, { immediate: true });
watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val;
});
watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true });
const formatNetworkTarget = (target = '') => {
if (target === 'pod') {
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
}
if (target === 'ignored') {
return t('harvester.addons.vmMigration.generic.ignored');
}
return target;
};
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => {
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return mbToGB(totalMB);
});
const totalStorageGB = computed(() => {
let totalBytes = 0;
vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) {
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
});
return bytesToGB(totalBytes);
});
const vmRows = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id,
name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os,
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB }${ diskDisplay }`,
powerState,
powerStateClass,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
networkMapping: networkMappingSort,
storageMapping: storageMappingSort,
};
});
});
const vmHeaders = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
value: 'name',
sort: ['name'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
value: 'os',
sort: ['os'],
width: 140,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
value: 'resources',
sort: false,
width: 120,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 100,
},
{
name: 'networkMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
value: 'networkMapping',
sort: ['networkMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'storageMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
value: 'storageMapping',
sort: ['storageMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'actions',
label: ' ',
align: 'right',
sort: false,
width: 40,
},
];
const onEditSelection = () => emit('edit-selection');
const onEditMappings = () => emit('edit-mappings');
const onRemoveVm = (id) => emit('remove-vm', id);
const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: props.providerName,
namespace: NS,
},
destination: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name: 'host',
namespace: NS,
},
};
const networkMapName = `${ planName.value }-network-map`;
const storageMapName = `${ planName.value }-storage-map`;
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NS },
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NS), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NS },
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
});
await storageMap.save();
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NS },
spec: {
provider: providerRef,
map: {
network: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: networkMapName,
namespace: NS,
},
storage: {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: storageMapName,
namespace: NS,
},
},
targetNamespace: targetNamespace.value,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
const namespaces = await store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE });
namespaceOptions.value = namespaces.filter((ns) => !ns.isSystem).map((ns) => ns.metadata.name).sort().map((name) => ({ label: name, value: name }));
} catch (e) {
namespaceOptions.value = [];
}
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {}
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
} catch (e) {}
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
}
loading.value = false;
};
init();
defineExpose({ startMigration: startMigrationAction });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<div
class="review-migration-content"
>
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary -->
<div class="migration-details">
<div class="section-header">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row">
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
<LabeledSelect
v-model:value="targetNamespace"
class="namespace-select"
:label="t('harvester.addons.vmMigration.reviewMigration.targetNamespace')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.targetNamespacePlaceholder')"
:options="namespaceOptions"
required
/>
</div>
<div class="summary-row">
<div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div>
<div class="stats-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-value">{{ providerName }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value">{{ vms.length }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div>
</div>
</div>
<!-- Virtual Machines -->
<div class="vm-section">
<SortableTable
class="vm-sortable-table"
:rows="vmRows"
:headers="vmHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
>
<template #header-left>
<h4 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h4>
</template>
<template #header-right>
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
</a>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.name }}</span>
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resources }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:networkMapping="{ row }">
<template v-if="row.networkMappings.length">
<div
v-for="(m, i) in row.networkMappings"
:key="'net-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:storageMapping="{ row }">
<template v-if="row.storageMappings.length">
<div
v-for="(m, i) in row.storageMappings"
:key="'stor-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ m.target }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:actions="{ row }">
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
<rc-dropdown-trigger
variant="ghost"
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<rc-dropdown-item @click="onEditMappings">
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
</rc-dropdown-item>
<rc-dropdown-item
class="text-error"
@click="onRemoveVm(row.id)"
>
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>
<template #no-rows>
<tr class="no-rows">
<td
:colspan="vmHeaders.length"
class="text-center"
>
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
</td>
</tr>
</template>
</SortableTable>
</div>
<!-- Error banner -->
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-migration {
gap: 36px;
}
.review-migration-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
font-weight: 600;
}
.name-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
align-items: start;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
line-height: 20px;
margin-top: 12px;
}
.section-header {
display: flex;
flex-direction: column;
gap: 4px;
.section-description {
font-size: 14px;
}
}
.migration-mode-column {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-size: 14px;
.cold-migration-badge {
padding: 2px 10px;
border-radius: 4px;
opacity: 0.8;
font-weight: 600;
background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600;
.theme-light & {
background: rgba(168, 128, 26, 0.16);
}
}
}
.stats-column {
display: grid;
grid-template-columns: auto auto;
justify-content: start;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
}
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
}
.edit-selection-link {
cursor: pointer;
color: var(--link);
font-size: 14px;
}
.vm-sortable-table {
:deep(.fixed-header-actions) {
align-items: end;
}
:deep(table) {
table-layout: fixed;
}
:deep(td) {
vertical-align: middle;
overflow: hidden;
}
:deep(th:first-child),
:deep(td:first-child) {
padding-left: 16px;
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
.vm-name {
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
}
.mapping-cell {
.mapping-target {
margin-left: 4px;
white-space: nowrap;
}
}
.mapping-cell + .mapping-cell {
margin-top: 8px;
}
.banner-title {
font-weight: 600;
}
.migration-details {
display: flex;
flex-direction: column;
gap: 12px;
}
header {
margin: 0;
}
.vm-section {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>

View File

@ -0,0 +1,668 @@
<script setup>
import {
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
} from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { useI18n } from '@shell/composables/useI18n';
import { bytesToGB, mbToGB } from '../../utils/forklift';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['complete', 'loading']);
const store = useStore();
const { t } = useI18n(store);
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]);
const loading = ref(true);
// Restored from stepData so friendly names survive step navigation / remount.
const networkMap = ref(props.stepData.networkMap || {});
const datastoreMap = ref(props.stepData.datastoreMap || {});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const errors = ref([]);
let skipNextSelectionEvent = false;
const lastFetchedAt = ref(null);
const now = ref(Date.now());
const formatElapsed = (ms) => {
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
}
if (hours > 0) {
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
}
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
};
const lastSyncedTime = computed(() => {
if (!lastFetchedAt.value) {
return '';
}
return formatElapsed(now.value - lastFetchedAt.value);
});
const nowTimer = setInterval(() => {
now.value = Date.now();
}, 30000);
onBeforeUnmount(() => {
clearInterval(nowTimer);
});
// Restore selection state from stepData
if (selectedVMIds.value.size > 0) {
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
}
const vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length);
const showSelectAllBanner = computed(() => {
if (allVMsSelected.value) {
return true;
}
const pagedRows = sortableTableRef.value?.pagedRows || [];
if (pagedRows.length === 0) {
return false;
}
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
return allPageSelected && tableRows.value.length > pagedRows.length;
});
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = ref([
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os',
sort: ['os'],
width: 180,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources',
sort: false,
width: 140,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 130,
},
{
name: 'network',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
]);
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
let networks = [];
if (vm.networks && vm.networks.length > 0) {
networks = vm.networks.map((n) => ({
name: n.name || networkMap.value[n.id] || n.id || n,
id: n.id || '',
vlanId: n.vlanId || '',
}));
}
const datastores = [];
if (vm.disks && vm.disks.length > 0) {
const seen = new Set();
vm.disks.forEach((d) => {
const dsId = d.datastore?.id || d.datastore?.name;
if (dsId && !seen.has(dsId)) {
seen.add(dsId);
datastores.push({
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
id: d.datastore?.id || '',
type: d.datastore?.type || '',
capacity: d.capacity || 0,
});
}
});
}
return {
_original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name,
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-',
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB }${ diskDisplay }`,
powerState,
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
networks,
network: networks.map((n) => n.name).join(', ') || '-',
datastores,
datastore: datastores.map((d) => d.name).join(', ') || '-',
};
});
};
const onSelect = (rows) => {
if (skipNextSelectionEvent) {
return;
}
const currentPageRows = sortableTableRef.value?.pagedRows || [];
const selectedKeys = new Set(rows.map((r) => r._key));
currentPageRows.forEach((row) => {
const vmId = row._original?.id;
if (selectedKeys.has(row._key)) {
selectedVMIds.value.add(vmId);
} else {
selectedVMIds.value.delete(vmId);
}
});
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
emit('complete', { selectedVMs: selectedVMs.value });
};
const clearSelection = () => {
allVMsSelected.value = false;
selectedVMIds.value = new Set();
selectedVMs.value = [];
nextTick(() => {
const table = sortableTableRef.value;
if (table) {
table.clearSelection();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
}
});
};
const selectAllVMs = () => {
allVMsSelected.value = true;
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
selectedVMs.value = discoveredVMs.value.slice();
};
// Moves currently-selected VMs to the first page(s) and re-checks them.
const sortSelectedToFront = () => {
if (selectedVMIds.value.size === 0) {
return;
}
tableRows.value.forEach((row) => {
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
});
headers.value = headers.value.map((h) => (
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
));
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
skipNextSelectionEvent = false;
return;
}
if (typeof table.changeSort === 'function') {
table.changeSort('vmName', false);
} else if (typeof table.setPage === 'function') {
table.setPage(1);
}
nextTick(() => {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
});
};
watch(
() => sortableTableRef.value?.page,
() => {
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
return;
}
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
}
);
const refreshing = ref(false);
const isLoading = computed(() => loading.value || refreshing.value);
watch(isLoading, (val) => {
emit('loading', val);
}, { immediate: true });
const fetchVMs = async() => {
if (!props.provider) {
return;
}
const inStore = store.getters['currentProduct'].inStore;
const providerUid = props.provider.metadata.uid;
const providerType = props.provider.spec?.type || 'vsphere';
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
`v1/harvester/providers/${ providerType }/${ providerUid }`
);
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
]);
if (Array.isArray(vmsResp)) {
discoveredVMs.value = vmsResp;
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
discoveredVMs.value = vmsResp.data;
}
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
networkMap.value = networks.reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
datastoreMap.value = datastores.reduce((map, d) => {
map[d.id] = d.name;
return map;
}, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
const previousSelectedIds = new Set(selectedVMIds.value);
try {
errors.value = [];
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
}
selectedVMIds.value = new Set(
discoveredVMs.value
.filter((vm) => previousSelectedIds.has(vm.id))
.map((vm) => vm.id)
);
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
props.stepData.discoveredVMs = discoveredVMs.value;
props.stepData.selectedVMIds = selectedVMIds.value;
props.stepData.tableRows = tableRows.value;
emit('complete', { selectedVMs: selectedVMs.value });
refreshing.value = false;
await nextTick();
const table = sortableTableRef.value;
if (table) {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
}
skipNextSelectionEvent = false;
};
const init = async() => {
if (discoveredVMs.value.length > 0) {
if (!lastFetchedAt.value) {
lastFetchedAt.value = Date.now();
}
tableRows.value = buildTableRows();
loading.value = false;
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
await nextTick();
sortSelectedToFront();
return;
}
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
}
loading.value = false;
};
// Emit initial complete if VMs are already selected (restored from stepData)
if (selectedVMs.value.length > 0) {
emit('complete', { selectedVMs: selectedVMs.value });
}
init();
</script>
<template>
<Loading v-if="loading || refreshing" />
<div
v-else
class="select-vms-step"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link
v-if="provider"
class="provider-link"
:to="provider._detailLocation"
>
{{ providerName }}
</router-link>
<br>
<span
v-if="lastSyncedTime"
>
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
<a
role="button"
class="text-bold"
:class="{ disabled: refreshing }"
@click.prevent="refreshVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
</a>
</span>
</p>
<!-- Discovered VMs table -->
<SortableTable
ref="sortableTableRef"
:rows="tableRows"
:headers="headers"
:search="true"
:table-actions="true"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
@selection="onSelect"
>
<template #header-left>
<div class="vm-table-title">
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="selected-actions">
<span class="text-deemphasized">
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
</span>
<template v-if="selectedCount > 0">
<span class="text-deemphasized">|</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
</a>
</template>
</span>
</div>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.vmName }}</span>
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resourcesDisplay }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
/>
</template>
<template #cell:network="{ row }">
<div>
<div
v-for="(net, i) in row.networks"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ net.name }}</span><br>
<span class="text-deemphasized">{{ net.id }}</span>
</div>
</div>
</template>
<template #cell:datastore="{ row }">
<div>
<div
v-for="(ds, i) in row.datastores"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ ds.name }}</span><br>
<span class="text-deemphasized">{{ ds.id }}</span>
</div>
</div>
</template>
</SortableTable>
<Teleport
v-if="showSelectAllBanner && theadElement"
:to="theadElement"
>
<tr class="select-all-banner-row">
<td
:colspan="headers.length + 1"
class="select-all-banner-cell"
>
<template v-if="allVMsSelected">
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a>
</template>
<template v-else>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a
role="button"
@click.prevent="selectAllVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a>
</template>
</td>
</tr>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.vm-table-title {
.text-deemphasized {
font-size: 13px;
}
.selected-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
a {
cursor: pointer;
&.disabled {
color: var(--muted);
cursor: default;
pointer-events: none;
}
}
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
font-size: 14px;
.vm-name {
line-height: 20px;
}
.vm-id {
line-height: 20px;
}
}
.select-vms-step {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
.line-height-20 {
line-height: 20px;
}
}
:deep(.select-all-banner-row) {
.select-all-banner-cell {
text-align: center;
padding: 8px 16px;
background-color: var(--sortable-table-header-bg);
border-bottom: 1px solid var(--border);
font-size: 13px;
a {
color: var(--primary);
cursor: pointer;
font-weight: 600;
margin-left: 5px;
&:hover {
text-decoration: underline;
}
}
}
}
</style>

View File

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

View File

@ -73,7 +73,9 @@ const FEATURE_FLAGS = {
'supportFilesystem', 'supportFilesystem',
'disableResourcePooling', 'disableResourcePooling',
'expandOnlineEncryptedVolume', 'expandOnlineEncryptedVolume',
'longhornV2HugepageSettings' 'longhornV2HugepageSettings',
'staticIPForVM',
'fsFreezeDeadline',
], ],
}; };

View File

@ -47,6 +47,13 @@ import {
VM_IMPORT_SOURCE_O_STATUS, VM_IMPORT_SOURCE_O_STATUS,
VM_IMPORT_SOURCE_OVA_URL, VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS, VM_IMPORT_SOURCE_OVA_STATUS,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
FORKLIFT_MIGRATION_PLAN,
} from './table-headers'; } 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';
@ -1319,4 +1326,187 @@ export function init($plugin, store) {
exact: false, exact: false,
ifHaveType: HCI.HOST_NETWORK_CONFIG, ifHaveType: HCI.HOST_NETWORK_CONFIG,
}); });
// ===========================================================================
// Forklift Addon UI Flow
// ===========================================================================
weightGroup('vmMigration', 0, false);
// Provider
headers(HCI.FORKLIFT_PROVIDER, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_PROVIDER_TYPE,
FORKLIFT_PROVIDER_URL,
AGE
]);
configureType(HCI.FORKLIFT_PROVIDER, {
resource: HCI.FORKLIFT_PROVIDER,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'vmMigration',
namespaced: true,
weight: 100,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
// NetworkMap
headers(HCI.FORKLIFT_NETWORK_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_NETWORK_MAP, {
resource: HCI.FORKLIFT_NETWORK_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
});
// StorageMap
headers(HCI.FORKLIFT_STORAGE_MAP, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_MAP_DEST_PROVIDER,
AGE
]);
configureType(HCI.FORKLIFT_STORAGE_MAP, {
resource: HCI.FORKLIFT_STORAGE_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
virtualType({
name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
});
// Plan
headers(HCI.FORKLIFT_PLAN, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MAP_SOURCE_PROVIDER,
FORKLIFT_PLAN_TARGET_NS,
FORKLIFT_PLAN_VM_COUNT,
AGE
]);
configureType(HCI.FORKLIFT_PLAN, {
resource: HCI.FORKLIFT_PLAN,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
// Migration
headers(HCI.FORKLIFT_MIGRATION, [
STATE,
NAME_COL,
NAMESPACE_COL,
FORKLIFT_MIGRATION_PLAN,
AGE
]);
configureType(HCI.FORKLIFT_MIGRATION, {
resource: HCI.FORKLIFT_MIGRATION,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'vmMigration::Configurations',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({
name: 'forklift-create',
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'vmMigration',
namespaced: true,
weight: 200,
route: {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {}
}
});
// Enable SideNav based on Forklift Addon Status
// The dashboard entry ('forklift-create') is a schema-less virtual type, so it
// is registered with requireSchema:false to still be gated by the addon status.
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
requireSchema: false,
types: ['forklift-create']
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration',
types: [
HCI.FORKLIFT_PROVIDER,
]
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration::Configurations',
types: [
HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP,
HCI.FORKLIFT_PLAN,
HCI.FORKLIFT_MIGRATION,
]
});
// ===========================================================================
} }

View File

@ -79,6 +79,7 @@ export const ADD_ONS = {
VM_IMPORT_CONTROLLER: 'vm-import-controller', VM_IMPORT_CONTROLLER: 'vm-import-controller',
LVM_DRIVER: 'lvm.driver.harvesterhci.io', LVM_DRIVER: 'lvm.driver.harvesterhci.io',
KUBEOVN_OPERATOR: 'kubeovn-operator', KUBEOVN_OPERATOR: 'kubeovn-operator',
FORKLIFT_OPERATOR: 'forklift-operator',
}; };
export const CSI_SECRETS = { export const CSI_SECRETS = {
@ -92,6 +93,9 @@ export const CSI_SECRETS = {
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace' CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
}; };
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
export const FORKLIFT_NAMESPACE = 'forklift';
// Some harvester CRD type is not equal to model file name, define the mapping here // Some harvester CRD type is not equal to model file name, define the mapping here
export const HARVESTER_CRD_MAP = { export const HARVESTER_CRD_MAP = {
node: HCI.HOST, node: HCI.HOST,

View File

@ -16,6 +16,7 @@ export const HCI = {
CREATOR: 'harvesterhci.io/creator', CREATOR: 'harvesterhci.io/creator',
OS: 'harvesterhci.io/os', OS: 'harvesterhci.io/os',
GOLDEN_IMAGE: 'harvesterhci.io/goldenImage', GOLDEN_IMAGE: 'harvesterhci.io/goldenImage',
CLUSTER_NETWORK: 'network.harvesterhci.io/clusternetwork',
NETWORK_TYPE: 'network.harvesterhci.io/type', NETWORK_TYPE: 'network.harvesterhci.io/type',
VM_NAME: 'harvesterhci.io/vmName', VM_NAME: 'harvesterhci.io/vmName',
VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix', VM_NAME_PREFIX: 'harvesterhci.io/vmNamePrefix',
@ -80,8 +81,10 @@ export const HCI = {
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes', VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass', VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address', MAC_ADDRESS: 'harvesterhci.io/mac-address',
STATIC_IP: 'static-ip.harvesterhci.io',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map', NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind', CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks', CNI_NETWORKS: 'k8s.v1.cni.cncf.io/networks',
WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep', WINDOWS_SYSPREP: 'harvesterhci.io/windows-sysprep',
SKIP_MIG_CONFIGURATION: 'harvesterhci.io/skip-mig-configuration',
}; };

View File

@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
sort: 'status.status', sort: 'status.status',
align: 'left', align: 'left',
}; };
// ========================================
// Forklift table headers
// ========================================
// Provider type column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_TYPE = {
name: 'providerType',
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
value: 'spec.type',
sort: 'spec.type',
align: 'left',
};
// Provider URL column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_URL = {
name: 'providerUrl',
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
};
// Source provider column in forklift network/storage map list page
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
name: 'sourceProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
value: 'spec.provider.source.name',
sort: 'spec.provider.source.name',
align: 'left',
};
// Destination provider column in forklift network/storage map list page
export const FORKLIFT_MAP_DEST_PROVIDER = {
name: 'destProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
value: 'spec.provider.destination.name',
sort: 'spec.provider.destination.name',
align: 'left',
};
// Target namespace column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_TARGET_NS = {
name: 'targetNamespace',
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
value: 'spec.targetNamespace',
sort: 'spec.targetNamespace',
align: 'left',
};
// VM count column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_VM_COUNT = {
name: 'vmCount',
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'center',
};
// Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan',
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
value: 'spec.plan.name',
sort: 'spec.plan.name',
align: 'left',
};

View File

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

View File

@ -7,6 +7,7 @@ import { MiniMap } from '@vue-flow/minimap';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { NETWORK_ATTACHMENT } from '@shell/config/types'; import { NETWORK_ATTACHMENT } from '@shell/config/types';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue'; import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { HCI } from '../types'; import { HCI } from '../types';
import { NETWORK_TYPE } from '../config/types'; import { NETWORK_TYPE } from '../config/types';
@ -454,10 +455,10 @@ export default {
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name; const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
const clusterNetwork = const clusterNetwork =
nad?.metadata?.labels?.[HCI.CLUSTER_NETWORK] || 'mgmt'; nad?.metadata?.labels?.[HCI_ANNOTATIONS.CLUSTER_NETWORK] || 'mgmt';
const nadType = const nadType =
nad?.metadata?.labels?.[HCI.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY; nad?.metadata?.labels?.[HCI_ANNOTATIONS.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
const overlayNodeId = `overlay-${ provider }`; const overlayNodeId = `overlay-${ provider }`;
nodes.push({ nodes.push({

View File

@ -0,0 +1,133 @@
<script>
import { mapGetters } from 'vuex';
import { Card } from '@components/Card';
import AsyncButton from '@shell/components/AsyncButton';
import { Checkbox } from '@components/Form/Checkbox';
import { escapeHtml } from '@shell/utils/string';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
export default {
name: 'HarvesterEnableSriovGpuDevice',
emits: ['close'],
components: {
AsyncButton,
Card,
Checkbox,
},
props: {
resources: {
type: Array,
required: true
}
},
data() {
return { skipMigConfiguration: this.resources[0]?.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true' };
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
methods: {
close() {
this.$emit('close');
},
async save(buttonCb) {
const actionResource = this.resources[0];
const prevEnabled = actionResource.spec.enabled;
const prevAnnotation = actionResource.metadata.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
try {
if (this.skipMigConfiguration) {
if (!actionResource.metadata.annotations) {
actionResource.metadata.annotations = {};
}
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = 'true';
} else {
if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
}
actionResource.spec.enabled = true;
await actionResource.save();
buttonCb(true);
this.close();
} catch (err) {
actionResource.spec.enabled = prevEnabled;
if (prevAnnotation !== undefined) {
actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] = prevAnnotation;
} else if (actionResource.metadata.annotations) {
delete actionResource.metadata.annotations[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION];
}
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(actionResource.metadata.name) }),
err,
}, { root: true });
buttonCb(false);
}
}
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
<h4
v-clean-html="t('harvester.sriovgpu.enable.title')"
class="text-default-text"
/>
</template>
<template #body>
<div class="body">
<Checkbox
v-model:value="skipMigConfiguration"
:label="t('harvester.sriovgpu.enable.skipMigConfiguration')"
/>
<p class="note mt-10">
{{ t('harvester.sriovgpu.enable.note') }}
</p>
</div>
</template>
<template #actions>
<div class="buttons actions">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="enable"
@click="save"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
.note {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
</style>

View File

@ -2,13 +2,16 @@
import { exceptionToErrorsArray } from '@shell/utils/error'; import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { PVC } from '@shell/config/types'; import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI } from '@pkg/harvester/types';
import { Card } from '@components/Card'; import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
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 { Checkbox } from '@components/Form/Checkbox';
export default { export default {
name: 'HotplugVolumeModal', name: 'HotplugVolumeModal',
@ -16,7 +19,7 @@ export default {
emits: ['close'], emits: ['close'],
components: { components: {
AsyncButton, Card, LabeledInput, LabeledSelect, Banner AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
}, },
props: { props: {
@ -28,14 +31,19 @@ export default {
async fetch() { async fetch() {
this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC }); this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC });
this.allStorageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
this.allVMs = await this.$store.dispatch('harvester/findAll', { type: HCI.VM });
}, },
data() { data() {
return { return {
diskName: '', diskName: '',
volumeName: '', volumeName: '',
errors: [], shareable: false,
allPVCs: [], errors: [],
allPVCs: [],
allStorageClasses: [],
allVMs: [],
}; };
}, },
@ -50,6 +58,31 @@ export default {
return this.resources[0]; return this.resources[0];
}, },
// claim names already attached to the VM this modal was opened for
currentVMClaimNames() {
const volumes = this.actionResource?.spec?.template?.spec?.volumes || [];
return volumes.map((vol) => vol.persistentVolumeClaim?.claimName).filter((name) => !!name);
},
// claim names attached to other VMs in the same namespace
otherVMClaimNames() {
const out = [];
this.allVMs.forEach((vm) => {
if (vm.metadata.namespace !== this.actionResource?.metadata?.namespace || vm.id === this.actionResource?.id) {
return;
}
(vm.spec?.template?.spec?.volumes || []).forEach((vol) => {
if (vol.persistentVolumeClaim?.claimName) {
out.push(vol.persistentVolumeClaim.claimName);
}
});
});
return out;
},
volumeOption() { volumeOption() {
return sortBy( return sortBy(
this.PVCs this.PVCs
@ -61,6 +94,14 @@ export default {
if (pvc.isGoldenImageVolume) { if (pvc.isGoldenImageVolume) {
return false; return false;
} }
// a volume attached to this VM can never be attached to it again
if (this.currentVMClaimNames.includes(pvc.metadata.name)) {
return false;
}
// a volume attached to another VM can only be re-attached as a shareable disk
if (this.otherVMClaimNames.includes(pvc.metadata.name) && !this.isShareableCapablePVC(pvc)) {
return false;
}
return true; return true;
}) })
@ -73,19 +114,62 @@ export default {
'label' 'label'
); );
}, },
selectedPVC() {
return this.PVCs.find((P) => P.metadata.name === this.volumeName);
},
isShareableCapable() {
return this.selectedPVC ? this.isShareableCapablePVC(this.selectedPVC) : false;
},
},
watch: {
isShareableCapable(neu) {
if (!neu) {
this.shareable = false;
}
},
}, },
methods: { methods: {
isShareableCapablePVC(pvc) {
const pvcSpec = pvc?.spec;
if (!pvcSpec) {
return false;
}
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
const storageClass = this.allStorageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
// fail closed: without a resolved StorageClass the provisioner
// requirement cannot be evaluated
if (!storageClass) {
return false;
}
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
},
close() { close() {
this.diskName = ''; this.diskName = '';
this.volumeName = ''; this.volumeName = '';
this.shareable = false;
this.$emit('close'); this.$emit('close');
}, },
async save(buttonCb) { async save(buttonCb) {
if (this.actionResource) { if (this.actionResource) {
try { try {
const res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false); const input = { volumeSourceName: this.volumeName, diskName: this.diskName };
if (this.isShareableCapable && this.shareable) {
input.shareable = true;
}
const res = await this.actionResource.doAction('addVolume', input, {}, false);
if (res._status === 200 || res._status === 204) { if (res._status === 200 || res._status === 204) {
this.$store.dispatch('growl/success', { this.$store.dispatch('growl/success', {
@ -141,6 +225,19 @@ export default {
class="mt-20" class="mt-20"
required required
/> />
<Checkbox
v-if="isShareableCapable"
v-model:value="shareable"
class="mt-20"
type="checkbox"
label-key="harvester.virtualMachine.volume.shareable.label"
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
/>
<Banner
v-if="isShareableCapable && shareable"
color="warning"
:label="t('harvester.virtualMachine.volume.shareable.warning')"
/>
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :key="i"

View File

@ -5,6 +5,8 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default { export default {
name: 'HarvesterBackupModal', name: 'HarvesterBackupModal',
@ -15,6 +17,7 @@ export default {
AsyncButton, AsyncButton,
Card, Card,
LabeledInput, LabeledInput,
LabeledSelect,
Banner Banner
}, },
@ -27,8 +30,9 @@ export default {
data() { data() {
return { return {
backUpName: '', backUpName: '',
errors: [] errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
}; };
}, },
@ -37,12 +41,22 @@ export default {
actionResource() { actionResource() {
return this.resources[0]; return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
} }
}, },
methods: { methods: {
close() { close() {
this.backUpName = ''; this.backUpName = '';
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.errors = [];
this.$emit('close'); this.$emit('close');
}, },
@ -51,7 +65,7 @@ export default {
try { try {
const res = await this.actionResource.doAction( const res = await this.actionResource.doAction(
'backup', 'backup',
{ name: this.backUpName }, { name: this.backUpName, fsFreezeDeadline: this.fsFreezeDeadline },
{}, {},
false false
); );
@ -100,9 +114,18 @@ export default {
<template #body> <template #body>
<LabeledInput <LabeledInput
v-model:value="backUpName" v-model:value="backUpName"
class="mt-20"
:label="t('generic.name')" :label="t('generic.name')"
required required
/> />
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.backup.fileSystemFreezeDeadline.label')"
required
/>
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :key="i"

View File

@ -5,8 +5,10 @@ import { Card } from '@components/Card';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI } from '../types'; import { HCI } from '../types';
import { BACKUP_TYPE } from '../config/types'; import { BACKUP_TYPE } from '../config/types';
import { DEFAULT_FS_FREEZE_DEADLINE, getFsFreezeDeadlineOptions } from '../utils/backup';
export default { export default {
name: 'HarvesterVMSnapshotDialog', name: 'HarvesterVMSnapshotDialog',
@ -17,6 +19,7 @@ export default {
AsyncButton, AsyncButton,
Card, Card,
LabeledInput, LabeledInput,
LabeledSelect,
Banner Banner
}, },
@ -31,7 +34,8 @@ export default {
return { return {
snapshotName: '', snapshotName: '',
snapshotNamespace: '', snapshotNamespace: '',
errors: [] errors: [],
fsFreezeDeadline: DEFAULT_FS_FREEZE_DEADLINE
}; };
}, },
@ -40,13 +44,23 @@ export default {
actionResource() { actionResource() {
return this.resources[0]; return this.resources[0];
},
fsFreezeDeadlineEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('fsFreezeDeadline');
},
fsFreezeDeadlineOptions() {
return getFsFreezeDeadlineOptions(this.t);
} }
}, },
methods: { methods: {
close() { close() {
this.fsFreezeDeadline = DEFAULT_FS_FREEZE_DEADLINE;
this.snapshotNamespace = ''; this.snapshotNamespace = '';
this.snapshotName = ''; this.snapshotName = '';
this.errors = [];
this.$emit('close'); this.$emit('close');
}, },
@ -60,7 +74,8 @@ export default {
ownerReferences: this.getOwnerReferencesFromVM(this.actionResource) ownerReferences: this.getOwnerReferencesFromVM(this.actionResource)
}, },
spec: { spec: {
source: { fsFreezeDeadline: this.fsFreezeDeadline,
source: {
apiGroup: 'kubevirt.io', apiGroup: 'kubevirt.io',
kind: 'VirtualMachine', kind: 'VirtualMachine',
name: this.actionResource.metadata.name name: this.actionResource.metadata.name
@ -132,6 +147,14 @@ export default {
:label="t('generic.name')" :label="t('generic.name')"
required required
/> />
<LabeledSelect
v-if="fsFreezeDeadlineEnabled"
v-model:value="fsFreezeDeadline"
class="mt-20"
:options="fsFreezeDeadlineOptions"
:label="t('harvester.modal.vmSnapshot.fileSystemFreezeDeadline.label')"
required
/>
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :key="i"

View File

@ -0,0 +1,24 @@
<script>
import { h } from 'vue';
import { PRODUCT_NAME } from '../config/harvester';
export default {
name: 'ForkliftProviderEdit',
created() {
const { namespace, id } = this.$route.params;
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
this.$router.replace({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$route.params.product,
cluster: this.$route.params.cluster,
},
query,
});
},
render: () => h('div'),
};
</script>

View File

@ -468,10 +468,15 @@ export default {
const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX }); const size = formatSi(sizeBytes, { increment: 1024, suffix: UNIT_SUFFIX });
const parentDevice = d.status?.deviceStatus?.parentDevice; const parentDevice = d.status?.deviceStatus?.parentDevice;
const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath); const isChildAdded = this.newDisks.find((newDisk) => newDisk.blockDevice?.status?.deviceStatus?.parentDevice === devPath);
const mountPoint = d.status?.deviceStatus?.fileSystem?.mountPoint;
const name = d.displayName; const name = d.displayName;
let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`; let label = `${ name } (Type: ${ deviceType }, Size: ${ size })`;
if (mountPoint) {
label = `${ label } - ${ this.t('harvester.host.disk.mounted') }`;
}
if (parentDevice) { if (parentDevice) {
label = `- ${ label }`; label = `- ${ label }`;
} }
@ -481,7 +486,7 @@ export default {
value: d.id, value: d.id,
action: this.addDisk, action: this.addDisk,
kind: !parentDevice ? 'group' : '', kind: !parentDevice ? 'group' : '',
disabled: !!isChildAdded, disabled: !!isChildAdded || !!mountPoint,
group: parentDevice || devPath, group: parentDevice || devPath,
isParent: !!parentDevice, isParent: !!parentDevice,
}; };

View File

@ -1,5 +1,5 @@
<script> <script>
import { VOLUME_MODE } from '@pkg/harvester/config/types'; import { VOLUME_MODE, ACCESS_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,6 +10,10 @@ 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',
@ -90,10 +94,10 @@ export default {
accessModeOptions() { accessModeOptions() {
return [ return [
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' }, { label: READ_WRITE_ONCE, value: READ_WRITE_ONCE },
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' }, { label: READ_ONLY_MANY, value: READ_ONLY_MANY },
{ label: 'ReadWriteMany', value: 'ReadWriteMany' }, { label: READ_WRITE_MANY, value: READ_WRITE_MANY },
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' }, { label: READ_WRITE_ONCE_POD, value: READ_WRITE_ONCE_POD },
]; ];
}, },

View File

@ -26,9 +26,11 @@ 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 } from '@pkg/harvester/config/types'; import { VOLUME_MODE, ACCESS_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',
@ -83,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 = ['ReadWriteMany']; this.value.spec.accessModes = [READ_WRITE_MANY];
} }
const storage = this.value?.spec?.resources?.requests?.storage || null; const storage = this.value?.spec?.resources?.requests?.storage || null;
@ -160,7 +162,7 @@ export default {
}, },
accessModeOptions() { accessModeOptions() {
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany']; return [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY];
}, },
imageOption() { imageOption() {
@ -355,7 +357,7 @@ export default {
getAccessMode() { getAccessMode() {
if (!this.longhornV2LVMSupport) { if (!this.longhornV2LVMSupport) {
return ['ReadWriteMany']; return [READ_WRITE_MANY];
} }
if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) { if (this.value?.spec?.accessModes && this.value?.spec?.accessModes?.length > 0) {
@ -371,7 +373,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 ? ['ReadWriteOnce'] : ['ReadWriteMany']; return readWriteOnce ? [READ_WRITE_ONCE] : [READ_WRITE_MANY];
}, },
buildDataVolumeObj() { buildDataVolumeObj() {
const storage = { const storage = {

View File

@ -9,7 +9,7 @@ import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view'; import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio'; import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types'; import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types';
import { set } from '@shell/utils/object'; import { set, remove } from '@shell/utils/object';
import ArrayList from '@shell/components/form/ArrayList'; import ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types'; import { HCI } from '../../types';
@ -142,10 +142,12 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || []; const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
return vlans.map((vlan) => ({ const options = vlans.map((vlan) => ({
label: vlan.id, label: vlan.id,
value: vlan.id, value: vlan.id,
})); }));
return [{ label: this.t('generic.none'), value: '' }, ...options];
} }
}, },
@ -160,6 +162,14 @@ export default {
} }
}, },
methods: { methods: {
onVlanChange(value) {
if (value === '') {
remove(this.value.spec, 'vlan');
} else {
set(this.value, 'spec.vlan', value);
}
},
async saveSubnet(buttonCb) { async saveSubnet(buttonCb) {
const errors = []; const errors = [];
const name = this.value?.metadata?.name; const name = this.value?.metadata?.name;
@ -283,12 +293,13 @@ export default {
</div> </div>
<div class="col span-6"> <div class="col span-6">
<LabeledSelect <LabeledSelect
v-model:value="value.spec.vlan" :value="value.spec.vlan ?? ''"
class="mb-20" class="mb-20"
:options="vlanOptions" :options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')" :placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')" :label="t('harvester.subnet.vlan.label')"
:mode="mode" :mode="mode"
@update:value="onVlanChange"
/> />
</div> </div>
</div> </div>

View File

@ -68,12 +68,6 @@ export default {
}, },
watch: { watch: {
osType(neu) {
if (neu === 'windows') {
this.id = '';
}
},
value(neu) { value(neu) {
this.yamlScript = neu; this.yamlScript = neu;
}, },

View File

@ -133,6 +133,10 @@ export default {
}]; }];
return this.isMasquerade ? masquerade : other; return this.isMasquerade ? masquerade : other;
},
staticIPForVMEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('staticIPForVM');
} }
}, },
@ -150,6 +154,7 @@ export default {
isSingle(neu) { isSingle(neu) {
if (!neu) { if (!neu) {
this.value['macAddress'] = ''; this.value['macAddress'] = '';
this.value['staticIp'] = '';
this.update(); this.update();
} }
} }
@ -163,6 +168,7 @@ export default {
if (neu === MANAGEMENT_NETWORK) { if (neu === MANAGEMENT_NETWORK) {
this.value.isPod = true; this.value.isPod = true;
this.value.macAddress = ''; this.value.macAddress = '';
this.value.staticIp = '';
} else { } else {
this.value.isPod = false; this.value.isPod = false;
} }
@ -289,9 +295,30 @@ export default {
</a> </a>
</div> </div>
<div class="row"> <div
v-if="showAdvanced"
class="row"
>
<div
v-if="staticIPForVMEnabled"
data-testid="input-hen-staticIp"
class="col span-6"
>
<InputOrDisplay
:name="t('harvester.fields.staticIp')"
:value="value.staticIp"
:mode="mode"
>
<LabeledInput
v-model:value="value.staticIp"
label-key="harvester.fields.staticIp"
:mode="mode"
:tooltip="t('harvester.virtualMachine.volume.staticIpTip', { nic_name: value.name || 'default' })"
@update:value="update"
/>
</InputOrDisplay>
</div>
<div <div
v-if="showAdvanced"
data-testid="input-hen-macAddress" data-testid="input-hen-macAddress"
class="col span-6" class="col span-6"
> >

View File

@ -16,10 +16,12 @@ import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs';
import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map'; import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester';
import { HCI } from '../../../types'; import { HCI } from '../../../types';
import { VOLUME_MODE } from '@pkg/harvester/config/types'; import { VOLUME_MODE, ACCESS_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'],
@ -69,7 +71,7 @@ export default {
customAccessMode: { customAccessMode: {
type: String, type: String,
default: 'ReadWriteMany' default: READ_WRITE_MANY
}, },
resourceType: { resourceType: {

View File

@ -5,10 +5,12 @@ import LabelValue from '@shell/components/LabelValue';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import InputOrDisplay from '@shell/components/InputOrDisplay'; import InputOrDisplay from '@shell/components/InputOrDisplay';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { PVC } from '@shell/config/types'; import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
import { _CREATE } from '@shell/config/query-params'; import { _CREATE } from '@shell/config/query-params';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
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';
@ -19,7 +21,7 @@ export default {
emits: ['update'], emits: ['update'],
components: { components: {
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox
}, },
props: { props: {
@ -94,6 +96,30 @@ export default {
return this.allPVCs.find( (P) => P.metadata.name === this.value.volumeName ); return this.allPVCs.find( (P) => P.metadata.name === this.value.volumeName );
}, },
storageClasses() {
return this.$store.getters['harvester/all'](STORAGE_CLASS) || [];
},
isShareableCapable() {
const pvcSpec = this.pvcResource?.spec;
if (!pvcSpec) {
return false;
}
const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany');
const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK;
const storageClass = this.storageClasses.find((sc) => sc.name === pvcSpec.storageClassName);
// fail closed: without a resolved StorageClass the provisioner
// requirement cannot be evaluated
if (!storageClass) {
return false;
}
return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER;
},
volumeOption() { volumeOption() {
return sortBy( return sortBy(
this.allPVCs this.allPVCs
@ -145,6 +171,7 @@ export default {
this.value.size = pvcResource.spec.resources.requests.storage; this.value.size = pvcResource.spec.resources.requests.storage;
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.update(); this.update();
}, },
@ -155,6 +182,13 @@ export default {
} }
}, },
isShareableCapable(neu) {
if (!neu && this.value.shareable) {
this.value.shareable = false;
this.update();
}
},
pvcResource: { pvcResource: {
handler(pvc) { handler(pvc) {
if (!this.value.volumeName && pvc?.metadata?.name) { if (!this.value.volumeName && pvc?.metadata?.name) {
@ -303,6 +337,26 @@ export default {
/> />
</InputOrDisplay> </InputOrDisplay>
</div> </div>
<div
v-if="isShareableCapable"
data-testid="input-hee-shareable"
class="col span-6"
>
<Checkbox
v-model:value="value.shareable"
class="check"
type="checkbox"
label-key="harvester.virtualMachine.volume.shareable.label"
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
:mode="mode"
@update:value="update"
/>
<Banner
v-if="value.shareable"
color="warning"
:label="t('harvester.virtualMachine.volume.shareable.warning')"
/>
</div>
<div <div
v-if="value.volumeBackups" v-if="value.volumeBackups"
class="col span-6" class="col span-6"

View File

@ -14,7 +14,9 @@ import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storagec
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 } from '@pkg/harvester/config/types'; import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types';
const { READ_WRITE_MANY, READ_WRITE_ONCE } = ACCESS_MODE;
export default { export default {
name: 'HarvesterEditVolume', name: 'HarvesterEditVolume',
@ -205,7 +207,7 @@ export default {
getAccessMode(storageClassName) { getAccessMode(storageClassName) {
if (!this.longhornV2LVMSupport) { if (!this.longhornV2LVMSupport) {
return 'ReadWriteMany'; return READ_WRITE_MANY;
} }
const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName); const storageClass = this.storageClasses.find((sc) => sc.name === storageClassName);
@ -216,7 +218,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 ? 'ReadWriteOnce' : 'ReadWriteMany'; return readWriteOnce ? READ_WRITE_ONCE : READ_WRITE_MANY;
}, },
update() { update() {

View File

@ -93,6 +93,9 @@ export default {
const hostname = this.value.spec.template.spec.hostname || ''; const hostname = this.value.spec.template.spec.hostname || '';
// Display name can contain arbitrary strings. There is no XSS risk because the value is
// rendered via Vue's {{ }} interpolation which auto-escapes HTML; v-html is never used for
// this field. See harvester/harvester#10423 for details.
const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]); const customizeDisplayName = !!(this.value.metadata?.annotations?.[HCI_ANNOTATIONS.VM_DISPLAY_NAME]);
return { return {
@ -484,6 +487,20 @@ export default {
await value.save(); await value.save();
await this.applyHooks(AFTER_SAVE_HOOKS); await this.applyHooks(AFTER_SAVE_HOOKS);
} catch (e) { } catch (e) {
// Reset the generated secret name(s) so a retried create (e.g. after correcting an
// invalid/duplicate VM name) always regenerates them from the current name instead of
// reusing names derived from this failed attempt. Only do this for create: in edit mode
// secretName/sysprep.secretName may reference an existing secret loaded from the VM,
// which must not be discarded. See harvester/harvester#11174.
if (this.isCreate) {
this.secretName = '';
this.secretNamePrefixUsed = '';
if (this.sysprepSecretNamePrefixUsed !== '') {
this.sysprep.secretName = '';
this.sysprepSecretNamePrefixUsed = '';
}
}
this.errors.push(...exceptionToErrorsArray(e)); this.errors.push(...exceptionToErrorsArray(e));
buttonCb(false); buttonCb(false);
} }
@ -1045,7 +1062,6 @@ export default {
</div> </div>
<CloudConfig <CloudConfig
v-if="!isWindows"
ref="yamlEditor" ref="yamlEditor"
:user-script="userScript" :user-script="userScript"
:mode="mode" :mode="mode"

View File

@ -63,6 +63,7 @@ export default {
RANCHER_TYPES, RANCHER_TYPES,
fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }], fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }],
canEditPSPBindings: true, canEditPSPBindings: true,
isQuotasValid: true,
}; };
}, },
computed: { computed: {
@ -190,15 +191,13 @@ export default {
this['membershipUpdate'] = update; this['membershipUpdate'] = update;
}, },
removeQuota(key) { validateQuotas(isValid) {
['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => { this.isQuotasValid = isValid;
if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) { },
delete this.value?.spec[specProp]?.limit[key];
} onQuotasInput({ projectLimit, nsLimit } = {}) {
if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) { this.value.spec.resourceQuota = { ...this.value.spec.resourceQuota, limit: projectLimit };
delete this.value?.spec[specProp]?.usedLimit[key]; this.value.spec.namespaceDefaultResourceQuota = { ...this.value.spec.namespaceDefaultResourceQuota, limit: nsLimit };
}
});
} }
}, },
}; };
@ -212,7 +211,7 @@ export default {
:resource="value" :resource="value"
:subtypes="[]" :subtypes="[]"
:can-yaml="false" :can-yaml="false"
:validation-passed="fvFormIsValid" :validation-passed="fvFormIsValid && isQuotasValid"
@error="e=>errors=e" @error="e=>errors=e"
@finish="save" @finish="save"
@cancel="done" @cancel="done"
@ -267,10 +266,11 @@ export default {
:weight="9" :weight="9"
> >
<ResourceQuota <ResourceQuota
v-model:value="value" :value="value"
:mode="canEditTabElements" :mode="canEditTabElements"
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES" :types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
@remove="removeQuota" @input="onQuotasInput"
@validationChanged="validateQuotas"
/> />
</Tab> </Tab>
<Tab <Tab

View File

@ -283,9 +283,9 @@ export default {
v-model:value.number="vlanID" v-model:value.number="vlanID"
type="number" type="number"
required required
:min="2" :min="1"
:max="4094" :max="4094"
placeholder="e.g. 2 ~ 4094" placeholder="e.g. 1 ~ 4094"
:label="t('harvester.hostNetworkConfig.vlanID.label')" :label="t('harvester.hostNetworkConfig.vlanID.label')"
:mode="mode" :mode="mode"
/> />

View File

@ -1,11 +1,11 @@
<script> <script>
import ConsumptionGauge from '@shell/components/ConsumptionGauge'; import PercentageBar from '@shell/components/PercentageBar';
import { METRIC, NODE } from '@shell/config/types'; import { METRIC, NODE } from '@shell/config/types';
import { parseSi } from '@shell/utils/units'; import { parseSi } from '@shell/utils/units';
export default { export default {
name: 'HarvesterCpuUsed', name: 'HarvesterCpuUsed',
components: { ConsumptionGauge }, components: { PercentageBar },
props: { props: {
value: { value: {
@ -74,54 +74,89 @@ export default {
return 0; return 0;
} }
}, },
}
reservedPercentage() {
return this.cpuTotal ? (this.reserved * 100) / this.cpuTotal : 0;
},
usedPercentage() {
return this.cpuTotal ? (this.used * 100) / this.cpuTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.numberFormatter(this.reserved || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.numberFormatter(this.used || 0),
total: this.numberFormatter(this.cpuTotal || 0),
unit: ` ${ this.cpuUnits }`,
};
},
},
methods: {
numberFormatter(value) {
return Number.isInteger(value) ? value : value.toFixed(2);
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<ConsumptionGauge <div class="consumption-gauge">
:capacity="cpuTotal" <div class="numbers">
:used="reserved" <span class="mr-5">
:units="cpuUnits"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }} {{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span> </span>
</template> </div>
</ConsumptionGauge> <div class="mt-10">
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
<div <div
v-if="showUsed" v-if="showUsed"
class="mt-10" class="consumption-gauge mt-10"
> >
<ConsumptionGauge <div class="numbers">
:capacity="cpuTotal" <span>
:used="used" {{ t('harvester.formatters.hardwareResourceGauge.used') }}
:units="cpuUnits" </span>
> <span class="precent-data">
<template #title="{amountTemplateValues, formattedPercentage}"> {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span> </span>
{{ t('harvester.formatters.hardwareResourceGauge.used') }} </div>
</span> <div class="mt-10">
<span class="precent-data"> <PercentageBar
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} :model-value="usedPercentage"
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }} show-percentage
</span> />
</span> </div>
</template>
</ConsumptionGauge>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }

View File

@ -36,6 +36,10 @@ export default {
computed: { computed: {
ips() { ips() {
if (this.staticAnnotationIP.length) {
return this.staticAnnotationIP;
}
if (this.vmiIp.length) { if (this.vmiIp.length) {
return [...this.vmiIp, ...this.networkAnnotationIP] return [...this.vmiIp, ...this.networkAnnotationIP]
.filter(Boolean) .filter(Boolean)
@ -45,6 +49,24 @@ export default {
return this.customAnnotationIP; return this.customAnnotationIP;
}, },
staticAnnotationIP() {
const annotations = this.row?.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
return Object.entries(annotations)
.filter(([key, value]) => key.startsWith(staticIpPrefix) && !!value)
.map(([key, value]) => {
const nicName = key.slice(staticIpPrefix.length);
const ip = String(value).replace(/\/[\d\D]*/, '');
return {
ip,
name: nicName
};
})
.filter(({ ip }) => isIpv4(ip));
},
customAnnotationIP() { customAnnotationIP() {
const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`); const annotationIp = get(this.row, `metadata.annotations."${ HCI_ANNOTATIONS.CUSTOM_IP }"`);
@ -110,16 +132,28 @@ export default {
</script> </script>
<template> <template>
<div v-if="showIP"> <div
<span v-if="showIP"
class="ip-list"
>
<div
v-for="{ ip, name, isCustom } in ips" v-for="{ ip, name, isCustom } in ips"
:key="`${ip}-${name}`" :key="`${ip}-${name}`"
class="ip-item"
> >
<CopyToClipboardText <CopyToClipboardText
v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name" v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name"
:text="ip" :text="ip"
:plain="isCustom" :plain="isCustom"
/> />
</span> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.ip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
</style>

View File

@ -1,11 +1,11 @@
<script> <script>
import ConsumptionGauge from '@shell/components/ConsumptionGauge'; import PercentageBar from '@shell/components/PercentageBar';
import { METRIC, NODE } from '@shell/config/types'; import { METRIC, NODE } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units'; import { formatSi, exponentNeeded, UNITS, parseSi } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit'; import { UNIT_SUFFIX } from '../utils/unit';
export default { export default {
name: 'HarvesterMemoryUsed', name: 'HarvesterMemoryUsed',
components: { ConsumptionGauge }, components: { PercentageBar },
props: { props: {
value: { value: {
@ -76,6 +76,30 @@ export default {
return 0; return 0;
} }
}, },
reservedPercentage() {
return this.memoryTotal ? (this.reserved * 100) / this.memoryTotal : 0;
},
usedPercentage() {
return this.memoryTotal ? (this.used * 100) / this.memoryTotal : 0;
},
reservedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.reserved || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
usedAmountTemplateValues() {
return {
used: this.memoryFormatter(this.used || 0),
total: this.memoryFormatter(this.memoryTotal || 0),
unit: ` ${ this.memoryUnits }`,
};
},
}, },
methods: { methods: {
@ -96,50 +120,53 @@ export default {
<template> <template>
<div> <div>
<ConsumptionGauge <div class="consumption-gauge">
:capacity="memoryTotal" <div class="numbers">
:used="reserved" <span class="mr-10">
:units="memoryUnits"
:number-formatter="memoryFormatter"
:resource-name="resourceName"
>
<template #title="{amountTemplateValues, formattedPercentage}">
<span>
{{ t('harvester.formatters.hardwareResourceGauge.reserved') }} {{ t('harvester.formatters.hardwareResourceGauge.reserved') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', reservedAmountTemplateValues) }}
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}
</span>
</span> </span>
</template> </div>
</ConsumptionGauge> <div class="mt-10">
<PercentageBar
:model-value="reservedPercentage"
show-percentage
/>
</div>
</div>
<div <div
v-if="showUsed" v-if="showUsed"
class="mt-10" class="consumption-gauge mt-10"
> >
<ConsumptionGauge <div class="numbers">
:capacity="memoryTotal" <span>
:used="used" {{ t('harvester.formatters.hardwareResourceGauge.used') }}
:units="memoryUnits" </span>
:number-formatter="memoryFormatter" <span class="precent-data">
> {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<template #title="{amountTemplateValues, formattedPercentage}"> </span>
<span> </div>
{{ t('harvester.formatters.hardwareResourceGauge.used') }} <div class="mt-10">
</span> <PercentageBar
<span class="precent-data"> :model-value="usedPercentage"
{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} show-percentage
<span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }} />
</span> </div>
</span>
</template>
</ConsumptionGauge>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }

View File

@ -1,12 +1,12 @@
<script> <script>
import ConsumptionGauge from '@shell/components/ConsumptionGauge'; import PercentageBar from '@shell/components/PercentageBar';
import { LONGHORN } from '@shell/config/types'; import { LONGHORN } from '@shell/config/types';
import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units'; import { formatSi, exponentNeeded, UNITS } from '@shell/utils/units';
import { UNIT_SUFFIX } from '../utils/unit'; import { UNIT_SUFFIX } from '../utils/unit';
export default { export default {
name: 'HarvesterStorageUsed', name: 'HarvesterStorageUsed',
components: { ConsumptionGauge }, components: { PercentageBar },
props: { props: {
value: { value: {
@ -120,6 +120,14 @@ export default {
unit: this.allocatedUnits, unit: this.allocatedUnits,
}; };
}, },
usedPercentage() {
return this.storageStats.maximum ? (this.storageStats.used * 100) / this.storageStats.maximum : 0;
},
allocatedPercentage() {
return this.storageStats.total ? (this.storageStats.scheduled * 100) / this.storageStats.total : 0;
},
}, },
methods: { methods: {
@ -144,53 +152,54 @@ export default {
<div> <div>
<div <div
v-if="showAllocated" v-if="showAllocated"
class="consumption-gauge"
> >
<ConsumptionGauge <div class="numbers">
:capacity="storageStats.total" <span class="mr-10">
:used="storageStats.scheduled" {{ t('harvester.dashboard.hardwareResourceGauge.allocated') }}
:units="allocatedUnits" </span>
:number-formatter="formatter" <span class="precent-data">
:resource-name="resourceName" {{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }}
> </span>
<template #title="{formattedPercentage}"> </div>
<span> <div class="mt-10">
{{ t('harvester.dashboard.hardwareResourceGauge.allocated') }} <PercentageBar
</span> :model-value="allocatedPercentage"
<span class="precent-data"> show-percentage
{{ t('node.detail.glance.consumptionGauge.amount', allocatedAmountTemplateValues) }} />
<span class="ml-10 percentage"> </div>
/&nbsp;{{ formattedPercentage }}
</span>
</span>
</template>
</ConsumptionGauge>
</div> </div>
<ConsumptionGauge <div
:capacity="storageStats.maximum" class="consumption-gauge"
:used="storageStats.used" :class="{ 'mt-10': showAllocated }"
:units="usedUnits"
:number-formatter="formatter"
:resource-name="showAllocated ? '' : resourceName"
:class="{
'mt-10': showAllocated,
}"
> >
<template #title="{formattedPercentage}"> <div class="numbers">
<span> <span>
{{ t('node.detail.glance.consumptionGauge.used') }} {{ t('node.detail.glance.consumptionGauge.used') }}
</span> </span>
<span class="precent-data"> <span class="precent-data">
{{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }} {{ t('node.detail.glance.consumptionGauge.amount', usedAmountTemplateValues) }}
<span class="ml-10 percentage">
/&nbsp;{{ formattedPercentage }}
</span>
</span> </span>
</template> </div>
</ConsumptionGauge> <div class="mt-10">
<PercentageBar
:model-value="usedPercentage"
show-percentage
/>
</div>
</div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.consumption-gauge {
.numbers {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.precent-data { .precent-data {
white-space: nowrap; white-space: nowrap;
} }

View File

@ -1,3 +1,8 @@
wizard:
create: Create
next: Proceed
previous: Back
generic: generic:
tip: Tip tip: Tip
resourceExternalLinkTips: 'External Link' resourceExternalLinkTips: 'External Link'
@ -8,6 +13,15 @@ generic:
basic: Basic basic: Basic
loading: Loading... loading: Loading...
none: None none: None
duration:
infinite: Infinite
1s: 1 second
5s: 5 seconds
10s: 10 seconds
30s: 30 seconds
1m: 1 minute
3m: 3 minutes
5m: 5 minutes
unsupported: unsupported:
serverVersion: 'Current version: <code>{serverVersion}</code>' serverVersion: 'Current version: <code>{serverVersion}</code>'
@ -22,6 +36,8 @@ nav:
Logging: Logging Logging: Logging
'Monitoring and Logging': Monitoring and Logging 'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports vmimport: Virtual Machine Imports
vmMigration: Virtual Machine Migration
Configurations: Configurations
resourceTable: resourceTable:
groupBy: groupBy:
@ -35,6 +51,8 @@ members:
projectMembership: Project Membership projectMembership: Project Membership
asyncButton: asyncButton:
createAndStart:
action: Create and Start
restart: restart:
action: Save and Restart action: Save and Restart
success: Restarted success: Restarted
@ -75,6 +93,8 @@ harvester:
backup: backup:
success: 'Backup { backUpName } has been initiated.' success: 'Backup { backUpName } has been initiated.'
addBackup: Add Backup addBackup: Add Backup
fileSystemFreezeDeadline:
label: 'File System Freeze Deadline'
quota: quota:
editVMQuota: Edit VM Quota editVMQuota: Edit VM Quota
editQuota: Edit Quota editQuota: Edit Quota
@ -214,6 +234,8 @@ harvester:
title: Take Virtual Machine Snapshot title: Take Virtual Machine Snapshot
name: Name name: Name
success: 'Take virtual machine Snapshot { name } successfully.' success: 'Take virtual machine Snapshot { name } successfully.'
fileSystemFreezeDeadline:
label: 'File System Freeze Deadline'
restart: restart:
title: Restart Virtual Machine title: Restart Virtual Machine
tip: Restart the virtual machine for configuration changes to take effect. tip: Restart the virtual machine for configuration changes to take effect.
@ -309,7 +331,8 @@ harvester:
message: Message message: Message
phase: Phase phase: Phase
attachedVM: Attached Virtual Machine attachedVM: Attached Virtual Machine
cpuManager: CPU Manager cpuManager: CPU<br>Manager
diskState: Disk
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to. routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
fingerprint: Fingerprint fingerprint: Fingerprint
value: Value value: Value
@ -352,6 +375,13 @@ harvester:
v4ip: V4 IP v4ip: V4 IP
v6ip: V6 IP v6ip: V6 IP
eipName: EIP Name eipName: EIP Name
vmMigrationProviderType: Type
vmMigrationProviderUrl: URL
vmMigrationMapSourceProvider: Source Provider
vmMigrationMapDestProvider: Destination Provider
vmMigrationPlanTargetNs: Target Namespace
vmMigrationPlanVmCount: VM Count
vmMigrationMigrationPlan: Plan
tab: tab:
volume: Volumes volume: Volumes
network: Networks network: Networks
@ -376,6 +406,7 @@ harvester:
network: Network network: Network
model: Model model: Model
macAddress: MAC address macAddress: MAC address
staticIp: Static IP
port: Port port: Port
protocol: Protocol protocol: Protocol
remove: Remove remove: Remove
@ -457,7 +488,6 @@ harvester:
volume: volume:
upperType: Volume name upperType: Volume name
lowerType: volume name lowerType: volume name
needAtLeastOneBootable: 'At least one bootable volume is required!'
image: image:
ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.' ruleTip: 'The URL you have entered ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.' ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
@ -611,6 +641,7 @@ harvester:
notification: notification:
success: 'Update host "{name}" storage configurations successfully.' success: 'Update host "{name}" storage configurations successfully.'
error: Host has unready or unschedulable disks. error: Host has unready or unschedulable disks.
mounted: Already Mounted
fileSystem: fileSystem:
info: Current file system is {system}, You can format it manually. info: Current file system is {system}, You can format it manually.
formatting: Disk is formatting, please wait. formatting: Disk is formatting, please wait.
@ -750,6 +781,7 @@ harvester:
dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order. dragTip: Drag and drop volumes, or use the volume's arrows, to change the boot order.
volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes. volumeTip: The virtual machine only contains a CD-ROM volume. You may want to add additional disk volumes.
macTip: "MAC address as seen inside the guest system." macTip: "MAC address as seen inside the guest system."
staticIpTip: "Static IP that will be stored to annotation static-ip.harvesterhci.io/{nic_name}."
volumeUpdate: 'Set volume { name } successfully' volumeUpdate: 'Set volume { name } successfully'
type: Type type: Type
size: Size size: Size
@ -766,6 +798,10 @@ harvester:
setFirst: Set as root volume setFirst: Set as root volume
saveVolume: Update Volume saveVolume: Update Volume
encryption: Encryption encryption: Encryption
shareable:
label: Shareable
tip: Allow multiple virtual machines to attach and write to this volume simultaneously.
warning: Multiple virtual machines can write to this volume at the same time. The guest workloads must coordinate access to the shared block device, otherwise concurrent writes will corrupt the data. Workloads that fence through SCSI-3 Persistent Reservations (e.g. Windows Server Failover Clustering) also require persistent reservation support from the storage backend. Host cache and hypervisor disk locking are disabled, and backup or snapshot operations are rejected while the volume is shareable.
vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize}) vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
lockTooltip: lockTooltip:
all: All volumes are encrypted. all: All volumes are encrypted.
@ -799,13 +835,13 @@ harvester:
title: "Network Data:" title: "Network Data:"
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>" tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
sysprep: sysprep:
title: Windows Sysprep Configuration title: Windows Unattended & Sysprep Configuration
description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>" description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
createNew: Create new... createNew: Create new...
createNewTitle: Create Windows Sysprep Template createNewTitle: Create Windows Unattended & Sysprep Template
xmlContent: autounattend.xml content xmlContent: autounattend.xml content
secret: secret:
label: Windows Sysprep Template label: Windows Unattended & Sysprep Template
validation: validation:
invalidXml: Invalid XML syntax invalidXml: Invalid XML syntax
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend' invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
@ -1067,6 +1103,8 @@ harvester:
downloadLog: Download Log downloadLog: Download Log
logStatus: Log Download Status logStatus: Log Download Status
dismissMessage: Dismiss it dismissMessage: Dismiss it
upgradeAvailable: Upgrade Available
upgradeUnavailable: Upgrade Unavailable
upgradeInfo: upgradeInfo:
warning: WARNING warning: WARNING
doc: Read the <a href="{url}" target="_blank">documentation</a> before starting the upgrade process. Ensure that you complete procedures that are relevant to your environment and the version you are upgrading to. doc: Read the <a href="{url}" target="_blank">documentation</a> before starting the upgrade process. Ensure that you complete procedures that are relevant to your environment and the version you are upgrading to.
@ -1799,6 +1837,222 @@ harvester:
'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.
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.' 'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
vmMigration:
generic:
unknown: Unknown
identifier: Identifier
podNetwork: Pod Network
ignored: Ignored
usedBy: "Used by:"
vlan: "VLAN {id}"
vCpu: "{count} vCPU"
memoryGb: "{value} GB"
memoryTb: "{value} TB"
elapsed:
hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}"
hours: "{hours} {hours, plural, one {hour} other {hours}}"
minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}"
errors:
failedLoadPlans: Failed to load migration plans
failedLoadVms: Failed to load virtual machines
failedRefreshVms: Failed to refresh virtual machines
failedLoadProviders: Failed to load providers
failedLoadProvider: "Failed to load provider: {error}"
failedResolveDetails: Failed to resolve network and datastore details
failedDecodeCredentials: Failed to decode provider credentials
connectionFailed: Connection failed
providerNotReady: Provider not ready
labels:
dashboard: Migration Overview
provider: Providers
networkMap: Network Maps
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
providerWizard:
title: Create Provider
editTitle: Edit Provider
wizard:
title: VM Migration
steps:
configureProvider:
label: Provider
description: Configure Provider
selectVms:
label: VMs
description: Select VMs
configureMappings:
label: Mappings
description: Define Mappings
reviewMigration:
label: Migration Plan
description: Review Migration Plan
fields:
username: Username
password: Password
configureProvider:
title: Configure Provider
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
connectionDetails: Connection Details
providerSelect: Provider
createNew: Create new provider
requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Harvester Node."
name: Name
urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https://
vddkImageLabel: VMware VDDK Image
vddkImagePlaceholder: "[your-private-registry]/vddk-image:latest"
vddkImageTooltip: Specify the VMware VDDK (Virtual Disk Development Kit) Image to accelerate and optimize VMware virtual machine migration.
skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments
testConnection:
action: Test Connection
success: Connection successful
error: Connection failed
waiting: Testing connection...
testSuccess: Connection test passed
testFailed: Connection test failed
testMissingFields: Please fill in all required fields before testing
testTimeout: Connection test timed out — the provider did not report a status in time
save: Save Provider and Continue
saveExisting: Check Provider and Continue
selectVms:
title: Select Virtual Machines
description: Select the virtual machines you want to migrate from the source provider
discovered: "{count} VMs discovered from"
lastSynced: "Last synced {time} ago • "
refreshNow: Refresh now
availableVms: Available Virtual Machines
selected: selected
clearAll: Clear All
saveSelection: Save Selection and Continue
selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page."
selectAll: "Select all {count} VMs from this provider"
allSelected: "Your current selection includes all VMs."
clearSelection: "Clear Selection"
manualEntry:
title: Add Virtual Machines
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
vmId: VM ID
vmIdPlaceholder: "e.g. vm-60527"
add: Add
added: VMs added
columns:
vmName: VM Name
vmId: VM ID
os: Operating System
resources: Resources
powerState: Power State
network: Network
datastore: Datastore
dashboard:
title: Virtual Machine Migration Overview
description: "Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester."
createPlan: Create Migration
tableTitle: Migrations
columns:
status: Status
plan: Migration Plan
progress: Progress
mappings: Mappings
duration: Duration
progress:
failed: Failed
migration: Migration
initializingMigration: Initializing migration
finishedSuccessfully: Finished Successfully
step: "Step {index}"
vmCount: "{count, plural, one {# VM} other {# VMs}}"
vmId: "id: {id}"
checkMigratedVm: Click to check migrated VM details
configureMappings:
title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue
noTemplate: Start from scratch
removeMap: Remove mapping
networkMapping:
title: Network Mapping
description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network
template: Use existing network mapping as template
options:
podNetworking: Management Network
ignored: Ignored (No NIC will be created)
storageMapping:
title: Storage Mapping
description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template
storageDefaults:
title: 'Storage defaults — {name}'
description: 'Set the volume mode and access modes applied to every datastore in this storage map that maps to {name}.'
descriptionInherited: 'Applies to every datastore in this storage map that maps to {name}. The provider default for {provider} stays unchanged.'
summary: 'Volume mode: {volumeMode} · Access mode: {accessModes}'
inherited: 'Inherited from provider {provider}'
volumeMode: Volume Mode
accessMode: Access Mode
edit: Edit
cancel: Cancel
apply: Apply
resetToProviderDefault: Reset to Provider Default
applyOverride: Apply Override
reviewMigration:
title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name
planNamePlaceholder: "e.g. my-migration-plan"
planNameProviderConflict: 'The name “{name}” is already used by an existing provider. Please input a different plan name.'
planNamePlanConflict: 'The name “{name}” is already used by an existing migration plan. Please input a different plan name.'
migrationDetails: Migration Details
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs
vcpu: vCPU
memory: Memory
storage: Storage
source: Source
provider: Provider
targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace"
migrationMode: Migration Mode
coldMigration: Cold Migration
coldMigrationDescription: All source VMs will be powered off during migration.
coldMigrationHint: Plan a maintenance window and notify relevant stakeholders before starting.
virtualMachines: Virtual Machines
editSelection: Edit selection
noVmSelected: "No VMs selected. Return to "
noVmSelectedLink: "Step 2 to select virtual machines."
columns:
vmName: VM Name
os: Operating System
resources: Resources
powerState: Power State
networkMapping: Network Mapping
storageMapping: Storage Mapping
sourceTarget: "Source \u2192 Target"
actions:
menuLabel: Actions
editMappings: Edit Mappings
removeFromPlan: Remove from Plan
warningTitle: "Cold Migration Warning"
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
startMigration: Start Migration Plan
plan:
states:
error: Error
canceled: Canceled
inProgress: In Progress
active: Succeeded
actions:
start: Start
restart: Restart
stop: Stop
deleteDialog:
warning: Deleting this migration plan will also delete the following related resources
vmImport: vmImport:
titles: titles:
basic: Basic basic: Basic
@ -2047,6 +2301,12 @@ harvester:
prefix: The nvidia-driver-toolkit add-on is not enabled, click prefix: The nvidia-driver-toolkit add-on is not enabled, click
middle: here middle: here
suffix: to enable it to manage your SR-IOV GPU devices. suffix: to enable it to manage your SR-IOV GPU devices.
enable:
title: Enable SR-IOV GPU Device
skipMigConfiguration: Use timesliced mode (skip MIG configuration)
note: If unchecked, MIG mode will be attempted by default. If MIG mode is not supported or fails to initialize, it will fall back to timesliced mode. The actual mode depends on whether the GPU supports MIG.
timeslicedBadge: timesliced mode
timesliced: Timesliced
migconfiguration: migconfiguration:
label: vGPU MIG Configurations label: vGPU MIG Configurations
@ -2434,3 +2694,28 @@ typeLabel:
one { Virtual Machine Import } one { Virtual Machine Import }
other { Virtual Machine Imports } other { Virtual Machine Imports }
} }
forklift.konveyor.io.provider: |-
{count, plural,
one { Provider }
other { Providers }
}
forklift.konveyor.io.networkmap: |-
{count, plural,
one { Network Map }
other { Network Maps }
}
forklift.konveyor.io.storagemap: |-
{count, plural,
one { Storage Map }
other { Storage Maps }
}
forklift.konveyor.io.plan: |-
{count, plural,
one { Migration Plan }
other { Migration Plans }
}
forklift.konveyor.io.migration: |-
{count, plural,
one { Migration }
other { Migrations }
}

View File

@ -86,6 +86,13 @@ export default {
value: 'spec.address', value: 'spec.address',
sort: ['spec.address'] sort: ['spec.address']
}, },
{
name: 'timesliced',
labelKey: 'harvester.sriovgpu.timesliced',
value: 'isTimesliced',
formatter: 'Checked',
align: 'center',
},
{ {
name: 'vfAddresses', name: 'vfAddresses',
label: 'VF Addresses', label: 'VF Addresses',

View File

@ -53,7 +53,6 @@ export default {
devices() { devices() {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || []; const data = this.$store.getters[`${ inStore }/all`](HCI.USB_DEVICE) || [];
data.forEach((row) => { data.forEach((row) => {

View File

@ -84,7 +84,7 @@ export default {
}, },
{ {
...NAME, ...NAME,
width: 130, width: 150,
}, },
{ {
name: 'host-ip', name: 'host-ip',
@ -136,14 +136,14 @@ export default {
value: 'id', value: 'id',
formatter: 'HarvesterCPUPinning', formatter: 'HarvesterCPUPinning',
formatterOpts: { rows: this.rows }, formatterOpts: { rows: this.rows },
width: 150, width: 75,
align: 'center', align: 'center',
}); });
} }
if (this.hasLonghornSchema) { if (this.hasLonghornSchema) {
out.push({ out.push({
name: 'diskState', name: 'diskState',
labelKey: 'tableHeaders.diskState', labelKey: 'harvester.tableHeaders.diskState',
value: 'diskState', value: 'diskState',
formatter: 'HarvesterDiskState', formatter: 'HarvesterDiskState',
width: 130, width: 130,
@ -156,7 +156,6 @@ export default {
name: 'console', name: 'console',
label: ' ', label: ' ',
align: 'right', align: 'right',
width: 80,
}); });
return out; return out;
@ -239,6 +238,8 @@ export default {
v-if="!row.consoleUrl" v-if="!row.consoleUrl"
:href="consoleDocLink" :href="consoleDocLink"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="console-info"
><i class="icon icon-info" /></a> ><i class="icon icon-info" /></a>
</div> </div>
</template> </template>
@ -249,5 +250,13 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.console-button { .console-button {
display: flex; display: flex;
position: relative;
justify-content: flex-end;
}
.console-info {
position: absolute;
top: -8px;
right: -3px;
} }
</style> </style>

View File

@ -59,7 +59,7 @@ export default {
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG); const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
return configs; return configs.filter((config) => config.spec?.clusterNetwork !== 'mgmt');
}, },
vlanConfigSchema() { vlanConfigSchema() {
@ -121,6 +121,22 @@ export default {
return [...this.rows, ...fakeRows]; return [...this.rows, ...fakeRows];
}, },
managementVlanConfigs() {
const inStore = this.$store.getters['currentProduct'].inStore;
const configs = this.$store.getters[`${ inStore }/all`](HCI.VLAN_CONFIG);
return configs
.filter((vc) => vc.spec?.clusterNetwork === 'mgmt')
.map((vc) => ({
id: vc.id,
node: vc.id.replace('mgmt-vlanconfig-', ''),
bondMode: vc.spec?.uplink?.bondOptions?.mode || '-',
interfaces: (vc.spec?.uplink?.nics?.length ? vc.spec.uplink.nics.join(', ') : '-'),
mtu: vc.spec?.uplink?.linkAttributes?.mtu || '-',
}));
},
}, },
methods: { methods: {
@ -224,6 +240,29 @@ export default {
</button> </button>
</div> </div>
</div> </div>
<div
v-if="group.key === 'mgmt'"
class="mgmt-summary"
>
<div class="mgmt-header">
<div>{{ t('harvester.tableHeaders.vm.node') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.bondOptions.mode.label') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.nics.label') }}</div>
<div>{{ t('harvester.vlanConfig.uplink.linkAttributes.mtu.label') }}</div>
</div>
<div
v-for="config in managementVlanConfigs"
:key="config.id"
class="mgmt-row"
>
<div>{{ config.node }}</div>
<div>{{ config.bondMode }}</div>
<div>{{ config.interfaces }}</div>
<div>{{ config.mtu }}</div>
</div>
</div>
</template> </template>
<template <template
v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs" v-for="(clusterNetwork, i) in clusterNetworkWithoutConfigs"
@ -273,6 +312,21 @@ export default {
} }
} }
} }
.mgmt-header,
.mgmt-row {
display: grid;
grid-template-columns: 2fr 2fr 2fr 1fr;
padding: 8px 12px;
}
.mgmt-header {
font-weight: 600;
}
.mgmt-row {
border-top: 1px solid var(--border);
}
</style> </style>
<style lang="scss"> <style lang="scss">

View File

@ -38,6 +38,10 @@ export const SSH_EXISTING_TYPE = {
export default { export default {
methods: { methods: {
hasCloudConfigComment(userScript) { hasCloudConfigComment(userScript) {
if (typeof userScript === 'string' && /^\s*#cloud-config(\s|$)/.test(userScript)) {
return true;
}
// Check that userData contains: #cloud-config // Check that userData contains: #cloud-config
const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({}); const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({});
const items = userDataDoc?.contents?.items || []; const items = userDataDoc?.contents?.items || [];
@ -52,6 +56,14 @@ export default {
exist = true; exist = true;
} }
if (userDataDoc?.contents?.commentBefore === 'cloud-config' || userDataDoc?.contents?.commentBefore?.includes('cloud-config\n')) {
exist = true;
}
if (userDataDoc?.contents?.comment === 'cloud-config' || userDataDoc?.contents?.comment?.includes('cloud-config\n')) {
exist = true;
}
items.map((item) => { items.map((item) => {
const key = item.key; const key = item.key;

View File

@ -25,7 +25,9 @@ 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, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types'; import { VOLUME_MODE, ACCESS_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';
@ -162,6 +164,12 @@ export default {
machineType: '', machineType: '',
machineTypes: [], machineTypes: [],
secretName: '', secretName: '',
// Tracks the name prefix that `secretName` was generated from, so it can be
// regenerated if the VM name changes (e.g. after a failed create attempt is retried
// with a different name). See harvester/harvester#11174.
secretNamePrefixUsed: '',
// Same purpose as secretNamePrefixUsed, but for the Windows sysprep secret name.
sysprepSecretNamePrefixUsed: '',
secretRef: null, secretRef: null,
showAdvanced: false, showAdvanced: false,
deleteAgent: true, deleteAgent: true,
@ -270,7 +278,7 @@ export default {
}, },
customAccessMode() { customAccessMode() {
return this.storageClassSetting.accessModes || 'ReadWriteMany'; return this.storageClassSetting.accessModes || READ_WRITE_MANY;
}, },
isWindows() { isWindows() {
@ -498,7 +506,7 @@ export default {
id: randomStr(5), id: randomStr(5),
source: SOURCE_TYPE.IMAGE, source: SOURCE_TYPE.IMAGE,
name: 'disk-0', name: 'disk-0',
accessMode: 'ReadWriteMany', // root disk only support LHv1 volume, should be RWX accessMode: READ_WRITE_MANY, // root disk only support LHv1 volume, should be RWX
bus, bus,
volumeName: '', volumeName: '',
size, size,
@ -564,7 +572,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] || 'ReadWriteMany'; accessMode = pvcResource?.spec?.accessModes?.[0] || READ_WRITE_MANY;
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;
@ -610,6 +618,7 @@ export default {
type, type,
storageClassName, storageClassName,
hotpluggable, hotpluggable,
shareable: DISK.shareable || false,
volumeStatus, volumeStatus,
dataSource, dataSource,
namespace, namespace,
@ -630,6 +639,8 @@ export default {
const networks = vm.spec.template.spec.networks || []; const networks = vm.spec.template.spec.networks || [];
const interfaces = vm.spec.template.spec.domain.devices.interfaces || []; const interfaces = vm.spec.template.spec.domain.devices.interfaces || [];
const annotations = vm.metadata?.annotations || {};
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const out = interfaces.map( (I, index) => { const out = interfaces.map( (I, index) => {
const network = networks.find( (N) => I.name === N.name); const network = networks.find( (N) => I.name === N.name);
@ -646,6 +657,7 @@ export default {
newCreateId: (fromTemplate || init) ? randomStr(10) : false, newCreateId: (fromTemplate || init) ? randomStr(10) : false,
model: I.model, model: I.model,
networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName, networkName: isPod ? MANAGEMENT_NETWORK : network?.multus?.networkName,
staticIp: annotations[`${ staticIpPrefix }${ I.name }`] || '',
}; };
}); });
@ -747,6 +759,8 @@ export default {
const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm; const vm = this.resourceType === HCI.VM ? this.value : this.value.spec.vm;
this.syncStaticIpAnnotations(vm);
// parse reserved memory // parse reserved memory
if (!this.reservedMemory) { if (!this.reservedMemory) {
delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY]; delete vm.metadata.annotations[HCI_ANNOTATIONS.VM_RESERVED_MEMORY];
@ -768,6 +782,53 @@ export default {
} }
}, },
syncStaticIpAnnotations(vm) {
if (!vm?.metadata) {
return;
}
if (!vm.metadata.annotations) {
vm.metadata.annotations = {};
}
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const annotations = vm.metadata.annotations;
const desired = {};
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
desired[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
}
});
const current = {};
Object.keys(annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
current[key] = annotations[key];
}
});
// Skip mutation when already in sync to avoid triggering a reactive update loop.
const desiredKeys = Object.keys(desired);
const currentKeys = Object.keys(current);
const isSame = desiredKeys.length === currentKeys.length &&
desiredKeys.every((key) => current[key] === desired[key]);
if (isSame) {
return;
}
currentKeys.forEach((key) => {
delete annotations[key];
});
Object.entries(desired).forEach(([key, value]) => {
annotations[key] = value;
});
},
setCPUAndMemory() { setCPUAndMemory() {
if (this.cpuMemoryHotplugEnabled) { if (this.cpuMemoryHotplugEnabled) {
// set CPU // set CPU
@ -837,18 +898,30 @@ export default {
} }
}); });
if (this.needNewSecret || !this.secretName) { // Regenerate the cloud-init secret name whenever it hasn't been generated yet, or the VM
// name has changed since it was last generated (e.g. a previous create attempt failed with
// an invalid/duplicate name and the user corrected it). Without this, a stale secret name
// derived from a rejected attempt gets reused for the VM that's actually created.
// See harvester/harvester#11174.
if (this.needNewSecret || !this.secretName || (this.isCreate && this.secretNamePrefixUsed !== this.secretNamePrefix)) {
this.secretName = this.generateSecretName(this.secretNamePrefix); this.secretName = this.generateSecretName(this.secretNamePrefix);
this.secretNamePrefixUsed = this.secretNamePrefix;
} }
if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) { if (!disks.find((D) => D.name === 'sysprep') && this.isWindows) {
const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.(); const hasSysprepContent = !!this.sysprep.xmlContent?.trim?.();
// If we have content but no secret name, it's a new secret that needs a name. // If we have content but no secret name, it's a new secret that needs a name. Also
if (hasSysprepContent && !this.sysprep.secretName?.trim?.()) { // regenerate when creating a VM if the name prefix has changed since it was last
// generated.
const sysprepNeedsNewSecretName = !this.sysprep.secretName?.trim?.() ||
(this.isCreate && this.sysprepSecretNamePrefixUsed !== '' && this.sysprepSecretNamePrefixUsed !== this.secretNamePrefix);
if (hasSysprepContent && sysprepNeedsNewSecretName) {
const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep'; const prefix = this.secretNamePrefix ? `${ this.secretNamePrefix }-windows-sysprep` : 'windows-sysprep';
this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`; this.sysprep.secretName = `${ this.value.metadata.namespace }/${ this.generateSecretName(prefix) }`;
this.sysprepSecretNamePrefixUsed = this.secretNamePrefix;
} }
// Preserve/attach sysprep whenever a secret is selected/known. // Preserve/attach sysprep whenever a secret is selected/known.
@ -867,38 +940,44 @@ export default {
} }
} }
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkData)) { if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
if (!this.isWindows) { disks.push({
disks.push({ name: 'cloudinitdisk',
name: 'cloudinitdisk', disk: { bus: 'virtio' }
disk: { bus: 'virtio' } });
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent }); const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = { const cloudinitdisk = {
name: 'cloudinitdisk', name: 'cloudinitdisk',
cloudInitNoCloud: {} cloudInitNoCloud: {}
}; };
if (this.saveUserDataAsClearText) { if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData; cloudinitdisk.cloudInitNoCloud.userData = userData;
} else { } else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName }; cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
}
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
} }
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
} }
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks; const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
const mergedDisks = this.mergeDeviceList(specDisks, disks); const mergedDisks = this.mergeDeviceList(specDisks, disks);
// `shareable` is an attachment-level opt-in, remove the field
// inherited from the old spec when the disk row no longer requests it
mergedDisks.forEach((disk) => {
if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) {
delete disk.shareable;
}
});
let spec = { let spec = {
...this.spec, ...this.spec,
runStrategy: this.runStrategy, runStrategy: this.runStrategy,
@ -1095,6 +1174,16 @@ export default {
}, },
getInitUserData(config) { getInitUserData(config) {
// Windows guests don't use qemu-guest-agent via systemd (VMDP installs
// the QGA as a Windows service), and the Linux `runcmd` recipe would
// fail on Cloudbase-Init. Return an empty string so `this.userData`
// stays falsy on Windows until the user actually enters cloud-config
// content — this prevents emitting a spurious cloudinitdisk volume
// and Secret for every Windows VM (Copilot review comment on #984).
if (config.osType === 'windows') {
return '';
}
const _QGA_JSON = this.getMatchQGA(config.osType); const _QGA_JSON = this.getMatchQGA(config.osType);
const out = jsyaml.dump(_QGA_JSON); const out = jsyaml.dump(_QGA_JSON);
@ -1122,9 +1211,10 @@ export default {
userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config }); userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config });
const userDataYaml = userDataDoc.toString(); const userDataYaml = userDataDoc.toString();
const userDataValue = userDataDoc.toJSON();
if (userDataYaml === '{}\n') { if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) {
// When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned. // Empty userData should not create cloud-init content like `null`.
return undefined; return undefined;
} }
@ -1173,6 +1263,10 @@ export default {
out.cdrom = { bus: R.bus }; out.cdrom = { bus: R.bus };
} }
if (R.shareable) {
out.shareable = true;
}
out.bootOrder = index + 1; out.bootOrder = index + 1;
return out; return out;
@ -1297,13 +1391,22 @@ export default {
*/ */
deleteYamlDocProp(doc, paths) { deleteYamlDocProp(doc, paths) {
try { try {
const item = doc.getIn([])?.items[0]; const items = doc.getIn([])?.items;
const key = item?.key; const firstKey = items?.[0]?.key;
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config'); const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
const isMatchProp = key.source === paths[paths.length - 1]; const isFirstProp = firstKey?.source === paths[paths.length - 1];
if (key && hasCloudConfigComment && isMatchProp) { if (firstKey && hasCloudConfigComment && isFirstProp) {
// Comments are mounted on the next node and we should not delete the node containing cloud-config const comment = firstKey.commentBefore;
doc.deleteIn(paths);
// Move the comment to the new first key; if no keys remain the comment is lost.
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
if (newFirstKey) {
newFirstKey.commentBefore = comment;
}
} else { } else {
doc.deleteIn(paths); doc.deleteIn(paths);
} }
@ -1354,7 +1457,6 @@ export default {
if (packages.length > 0) { if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages); userDataDoc.setIn(['packages'], packages);
} else { } else {
userDataDoc.setIn(['packages'], []); // It needs to be set empty first, as it is possible that cloud-init comments are mounted on this node
this.deleteYamlDocProp(userDataDoc, ['packages']); this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']); this.deleteYamlDocProp(userDataDoc, ['package_update']);
} }
@ -1369,7 +1471,7 @@ export default {
}, },
deleteQGA(config) { deleteQGA(config) {
const { osType, userDataDoc, deletePackage = false } = config; const { osType, userDataDoc } = config;
const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || ''; const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || '';
@ -1378,6 +1480,15 @@ export default {
const packages = userDataJSON?.packages || []; const packages = userDataJSON?.packages || [];
const runcmd = userDataJSON?.runcmd || []; const runcmd = userDataJSON?.runcmd || [];
// Special handling of OS types.
let deletePackage = config.deletePackage ?? false;
switch (osType) {
case 'windows':
deletePackage = true;
break;
}
if (Array.isArray(packages) && deletePackage) { if (Array.isArray(packages) && deletePackage) {
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue); const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
@ -1403,7 +1514,6 @@ export default {
if (packages.length > 0) { if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages); userDataDoc.setIn(['packages'], packages);
} else { } else {
userDataDoc.setIn(['packages'], []);
this.deleteYamlDocProp(userDataDoc, ['packages']); this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']); this.deleteYamlDocProp(userDataDoc, ['package_update']);
} }
@ -1436,7 +1546,7 @@ export default {
}, },
async saveSecret(vm) { async saveSecret(vm) {
if (!vm?.spec || !this.secretName || this.isWindows) { if (!vm?.spec || !this.secretName) {
return true; return true;
} }
@ -1759,7 +1869,18 @@ export default {
const specDevice = specDeviceMap.get(device.name); const specDevice = specDeviceMap.get(device.name);
if (specDevice) { if (specDevice) {
return { ...specDevice, ...device }; const merged = { ...specDevice, ...device };
// A disk entry must be either `disk` or `cdrom`, never both.
const type = device?.cdrom ? CD_ROM : device?.disk ? HARD_DISK : '';
if (type === CD_ROM) {
delete merged.disk;
} else if (type === HARD_DISK) {
delete merged.cdrom;
}
return merged;
} }
return device; return device;
@ -1807,6 +1928,15 @@ export default {
}, },
watch: { watch: {
networkRows: {
handler() {
const vm = this.resourceType === HCI.VM ? this.value : this.value?.spec?.vm;
this.syncStaticIpAnnotations(vm);
},
deep: true
},
diskRows: { diskRows: {
handler(neu, old) { handler(neu, old) {
if (Array.isArray(neu)) { if (Array.isArray(neu)) {
@ -1837,8 +1967,6 @@ export default {
isWindows(val) { isWindows(val) {
if (val) { if (val) {
this['sshKey'] = []; this['sshKey'] = [];
this['userScript'] = undefined;
this['networkScript'] = undefined;
this['installAgent'] = false; this['installAgent'] = false;
} }
}, },
@ -1898,9 +2026,8 @@ export default {
osType(neu, old) { osType(neu, old) {
this.installAgent = old === 'windows' ? true : this.installAgent; this.installAgent = old === 'windows' ? true : this.installAgent;
const out = old === 'windows' ? this.getInitUserData({ osType: neu }) : this.getUserData({ installAgent: this.installAgent, osType: neu }); this.userScript = this.getUserData({ installAgent: this.installAgent, osType: neu });
this['userScript'] = out;
this.refreshYamlEditor(); this.refreshYamlEditor();
}, },

View File

@ -69,16 +69,15 @@ export default class SRIOVDevice extends SteveModel {
return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0; return this.spec.enabled && this.status?.vfAddresses?.length > 0 && this.status?.vGPUDevices?.length > 0;
} }
async enableDevice() { get isTimesliced() {
try { return this.metadata?.annotations?.[HCI_ANNOTATIONS.SKIP_MIG_CONFIGURATION] === 'true';
this.spec.enabled = true; }
await this.save();
} catch (err) { enableDevice() {
this.$dispatch('growl/fromError', { this.$dispatch('promptModal', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.metadata.name) }), resources: [this],
err, component: 'EnableSriovGpuDevice'
}, { root: true }); });
}
} }
async disableDevice() { async disableDevice() {

View File

@ -0,0 +1,286 @@
import HarvesterResource from './harvester';
import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester';
import { randomStr } from '@shell/utils/string';
export default class ForkliftPlan extends HarvesterResource {
get listLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
};
}
/**
* The base HarvesterResource sets `doneOverride` to `listLocation`, which would
* force every single-item delete to redirect to the VM Migration dashboard.
* Return undefined so the delete flow stays on the current page (list/dashboard)
* and only falls back to the shell's default navigation when a now-invalid
* detail page is being viewed.
*/
get doneOverride() {
return undefined;
}
get planFailed() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
}
get planCritical() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.category === 'Critical' && c.status === 'True');
}
get criticalMessages() {
const conditions = this.status?.conditions || [];
return conditions
.filter((c) => c.category === 'Critical' && c.status === 'True')
.map((c) => c.message);
}
get stateDescription() {
if (this.metadata?.deletionTimestamp) {
return null;
}
const messages = [];
const conditions = this.status?.conditions || [];
if (this.planFailed) {
const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message;
if (failedMsg) {
messages.push(failedMsg);
}
}
if (this.planCritical) {
messages.push(...this.criticalMessages);
}
return messages.length > 0 ? messages.join(' ') : null;
}
get stateObj() {
return {
error: this.planFailed || this.planCritical,
transitioning: this.isMigrating || this.isPendingMigrationStart || !!this.metadata?.deletionTimestamp,
message: this.stateDescription,
};
}
get isMigrating() {
const migration = this.status?.migration;
return !!migration?.started && !migration?.completed;
}
// Right after creating a plan, controller status can lag briefly.
// Treat this window as in-progress so we don't flash a succeeded badge.
get isPendingMigrationStart() {
const vmCount = this.spec?.vms?.length || 0;
return vmCount > 0 && !this.planFailed && !this.planCritical && !this.planCanceled && !this.planSucceeded && !this.isMigrating;
}
get planCanceled() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return false;
}
const latest = history[history.length - 1];
const conditions = latest.conditions || [];
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
}
get planSucceeded() {
const migration = this.status?.migration;
return !!migration?.completed && !this.planFailed && !this.planCanceled;
}
get stateDisplay() {
if (this.metadata?.deletionTimestamp) {
return 'Terminating';
}
if (this.planFailed) {
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCritical) {
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCanceled) {
return this.t('harvester.addons.vmMigration.plan.states.canceled');
}
if (this.isMigrating) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
if (this.isPendingMigrationStart) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
return this.t('harvester.addons.vmMigration.plan.states.active');
}
get stateBackground() {
if (this.metadata?.deletionTimestamp) {
return 'bg-info';
}
if (this.planFailed) {
return 'bg-error';
}
if (this.planCritical) {
return 'bg-error';
}
if (this.planCanceled) {
return 'bg-warning';
}
if (this.isMigrating) {
return 'bg-info';
}
if (this.isPendingMigrationStart) {
return 'bg-info';
}
return 'bg-success';
}
get isForkliftDashboard() {
const route = this.currentRouter()?.currentRoute?.value;
return route?.name?.endsWith('-vm-migration');
}
get _availableActions() {
const canStop = this.isMigrating && !this.planCanceled;
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
const out = [];
if (canStart) {
out.push({
action: 'startMigration',
enabled: true,
icon: 'icon icon-play',
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
});
}
if (canStop) {
out.push({
action: 'stopMigration',
enabled: true,
icon: 'icon icon-pause',
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
});
}
if (this.isForkliftDashboard) {
out.push({
action: 'promptRemove',
altAction: 'remove',
label: this.t('action.remove'),
icon: 'icon icon-trash',
bulkable: true,
enabled: this.canDelete,
bulkAction: 'promptRemove',
weight: -10,
});
return out;
} else {
out.push(...super._availableActions);
return out;
}
}
async stopMigration() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return;
}
const latest = history[history.length - 1];
const migrationName = latest.migration?.name;
if (migrationName) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
await this.$dispatch('request', { url, method: 'DELETE' });
}
}
async startMigration() {
const namespace = this.metadata.namespace;
const history = this.status?.migration?.history || [];
// Delete previous migrations before starting a new one
for (const entry of history) {
const name = entry.migration?.name;
if (name) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
try {
await this.$dispatch('request', { url, method: 'DELETE' });
} catch (e) {
// Migration may already be gone — ignore 404s
}
}
}
const migration = await this.$dispatch('create', {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
namespace,
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
uid: this.metadata.uid,
blockOwnerDeletion: true,
},
],
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
namespace,
},
},
});
await migration.save();
}
async deletePlan() {
await this.remove();
}
}

View File

@ -0,0 +1,36 @@
import HarvesterResource from './harvester';
import { PRODUCT_NAME } from '../config/harvester';
export default class ForkliftProvider extends HarvesterResource {
get _createLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
}
};
}
get _editLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
};
}
/**
* Deleting a Provider cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove(opt = {}) {
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
return this._remove(opt);
}
}

View File

@ -69,6 +69,11 @@ export default class HarvesterResource extends SteveModel {
} }
} }
// Disable Resources/ Extras cards in resource details pages
get resourcesCardRows() {
return [];
}
cleanForSave(data, _forNew) { cleanForSave(data, _forNew) {
return data; return data;
} }

View File

@ -1,13 +1,13 @@
{ {
"name": "harvester", "name": "harvester",
"description": "Rancher UI Extension for Harvester", "description": "Rancher UI Extension for Harvester",
"version": "1.9.0-dev", "version": "1.10.0-dev",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
"catalog.cattle.io/display-name": "Harvester", "catalog.cattle.io/display-name": "Harvester",
"catalog.cattle.io/kube-version": ">= 1.16.0-0", "catalog.cattle.io/kube-version": ">= 1.16.0-0",
"catalog.cattle.io/rancher-version": ">= 2.14.0-0", "catalog.cattle.io/rancher-version": ">= 2.15.0-0",
"catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0" "catalog.cattle.io/ui-extensions-version": ">= 3.0.0 < 4.0.0"
} }
}, },

View File

@ -1,10 +1,9 @@
<script> <script>
import Loading from '@shell/components/Loading';
import { HCI } from '../../../../../types'; import { HCI } from '../../../../../types';
import SerialConsole from '../../../../../components/SerialConsole'; import SerialConsole from '../../../../../components/SerialConsole';
export default { export default {
components: { SerialConsole, Loading }, components: { SerialConsole },
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 });
@ -38,16 +37,44 @@ export default {
</script> </script>
<template> <template>
<Loading v-if="$fetchState.pending" /> <div class="serial-console-page">
<SerialConsole <SerialConsole
v-else ref="serialConsole"
ref="serialConsole" :value="vmi || {}"
v-model:value="vmi" />
/> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss">
body, #__nuxt, #__layout, main { html,
body,
#__nuxt,
#__layout,
main {
height: 100%; height: 100%;
} }
.serial-console-page {
position: fixed;
inset: 0;
min-height: 0;
background: var(--body-bg);
color: var(--terminal-text);
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25));
}
.harvester-shell-container {
height: 100%;
background: var(--terminal-bg);
}
}
</style> </style>

View File

@ -0,0 +1,558 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable';
import PercentageBar from '@shell/components/PercentageBar';
import { Banner } from '@components/Banner';
import MappingsCell from '../../../../components/MappingsCell';
import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { ADD_ONS } from '../../../../config/harvester-map';
import { currentRouter } from '../../../../utils/router';
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
const store = useStore();
const { t } = useI18n(store);
const loading = ref(true);
const errors = ref([]);
const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
const allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
const escapeHtml = (value = '') => String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const getPipelineStepMeta = (step = {}) => {
const phase = String(step.phase || '').toLowerCase();
const hasProgress = typeof step.progress?.completed === 'number' && typeof step.progress?.total === 'number' && step.progress.total > 0;
const progressPct = hasProgress ? Math.round((step.progress.completed / step.progress.total) * 1000) / 10 : null;
if (phase === 'completed' || phase === 'succeeded') {
return {
icon: '✓',
color: 'var(--success)',
text: ''
};
}
if (phase === 'running' || phase === 'inprogress' || phase === 'progressing' || phase === 'started' || (hasProgress && progressPct > 0)) {
return {
icon: '↻',
color: 'var(--primary)',
text: progressPct !== null ? `${ step.phase || t('generic.inProgress') } (${ progressPct }%)` : (step.phase || t('generic.inProgress'))
};
}
if (phase === 'failed' || phase === 'error') {
return {
icon: '✕',
color: 'var(--error)',
text: step.phase || t('harvester.addons.vmMigration.dashboard.progress.failed')
};
}
return {
icon: '○',
color: 'var(--muted)',
text: ''
};
};
const formatPipelineTooltip = (pipeline = []) => {
if (!pipeline.length) {
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:var(--muted);font-weight:700;">○</span><span>${ escapeHtml(t('harvester.addons.vmMigration.dashboard.progress.initializingMigration')) }</span></div>`;
}
return pipeline
.map((step, idx) => {
const stepName = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
const meta = getPipelineStepMeta(step);
const lineText = meta.text ? `${ escapeHtml(stepName) } : ${ escapeHtml(meta.text) }` : escapeHtml(stepName);
return `<div style="display:flex;align-items:center;gap:8px;"><span style="color:${ meta.color };font-weight:700;">${ meta.icon }</span><span>${ lineText }</span></div>`;
})
.join('');
};
const formatDuration = (started, completed) => {
if (!started) {
return { display: null, seconds: -1 };
}
const startMs = new Date(started).getTime();
const endMs = completed ? new Date(completed).getTime() : Date.now();
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) {
return { display: null, seconds: -1 };
}
const totalSeconds = Math.floor((endMs - startMs) / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const display = hours > 0 ? `${ hours }h ${ minutes }m` : `${ minutes }m ${ seconds }s`;
return { display, seconds: totalSeconds };
};
const rows = computed(() => {
return allPlans.value.map((plan) => {
const netMapName = plan.spec?.map?.network?.name;
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
const storMapName = plan.spec?.map?.storage?.name;
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' }${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking') : (e.destination?.name || 'Ignored') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' }${ e.destination?.storageClass || 'Ignored' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
const vms = plan.status?.migration?.vms || [];
plan.vmProgress = vms.map((vm) => {
const pipeline = vm.pipeline || [];
const totalSteps = pipeline.length || 1;
let overallProgress = 0;
let currentStep = '';
let errorMsg = '';
pipeline.forEach((step, idx) => {
const stepWeight = 100 / totalSteps;
if (step.phase === 'Completed') {
overallProgress += stepWeight;
} else {
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
overallProgress += (stepPct / 100) * stepWeight;
if (!currentStep) {
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
}
if (step.error && !errorMsg) {
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
}
if (step.phase === 'Failed' && !errorMsg) {
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
}
});
// Fallback to VM-level error if no step-level error found
if (!errorMsg && vm.error) {
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
}
overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it.
// Skip VMs that already reached 100% (they finished successfully before
// the plan failed) so their bar stays green instead of turning red.
if (!errorMsg && plan.planFailed && overallProgress < 100) {
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
const vmNamespace = vm.namespace || plan.spec?.targetNamespace || plan.metadata?.namespace;
const routeParams = currentRouter().currentRoute?.value?.params || {};
const product = routeParams.product || store.getters['productId'];
const cluster = routeParams.cluster || store.getters['clusterId'];
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
// A VM that individually reached 100% without error should be navigable
// even when the overall plan failed (other VMs in the plan may have
// failed); `overallProgress >= 100 && !errorMsg && targetVm` already
// guarantees this VM migrated successfully.
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planCanceled && !!targetVm && !!product && !!cluster;
const vmDetailLocation = canNavigateToVm ? {
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
params: {
product,
cluster,
resource: HCI.VM,
namespace: targetVm.metadata.namespace,
id: targetVm.metadata.name,
}
} : null;
return {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: overallProgress,
currentStep,
pipelineTooltip: formatPipelineTooltip(pipeline),
errorMsg,
canceled: plan.planCanceled,
vmDetailLocation,
};
});
// If no migration progress but we have VMs in the spec, show them at 0%
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
plan.vmProgress = plan.spec.vms.map((vm) => ({
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: 0,
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
pipelineTooltip: formatPipelineTooltip([]),
errorMsg: '',
canceled: false,
}));
}
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
const durationResult = formatDuration(plan.status?.migration?.started, plan.status?.migration?.completed);
plan.duration = durationResult.display;
plan.durationSeconds = durationResult.seconds;
return plan;
});
});
const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
}));
const headers = [
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
{
...NAME_COL,
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
},
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
{
name: 'progress',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
value: 'progress',
width: 450,
},
{
name: 'mappings',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
},
{
name: 'duration',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
value: 'duration',
sort: 'durationSeconds',
width: 90,
},
{ ...AGE },
];
const init = async() => {
try {
// Guard: the Forklift dashboard is only available when the Forklift addon is
// enabled. Redirect back to the Harvester dashboard otherwise (e.g. direct URL).
let forkliftEnabled = false;
if (store.getters[`${ inStore.value }/schemaFor`](HCI.ADD_ONS)) {
const addons = await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.ADD_ONS });
forkliftEnabled = addons.find((a) => a.metadata?.name === ADD_ONS.FORKLIFT_OPERATOR)?.spec?.enabled === true;
}
if (!forkliftEnabled) {
await currentRouter().replace({
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.DASHBOARD,
},
});
return;
}
await Promise.all([
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
// VMs are only needed to conditionally show the post-migration VM-detail
// link, so load them separately and non-fatally: a failure here (or the
// cost of findAll on large clusters) should not block plans/maps from
// rendering.
try {
await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.VM });
} catch (e) {
// Intentionally ignored: the dashboard still works without the VM list,
// the migrated VM-detail link simply won't be shown.
}
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally {
loading.value = false;
}
};
init();
</script>
<template>
<Loading v-if="loading" />
<div v-else>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
>
<template #subHeader>
<div class="mmt-5">
<p
v-clean-html="t('harvester.addons.vmMigration.dashboard.description')"
class="text-muted"
></p>
</div>
</template>
<template #createButton>
<router-link
:to="createLocation"
class="btn role-primary"
>
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
</router-link>
</template>
</Masthead>
<ResourceTable
:schema="schema"
:rows="rows"
:headers="headers"
:groupable="false"
:table-actions="false"
:search="false"
key-field="_key"
>
<template #cell:name="{ row }">
<div class="plan-name-cell">
<router-link
v-if="row.detailLocation"
:to="row.detailLocation"
class="plan-name"
>
{{ row.metadata.name }}
</router-link>
<div
v-else
class="plan-name"
>
{{ row.metadata.name }}
</div>
</div>
</template>
<template #cell:vmCount="{ row }">
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
</template>
<template #cell:progress="{ row }">
<div
v-if="row.vmProgress && row.vmProgress.length"
class="progress-cells"
>
<div
v-for="vm in row.vmProgress"
:key="vm.vmId"
class="vm-progress"
>
<div class="vm-progress-header">
<div class="vm-name-block">
<span class="vm-name">
{{ vm.vmName }}
<router-link
v-if="vm.vmDetailLocation"
v-clean-tooltip="t('harvester.addons.vmMigration.dashboard.progress.checkMigratedVm')"
:to="vm.vmDetailLocation"
class="vm-detail-link"
>
<i class="icon icon-external-link" />
</router-link>
</span>
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
</div>
</div>
<div class="vm-pct-block">
<div
v-clean-tooltip="{ content: vm.pipelineTooltip, html: true }"
class="vm-bar"
>
<PercentageBar
:model-value="vm.progress"
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
preferred-direction="MORE"
/>
</div>
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
</div>
<div
v-if="vm.progress >= 100"
class="step-label text-muted"
>
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
</div>
<div
v-else-if="vm.errorMsg"
class="step-label text-error"
>
{{ vm.errorMsg }}
</div>
<div
v-else-if="vm.canceled"
class="step-label text-muted"
>
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
</div>
<div
v-else-if="vm.currentStep"
class="step-label text-muted"
>
{{ vm.currentStep }}
</div>
</div>
</div>
<span v-if="!row.vmProgress.length">-</span>
</template>
<template #cell:mappings="{ row }">
<MappingsCell
:network-entries="row.networkEntries"
:storage-entries="row.storageEntries"
/>
</template>
<template #cell:duration="{ row }">
<span v-if="row.duration">{{ row.duration }}</span>
<span
v-else
class="text-muted"
>&mdash;</span>
</template>
</ResourceTable>
</div>
</template>
<style lang="scss" scoped>
.plan-name-cell {
.plan-name {
font-weight: 500;
line-height: 20px;
}
.plan-vms {
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
}
.progress-cells {
display: flex;
flex-direction: column;
gap: 16px;
}
.vm-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.vm-name {
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-detail-link {
display: inline-flex;
align-items: center;
}
.vm-name-block {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.vm-id {
font-size: 11px;
}
.vm-pct {
font-size: 14px;
min-width: 40px;
text-align: left;
}
.vm-pct-block {
display: flex;
align-items: center;
gap: 14px;
justify-content: space-between;
}
.vm-bar {
width: 100%;
}
.step-label {
font-size: 13px;
margin-top: 4px;
line-height: 20px;
&.text-error {
color: var(--error);
}
}
.table-title {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,289 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
import { HCI } from '@pkg/harvester/types';
const store = useStore();
const route = currentRoute();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const mappingsStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const mappingsReady = ref(false);
const existingNetworkMap = ref(null);
const existingStorageMap = ref(null);
const initialLoading = ref(true);
const dummyResource = ref({ save: () => Promise.resolve() });
const providerId = route.query?.providerId || null;
const isEditMode = !!providerId;
const stepData = reactive({
provider: {
selectedProvider: isEditMode ? '' : '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
vddkInitImage: '',
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
mappings: {
networkEntries: [],
storageEntries: [],
},
});
const steps = reactive([
{
name: 'configure-provider',
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
ready: false,
},
{
name: 'configure-mappings',
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
ready: false,
},
]);
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch(mappingsReady, (val) => {
steps[1].ready = val;
});
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady`, mark the step ready and advance
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
watch(
() => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => {
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
wizardComponent.value.goToStep(1);
pendingProceed.value = true;
providerStepRef.value.clickTestButton();
}
}
);
watch(providerReady, (val) => {
if (val && pendingProceed.value) {
pendingProceed.value = false;
steps[0].ready = true;
wizardComponent.value.goToStep(2);
}
});
const wizardTitle = computed(() => {
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
// Clear mappings when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
}
});
const providerListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.FORKLIFT_PROVIDER,
}
};
const onFinish = async(buttonCb) => {
try {
await mappingsStepRef.value.saveMappings();
buttonCb(true);
currentRouter().push(providerListLocation);
} catch (err) {
errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(providerListLocation);
};
const init = async() => {
if (!isEditMode) {
initialLoading.value = false;
return;
}
const inStore = store.getters['currentProduct'].inStore;
try {
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: providerId,
opt: { force: true },
});
provider.value = fetchedProvider;
providerName.value = fetchedProvider.metadata.name;
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret;
if (secretRef) {
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
});
const secret = allSecrets.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
stepData.provider.username = decodeSecretValue(secret.data.user);
stepData.provider.password = decodeSecretValue(secret.data.password);
stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
stepData.provider.createdSecret = secret;
}
}
// Fetch existing default maps for this provider
try {
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
existingNetworkMap.value = allNetworkMaps.find(
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
) || null;
existingStorageMap.value = allStorageMaps.find(
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
) || null;
} catch (e) {
// Maps may not exist yet
}
} catch (err) {
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })];
}
initialLoading.value = false;
};
init();
</script>
<template>
<Loading v-if="initialLoading" />
<CruResource
v-else
ref="cruRef"
:resource="dummyResource"
:mode="isEditMode ? 'edit' : 'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
:title="wizardTitle"
finish-mode="finish"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
:create-only="true"
:edit-mode="isEditMode"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
ref="mappingsStepRef"
:provider-name="providerName"
:provider="provider"
:step-data="stepData.mappings"
:use-all-provider-data="true"
:existing-network-map="existingNetworkMap"
:existing-storage-map="existingStorageMap"
@ready="onMappingsReady"
/>
</template>
</CruResource>
</template>

View File

@ -0,0 +1,318 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import CruResource from '@shell/components/CruResource';
import { useI18n } from '@shell/composables/useI18n';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import SelectVmsStep from '@pkg/harvester/components/vm-migration/SelectVmsStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter } from '@pkg/harvester/utils/router';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
const store = useStore();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const reviewStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const selectedVMs = ref([]);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const vmsReady = ref(false);
const vmsLoading = ref(true);
const mappingsReady = ref(false);
const reviewReady = ref(false);
const dummyResource = ref({ save: () => Promise.resolve() });
const stepData = reactive({
provider: {
selectedProvider: '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
vddkInitImage: '',
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
vms: {
discoveredVMs: [],
selectedVMIds: new Set(),
tableRows: [],
},
mappings: {
networkEntries: [],
storageEntries: [],
},
review: { planName: '', targetNamespace: '' },
});
const steps = reactive([
{
name: 'configure-provider',
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
ready: false,
},
{
name: 'select-vms',
label: t('harvester.addons.vmMigration.wizard.steps.selectVms.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.selectVms.description'),
ready: false,
},
{
name: 'configure-mappings',
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
ready: false,
},
{
name: 'review-migration',
label: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label'),
subtext: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description'),
ready: false,
},
]);
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch([vmsReady, vmsLoading], () => {
steps[1].ready = vmsReady.value && !vmsLoading.value;
});
watch(mappingsReady, (val) => {
steps[2].ready = val;
});
watch(reviewReady, (val) => {
steps[3].ready = val;
});
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
// watcher clears `pendingProceed` and the user stays put.
watch(
() => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => {
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
wizardComponent.value.goToStep(1);
pendingProceed.value = true;
providerStepRef.value.clickTestButton();
}
}
);
watch(providerReady, (val) => {
if (val && pendingProceed.value) {
pendingProceed.value = false;
steps[0].ready = true;
wizardComponent.value.goToStep(2);
}
});
watch(providerTesting, (testing) => {
if (!testing && pendingProceed.value && !providerReady.value) {
pendingProceed.value = false;
}
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onVmsComplete = (data) => {
selectedVMs.value = data.selectedVMs;
vmsReady.value = data.selectedVMs.length > 0;
};
const onVmsLoading = (val) => {
vmsLoading.value = val;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
const onReviewReady = (ready) => {
reviewReady.value = ready;
};
const onEditSelection = () => {
wizardComponent.value?.goToStep(2);
};
const onEditMappings = () => {
wizardComponent.value?.goToStep(3);
};
const onRemoveVm = (id) => {
selectedVMs.value = selectedVMs.value.filter((vm) => vm.id !== id);
stepData.vms.selectedVMIds.delete(id);
vmsReady.value = selectedVMs.value.length > 0;
if (!vmsReady.value) {
reviewReady.value = false;
}
};
// Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.vms.discoveredVMs = [];
stepData.vms.selectedVMIds = new Set();
stepData.vms.tableRows = [];
selectedVMs.value = [];
vmsReady.value = false;
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
// Clear mappings and review when VMs are added to the selection. Pure removals
// (e.g. "Remove from Plan" on the review step) keep the existing mappings and plan name.
watch(selectedVMs, (newVal, oldVal) => {
const oldIds = new Set(oldVal.map((v) => v.id));
const added = newVal.some((v) => !oldIds.has(v.id));
if (oldVal.length > 0 && added) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
const migrationListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
};
const onFinish = async(buttonCb) => {
try {
await reviewStepRef.value.startMigration();
buttonCb(true);
currentRouter().push(migrationListLocation);
} catch (err) {
errors.value = exceptionToErrorsArray(err).map((e) => {
if (typeof e === 'string') {
return e;
}
return stringify(e);
});
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(migrationListLocation);
};
</script>
<template>
<CruResource
ref="cruRef"
:resource="dummyResource"
:mode="'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
finish-mode="finish"
finish-button-mode="createAndStart"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #select-vms>
<SelectVmsStep
:provider-name="providerName"
:provider="provider"
:step-data="stepData.vms"
@complete="onVmsComplete"
@loading="onVmsLoading"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
:provider-name="providerName"
:provider="provider"
:selected-vms="selectedVMs"
:step-data="stepData.mappings"
@ready="onMappingsReady"
/>
</template>
<template #review-migration>
<ReviewMigrationStep
ref="reviewStepRef"
:provider-name="providerName"
:provider="provider"
:selected-vms="selectedVMs"
:mapping-entries="stepData.mappings"
:step-data="stepData.review"
@ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/>
</template>
</CruResource>
</template>

View File

@ -0,0 +1,330 @@
<script>
import { defineComponent } from 'vue';
import { mapState, mapGetters } from 'vuex';
import { resourceNames } from '@shell/utils/string';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI } from '../types';
export default defineComponent({
name: 'PromptRemoveForkliftPlanDialog',
emits: ['errors'],
props: {
value: {
type: Array,
default: () => []
},
names: {
type: Array,
default: () => []
},
type: {
type: String,
required: true
},
close: {
type: Function,
required: true
},
doneLocation: {
type: Object,
default: () => {}
}
},
data() {
return {
errors: [],
removing: false,
};
},
computed: {
...mapState('action-menu', ['toRemove']),
...mapGetters({ t: 'i18n/t' }),
existingRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
return {
[HCI.FORKLIFT_MIGRATION]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_MIGRATION) || [],
[HCI.FORKLIFT_NETWORK_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [],
[HCI.FORKLIFT_STORAGE_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [],
};
},
resourceType() {
return this.t('typeLabel."forklift.konveyor.io.plan"', { count: this.names?.length || 1 });
},
relatedSummary() {
const existing = this.existingRelatedResources;
return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || '';
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => resource?.metadata?.name)
.filter(Boolean);
const matchedNetworkMap = networkMapRef ? existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMapRef.name && resource?.metadata?.namespace === networkMapRef.namespace) : null;
const matchedStorageMap = storageMapRef ? existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMapRef.name && resource?.metadata?.namespace === storageMapRef.namespace) : null;
return {
planName,
namespace,
migrations,
networkMap: matchedNetworkMap ? {
namespace: matchedNetworkMap?.metadata?.namespace || networkMapRef?.namespace || namespace,
name: matchedNetworkMap?.metadata?.name,
} : (networkMapRef ? {
namespace: networkMapRef.namespace,
name: networkMapRef.name,
} : null),
storageMap: matchedStorageMap ? {
namespace: matchedStorageMap?.metadata?.namespace || storageMapRef?.namespace || namespace,
name: matchedStorageMap?.metadata?.name,
} : (storageMapRef ? {
namespace: storageMapRef.namespace,
name: storageMapRef.name,
} : null),
};
});
},
},
async mounted() {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
await Promise.all([
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
} catch {
// Best-effort: the dialog can still function without this prefetch.
}
},
methods: {
resourceNames,
getPlanMapRef(plan, mapType) {
const ref = plan?.spec?.map?.[mapType];
const name = ref?.name;
if (!name) {
return null;
}
return {
name,
namespace: ref?.namespace || plan?.metadata?.namespace || '',
};
},
/**
* Determine whether a related resource (migration / network map / storage map)
* is owned by the given plan by inspecting its `metadata.ownerReferences`.
* Matches on `kind: Plan` and the plan name; when both sides expose a uid it
* must match too, to disambiguate same-named plans.
*/
ownedByPlan(resource, plan) {
const planName = plan?.metadata?.name;
const planUid = plan?.metadata?.uid;
const owners = resource?.metadata?.ownerReferences || [];
if (!planName) {
return false;
}
return owners.some((owner) => owner?.kind === 'Plan' &&
owner?.name === planName &&
(!planUid || !owner?.uid || owner.uid === planUid));
},
buildDeleteTargets() {
const existing = this.existingRelatedResources;
const targets = new Map();
const addTarget = (type, name, namespace) => {
const ns = namespace || '';
if (!name) {
return;
}
const key = `${ type }|${ ns }|${ name }`;
targets.set(key, {
type, name, namespace: ns
});
};
for (const plan of this.value || []) {
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
if (networkMapRef) {
addTarget(HCI.FORKLIFT_NETWORK_MAP, networkMapRef.name, networkMapRef.namespace);
}
if (storageMapRef) {
addTarget(HCI.FORKLIFT_STORAGE_MAP, storageMapRef.name, storageMapRef.namespace);
}
existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource?.metadata?.name, resource?.metadata?.namespace));
}
return [...targets.values()];
},
async deleteRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
const targets = this.buildDeleteTargets();
if (!targets.length) {
return;
}
let cache = {};
try {
cache = {
[HCI.FORKLIFT_MIGRATION]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
[HCI.FORKLIFT_NETWORK_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
[HCI.FORKLIFT_STORAGE_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
};
} catch (err) {
// Best-effort cleanup; don't block closing the dialog if listing related resources fails.
return;
}
for (const target of targets) {
const matched = (cache[target.type] || []).find((resource) => resource?.metadata?.name === target.name && resource?.metadata?.namespace === target.namespace
);
if (!matched) {
continue;
}
try {
await matched.remove({ params: { propagationPolicy: 'Background' } });
} catch (err) {
// Continue deleting remaining resources and let final plan deletion fail if needed.
}
}
},
async remove(buttonDone) {
this.errors = [];
this.removing = true;
try {
const results = await Promise.allSettled((this.value || []).map((resource) => resource.remove({ params: { propagationPolicy: 'Background' } })));
await this.deleteRelatedResources();
const rejected = results.filter((r) => r.status === 'rejected');
if (rejected.length) {
throw rejected[0].reason;
}
this.close(buttonDone);
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.$emit('errors', err);
if (buttonDone) {
buttonDone(false);
}
} finally {
this.removing = false;
}
},
}
});
</script>
<template>
<div class="mt-10">
<div class="mb-10">
{{ t('promptRemove.attemptingToRemove', { type: resourceType }) }}
<span v-clean-html="resourceNames(names, null, t)" />
</div>
<hr class="mb-10">
<div class="mb-10 text-warning">
<i class="icon icon-warning icon-lg mr-5" />
{{ t('harvester.addons.vmMigration.deleteDialog.warning') }}
</div>
<div
v-for="summary in relatedSummary"
:key="`${ summary.namespace }/${ summary.planName }`"
class="mb-10"
>
<div> {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
<template v-if="summary.migrations.length">
<div
v-for="migrationName in summary.migrations"
:key="migrationName"
>
<div class="ml-20">
- {{ migrationName }}
</div>
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div> {{ t('harvester.addons.vmMigration.labels.networkMap') }}:</div>
<template v-if="summary.networkMap">
<div class="ml-20">
- {{ summary.networkMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div> {{ t('harvester.addons.vmMigration.labels.storageMap') }}:</div>
<template v-if="summary.storageMap">
<div class="ml-20">
- {{ summary.storageMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
</div>
<Banner
v-for="(error, i) in errors"
:key="i"
color="error"
:label="error"
/>
</div>
</template>

View File

@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue'; import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue'; import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue'; import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue';
const routes = [ const routes = [
{ {
@ -83,6 +86,18 @@ const routes = [
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`, name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
path: `/:product/c/:cluster/projectsnamespaces`, path: `/:product/c/:cluster/projectsnamespaces`,
component: ProjectNamespaces, component: ProjectNamespaces,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
path: `/:product/c/:cluster/vm-migration`,
component: ForkliftDashboard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
path: `/:product/c/:cluster/vm-migration/wizard`,
component: ForkliftVmMigrationWizard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
path: `/:product/c/:cluster/vm-migration/provider-wizard`,
component: ForkliftProviderWizard,
}, { }, {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`, path: `/:product/c/:cluster/:resource`,

View File

@ -5,77 +5,82 @@
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group. // 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
export const HCI = { export const HCI = {
VM: 'kubevirt.io.virtualmachine', VM: 'kubevirt.io.virtualmachine',
VMI: 'kubevirt.io.virtualmachineinstance', VMI: 'kubevirt.io.virtualmachineinstance',
VMIM: 'kubevirt.io.virtualmachineinstancemigration', VMIM: 'kubevirt.io.virtualmachineinstancemigration',
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate', VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion', VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
IMAGE: 'harvesterhci.io.virtualmachineimage', IMAGE: 'harvesterhci.io.virtualmachineimage',
SSH: 'harvesterhci.io.keypair', SSH: 'harvesterhci.io.keypair',
VOLUME: 'harvesterhci.io.volume', VOLUME: 'harvesterhci.io.volume',
USER: 'harvesterhci.io.user', USER: 'harvesterhci.io.user',
SETTING: 'harvesterhci.io.setting', SETTING: 'harvesterhci.io.setting',
UPGRADE: 'harvesterhci.io.upgrade', UPGRADE: 'harvesterhci.io.upgrade',
UPGRADE_LOG: 'harvesterhci.io.upgradelog', UPGRADE_LOG: 'harvesterhci.io.upgradelog',
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup', SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
BACKUP: 'harvesterhci.io.virtualmachinebackup', BACKUP: 'harvesterhci.io.virtualmachinebackup',
RESTORE: 'harvesterhci.io.virtualmachinerestore', RESTORE: 'harvesterhci.io.virtualmachinerestore',
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork', NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork', CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig', HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
SUBNET: 'kubeovn.io.subnet', SUBNET: 'kubeovn.io.subnet',
VPC: 'kubeovn.io.vpc', VPC: 'kubeovn.io.vpc',
IP: 'kubeovn.io.ip', IP: 'kubeovn.io.ip',
VLAN: 'kubeovn.io.vlan', VLAN: 'kubeovn.io.vlan',
IPTABLES_EIP: 'kubeovn.io.iptableseip', IPTABLES_EIP: 'kubeovn.io.iptableseip',
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule', IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule', IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
PROVIDER_NETWORK: 'kubeovn.io.providernetwork', PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway', VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader', VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle', SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition', NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
CLUSTER: 'harvesterhci.io.management.cluster', CLUSTER: 'harvesterhci.io.management.cluster',
DASHBOARD: 'harvesterhci.io.dashboard', DASHBOARD: 'harvesterhci.io.dashboard',
BLOCK_DEVICE: 'harvesterhci.io.blockdevice', BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate', CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
HOST: 'harvesterhci.io.host', HOST: 'harvesterhci.io.host',
VERSION: 'harvesterhci.io.version', VERSION: 'harvesterhci.io.version',
SNAPSHOT: 'harvesterhci.io.volumesnapshot', SNAPSHOT: 'harvesterhci.io.volumesnapshot',
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot', VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig', ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow', CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput', CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
FLOW: 'harvesterhci.io.logging.flow', FLOW: 'harvesterhci.io.logging.flow',
OUTPUT: 'harvesterhci.io.logging.output', OUTPUT: 'harvesterhci.io.logging.output',
STORAGE: 'harvesterhci.io.storage', STORAGE: 'harvesterhci.io.storage',
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota', RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
KSTUNED: 'node.harvesterhci.io.ksmtuned', KSTUNED: 'node.harvesterhci.io.ksmtuned',
HUGEPAGES: 'node.harvesterhci.io.hugepage', HUGEPAGES: 'node.harvesterhci.io.hugepage',
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice', PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim', PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice', SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice', VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice', SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
USB_DEVICE: 'devices.harvesterhci.io.usbdevice', USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim', USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration', MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig', VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus', VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
ADD_ONS: 'harvesterhci.io.addon', ADD_ONS: 'harvesterhci.io.addon',
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor', LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
SECRET: 'harvesterhci.io.secret', SECRET: 'harvesterhci.io.secret',
INVENTORY: 'metal.harvesterhci.io.inventory', INVENTORY: 'metal.harvesterhci.io.inventory',
LB: 'loadbalancer.harvesterhci.io.loadbalancer', LB: 'loadbalancer.harvesterhci.io.loadbalancer',
IP_POOL: 'loadbalancer.harvesterhci.io.ippool', IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig', HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup', LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource', VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource', VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource', VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport', VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
MIGRATION: 'migration.harvesterhci.io', MIGRATION: 'migration.harvesterhci.io',
// Forklift CRDs
FORKLIFT_PROVIDER: 'forklift.konveyor.io.provider',
FORKLIFT_NETWORK_MAP: 'forklift.konveyor.io.networkmap',
FORKLIFT_STORAGE_MAP: 'forklift.konveyor.io.storagemap',
FORKLIFT_PLAN: 'forklift.konveyor.io.plan',
FORKLIFT_MIGRATION: 'forklift.konveyor.io.migration',
}; };
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot'; export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';

View File

@ -0,0 +1,43 @@
export const DEFAULT_FS_FREEZE_DEADLINE = '1s';
/**
* Generate filesystem freeze deadline options with i18n translations
* @param {Function} t - i18n translation function
* @returns {Array} Array of deadline options
*/
export function getFsFreezeDeadlineOptions(t) {
return [
{
label: t('generic.duration.infinite'),
value: '0s'
},
{
label: t('generic.duration.1s'),
value: '1s'
},
{
label: t('generic.duration.5s'),
value: '5s'
},
{
label: t('generic.duration.10s'),
value: '10s'
},
{
label: t('generic.duration.30s'),
value: '30s'
},
{
label: t('generic.duration.1m'),
value: '1m'
},
{
label: t('generic.duration.3m'),
value: '3m'
},
{
label: t('generic.duration.5m'),
value: '5m'
}
];
}

View File

@ -8,9 +8,11 @@
* @param {String} config.resourceType - The schema ID for addons. * @param {String} config.resourceType - The schema ID for addons.
* @param {String} config.navGroup - The group name in the side nav. * @param {String} config.navGroup - The group name in the side nav.
* @param {Array<String>} config.types - Array of Resource IDs to show/hide. * @param {Array<String>} config.types - Array of Resource IDs to show/hide.
* @param {Boolean} [config.requireSchema=true] - When true, only types with an
* accessible schema are shown. Set false for schema-less virtual types.
*/ */
export function registerAddonSideNav(store, productName, { export function registerAddonSideNav(store, productName, {
addonName, resourceType, navGroup, types addonName, resourceType, navGroup, types, requireSchema = true
}) { }) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@ -22,13 +24,14 @@ export function registerAddonSideNav(store, productName, {
const kickSideNav = () => { const kickSideNav = () => {
const TRIGGER = 'ui.refresh.trigger'; const TRIGGER = 'ui.refresh.trigger';
store.dispatch('type-map/addFavorite', TRIGGER); // 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.
// SideNav component seem to ignore rapid state changes. [0, 600, 1500].forEach((delay) => {
// Wait 600ms to ensure the toggle event triggers a re-render. setTimeout(() => {
setTimeout(() => { store.dispatch('type-map/addFavorite', TRIGGER);
store.dispatch('type-map/removeFavorite', TRIGGER); setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
}, 600); }, delay);
});
}; };
const hasAccessibleSchema = (t) => { const hasAccessibleSchema = (t) => {
@ -57,7 +60,7 @@ export function registerAddonSideNav(store, productName, {
// Adds or removes the resource IDs from the product visibility whitelist. // Adds or removes the resource IDs from the product visibility whitelist.
const setMenuVisibility = (visible) => { const setMenuVisibility = (visible) => {
const accessibleTypes = visible ? types.filter(hasAccessibleSchema) : []; const accessibleTypes = visible ? (requireSchema ? types.filter(hasAccessibleSchema) : types) : [];
// Always clear first to remove any previously-registered types that are // Always clear first to remove any previously-registered types that are
// no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped). // no longer accessible (e.g. partial permission changes like types=[A,B] where B is dropped).
@ -90,16 +93,25 @@ export function registerAddonSideNav(store, productName, {
// Store is ready. Stop polling. // Store is ready. Stop polling.
clearInterval(waitForStore); clearInterval(waitForStore);
// Watch the specific addon resource for changes to its enabled status. // Watch the addon's enabled status together with the schema availability
// of the gated types. Schemas (e.g. forklift CRDs) can load after the
// addon is already enabled, so the watcher must also re-run when they
// become accessible; otherwise the menu never updates until a refresh.
store.watch( store.watch(
(state, getters) => { (state, getters) => {
const addons = getters[`${ productName }/all`](resourceType); const addons = getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName); const addon = addons.find((a) => a.metadata.name === addonName);
const isEnabled = addon?.spec?.enabled === true;
return addon?.spec?.enabled === true; const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true;
return `${ isEnabled }:${ schemaReady }`;
}, },
(isEnabled) => { () => {
setMenuVisibility(isEnabled); const addons = store.getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName);
setMenuVisibility(addon?.spec?.enabled === true);
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
); );

View File

@ -0,0 +1,93 @@
// Shared helpers for the Forklift VM-migration feature.
// Keep the map-spec builders here so the wizard step and the review step
// (and any future edit flow) stay in sync instead of drifting apart.
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
/**
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
*/
export function decodeSecretValue(val) {
try {
return atob(val || '');
} catch (e) {
return '';
}
}
/**
* Bytes whole GiB (rounded). Returns 0 for falsy input.
*/
export function bytesToGB(bytes) {
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
}
/**
* MiB whole GiB (rounded). Returns 0 for falsy input.
*/
export function mbToGB(mb) {
return Math.round((mb || 0) / 1024);
}
/**
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
* Entries without a chosen target are dropped so we never emit an invalid
* multus destination with an empty name.
*
* @param {Array} entries network mapping entries ({ name, id, target })
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
*/
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => {
const source = { name: entry.name, id: entry.id };
if (entry.target === 'pod') {
return { source, destination: { type: 'pod' } };
}
if (entry.target === 'ignored') {
return { source, destination: { type: 'ignored' } };
}
const parts = entry.target.split('/');
const name = parts.length > 1 ? parts[1] : parts[0];
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
return {
source,
destination: {
type: 'multus', name, namespace
},
};
});
}
/**
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
* Entries without a chosen target are dropped.
*
* @param {Array} entries storage mapping entries ({ name, id, target })
*/
export function buildStorageMapEntries(entries = []) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => {
const destination = { storageClass: entry.target };
if (entry.volumeMode) {
destination.volumeMode = entry.volumeMode;
}
// Forklift StorageMap destination expects a single `accessMode` value.
if (Array.isArray(entry.accessModes) && entry.accessModes.length) {
destination.accessMode = entry.accessModes[0];
}
return {
source: { name: entry.name, id: entry.id },
destination,
};
});
}

View File

@ -0,0 +1,11 @@
// Mirrors Resource class methods from @shell/plugins/dashboard-store/resource-class.js
// useRouter()/useRoute() from vue-router don't work in extension mode,
// so we use the same global access pattern the shell uses internally.
export function currentRouter() {
return window.$globalApp.$router;
}
export function currentRoute() {
return window.$globalApp.$route;
}

View File

@ -69,17 +69,11 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType); validName(getters, errors, D.name, diskNames, prefix, type, lowerType, upperType);
}); });
let hasBootableVolume = false;
_volumes.forEach((V, idx) => { _volumes.forEach((V, idx) => {
const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value); const { type, typeValue } = getVolumeType(getters, V, _volumeClaimTemplates, value);
const prefix = V.name || idx + 1; const prefix = V.name || idx + 1;
if ([SOURCE_TYPE.IMAGE, SOURCE_TYPE.ATTACH_VOLUME, SOURCE_TYPE.CONTAINER].includes(type)) {
hasBootableVolume = true;
}
if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) { if (type === SOURCE_TYPE.NEW || type === SOURCE_TYPE.IMAGE) {
if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) { if (!/([1-9]|[1-9][0-9]+)[a-zA-Z]+/.test(typeValue?.spec?.resources?.requests?.storage)) {
const key = getters['i18n/t']('harvester.fields.size'); const key = getters['i18n/t']('harvester.fields.size');
@ -136,13 +130,6 @@ export function vmDisks(spec, getters, errors, validatorArgs, displayKey, value)
} }
}); });
/**
* At least one bootable volume must be provided. (Verify only when create.)
*/
if (!hasBootableVolume && !value.links) {
errors.push(getters['i18n/t']('harvester.validation.vm.volume.needAtLeastOneBootable'));
}
return errors; return errors;
} }

View File

@ -7,7 +7,11 @@ if (process.argv.length < 3) {
} }
const prTitle = process.argv[2]; const prTitle = process.argv[2];
const prLabel = prTitle.split(':')[0].trim();
// Parse the conventional-commit prefix `type(scope)!:`, keeping only the type
// and dropping an optional scope and breaking-change marker.
const prefixMatch = prTitle.match(/^\s*([a-zA-Z]+)(?:\([^)]*\))?!?:/);
const prLabel = prefixMatch ? prefixMatch[1].toLowerCase() : '';
const config = await load({ extends: ["./commitlint.config.js"] }); const config = await load({ extends: ["./commitlint.config.js"] });
const commitPrefix = config?.rules?.['type-enum']?.[2] || []; const commitPrefix = config?.rules?.['type-enum']?.[2] || [];

315
yarn.lock
View File

@ -2181,15 +2181,15 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@rancher/icons@2.0.60": "@rancher/icons@2.0.62":
version "2.0.60" version "2.0.62"
resolved "https://registry.yarnpkg.com/@rancher/icons/-/icons-2.0.60.tgz#a7d8bd39a007ef8ecf1cb7bfe27d82f64f9cd75c" resolved "https://registry.yarnpkg.com/@rancher/icons/-/icons-2.0.62.tgz#42d3aa89d03a2e0e551332e99c8102dfdd422746"
integrity sha512-5bbXVOc4Uk2OmACX67i1e1jOTeLg87T22u+alhwEZX+/WzBEH/B0DPVqSbuYQtKYkH0Me4ojnOVCzZ2HuuLHWQ== integrity sha512-ywGk1NSrIdN1zllpbDRU3ESVW/D0ExRZFBz+YPGAo9G08VzWxKIcT5GG5Nd2GXLTcUestlq1JwkKDaVAM8hkmQ==
"@rancher/shell@3.0.12-rc.3": "@rancher/shell@3.0.12-rc.7":
version "3.0.12-rc.3" version "3.0.12-rc.7"
resolved "https://registry.yarnpkg.com/@rancher/shell/-/shell-3.0.12-rc.3.tgz#566ea39fac704fbed2e0bc070f47b5bb3b6a92fb" resolved "https://registry.yarnpkg.com/@rancher/shell/-/shell-3.0.12-rc.7.tgz#509b42d1a40fc8f399dc8c15fd07f2ea4361af54"
integrity sha512-4mKX3Ou2t/P8ko5Tb8UuuxgttgQPPX66Rlq8vC6IftGowJ+ExtM6FJDg4bKy1Lu3DrDERHoQLmTHehDScRnSEw== integrity sha512-aA2uX5roVhJtj8p09OQNREhFraa9MqSrjXjbAC+3uRLniQkhqx+INKpyBDvhkDy0wd7XWIGbX3cfl+Kz6mwSYA==
dependencies: dependencies:
"@aws-sdk/client-ec2" "3.1041.0" "@aws-sdk/client-ec2" "3.1041.0"
"@aws-sdk/client-eks" "3.1041.0" "@aws-sdk/client-eks" "3.1041.0"
@ -2201,21 +2201,28 @@
"@babel/preset-typescript" "7.16.7" "@babel/preset-typescript" "7.16.7"
"@novnc/novnc" "1.2.0" "@novnc/novnc" "1.2.0"
"@popperjs/core" "2.11.8" "@popperjs/core" "2.11.8"
"@rancher/icons" "2.0.60" "@rancher/icons" "2.0.62"
"@smithy/fetch-http-handler" "5.1.1" "@smithy/fetch-http-handler" "5.1.1"
"@types/is-url" "1.2.30" "@types/is-url" "1.2.30"
"@types/node" "25.3.3" "@types/node" "25.3.3"
"@types/semver" "^7.5.8" "@types/semver" "^7.5.8"
"@typescript-eslint/eslint-plugin" "5.62.0" "@typescript-eslint/eslint-plugin" "5.62.0"
"@typescript-eslint/parser" "5.62.0" "@typescript-eslint/parser" "5.62.0"
"@vee-validate/zod" "4.15.0"
"@vue/cli-plugin-babel" "~5.0.0" "@vue/cli-plugin-babel" "~5.0.0"
"@vue/cli-plugin-typescript" "~5.0.0" "@vue/cli-plugin-typescript" "~5.0.0"
"@vue/cli-service" "5.0.8" "@vue/cli-service" "5.0.8"
"@vue/test-utils" "2.4.6" "@vue/test-utils" "2.4.6"
"@vue/vue3-jest" "27.0.0" "@vue/vue3-jest" "27.0.0"
"@xterm/addon-canvas" "0.7.0"
"@xterm/addon-fit" "0.11.0"
"@xterm/addon-search" "0.16.0"
"@xterm/addon-web-links" "0.12.0"
"@xterm/addon-webgl" "0.19.0"
"@xterm/xterm" "6.0.0"
add "2.0.6" add "2.0.6"
ansi_up "5.0.0" ansi_up "5.0.0"
axios "1.15.2" axios "1.16.0"
axios-retry "3.1.9" axios-retry "3.1.9"
babel-eslint "10.1.0" babel-eslint "10.1.0"
babel-preset-vue "2.0.2" babel-preset-vue "2.0.2"
@ -2248,14 +2255,13 @@
eslint-plugin-n "15.2.0" eslint-plugin-n "15.2.0"
eslint-plugin-vue "9.32.0" eslint-plugin-vue "9.32.0"
event-target-shim "5.0.1" event-target-shim "5.0.1"
express "4.17.1" express "4.22.2"
file-saver "2.0.2" file-saver "2.0.5"
floating-vue "5.2.2" floating-vue "5.2.2"
focus-trap "7.6.5" focus-trap "7.6.5"
frontmatter-markdown-loader "3.7.0" frontmatter-markdown-loader "3.7.0"
identicon.js "2.3.3" identicon.js "2.3.3"
intl-messageformat "7.8.4" intl-messageformat "7.8.4"
ip "2.0.1"
is-url "1.2.4" is-url "1.2.4"
jest "27.5.1" jest "27.5.1"
jest-serializer-vue "2.0.2" jest-serializer-vue "2.0.2"
@ -2292,13 +2298,8 @@
webpack-virtual-modules "0.6.2" webpack-virtual-modules "0.6.2"
worker-loader "3.0.8" worker-loader "3.0.8"
xmlbuilder2 "4.0.1" xmlbuilder2 "4.0.1"
xterm "5.2.1"
xterm-addon-canvas "0.5.0"
xterm-addon-fit "0.8.0"
xterm-addon-search "0.13.0"
xterm-addon-web-links "0.9.0"
xterm-addon-webgl "0.16.0"
yarn "1.22.22" yarn "1.22.22"
zod "3.24.3"
"@rtsao/scc@^1.1.0": "@rtsao/scc@^1.1.0":
version "1.1.0" version "1.1.0"
@ -2824,10 +2825,10 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash@4.17.24": "@types/lodash@4.17.25":
version "4.17.24" version "4.17.25"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.24.tgz#4ae334fc62c0e915ca8ed8e35dcc6d4eeb29215f" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.25.tgz#69765ac7bcddb0eb072961cf292524a8f5b3c2c0"
integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ== integrity sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==
"@types/mime@^1": "@types/mime@^1":
version "1.3.5" version "1.3.5"
@ -2846,10 +2847,10 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/node@*", "@types/node@25.3.3", "@types/node@25.9.4": "@types/node@*", "@types/node@25.3.3", "@types/node@25.9.5":
version "25.9.4" version "25.9.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.4.tgz#18b63c47f88c1fbbed9d55ea2b66ffd494a47001" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.5.tgz#0fefc09e6e82e94cde291bacf43522e989eb01a4"
integrity sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g== integrity sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==
dependencies: dependencies:
undici-types ">=7.24.0 <7.24.7" undici-types ">=7.24.0 <7.24.7"
@ -3098,6 +3099,14 @@
"@typescript-eslint/types" "5.62.0" "@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0" eslint-visitor-keys "^3.3.0"
"@vee-validate/zod@4.15.0":
version "4.15.0"
resolved "https://registry.yarnpkg.com/@vee-validate/zod/-/zod-4.15.0.tgz#2de193da114eda9f873b5a677f13c23bcecb58aa"
integrity sha512-MpvIKiyg9X5yD8bJW0no2AU7wtR2T5mrvD9tuPRiie951sU2n6QKgMV38qKKOiqFBCxsMSjIuLLLV3V5kVE4nQ==
dependencies:
type-fest "^4.8.3"
vee-validate "4.15.0"
"@vue-flow/background@^1.3.0": "@vue-flow/background@^1.3.0":
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/@vue-flow/background/-/background-1.3.2.tgz#0c90cd05e5d60da017bbaf5a1c3eb6af7ed9b778" resolved "https://registry.yarnpkg.com/@vue-flow/background/-/background-1.3.2.tgz#0c90cd05e5d60da017bbaf5a1c3eb6af7ed9b778"
@ -3690,6 +3699,36 @@
"@webassemblyjs/ast" "1.14.1" "@webassemblyjs/ast" "1.14.1"
"@xtuc/long" "4.2.2" "@xtuc/long" "4.2.2"
"@xterm/addon-canvas@0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz#d3a3835f3634e0106e108661315ae14da23e8dd7"
integrity sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==
"@xterm/addon-fit@0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz#ba4778b69fcc9044a060c2176bbe077657d7b37e"
integrity sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==
"@xterm/addon-search@0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@xterm/addon-search/-/addon-search-0.16.0.tgz#ddadcbc6e556e45fc4b153f8a0efdf2aa1456b82"
integrity sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==
"@xterm/addon-web-links@0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz#bc34ae46f4c12012256d451ac2b9be791c0b6bfa"
integrity sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==
"@xterm/addon-webgl@0.19.0":
version "0.19.0"
resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz#02533c3f7c0af3ac9a44bbb095cbd47d48e90c25"
integrity sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==
"@xterm/xterm@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-6.0.0.tgz#93637b0f2ee3a70718b5746a27c9c506af16745b"
integrity sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==
"@xtuc/ieee754@^1.2.0": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -3730,7 +3769,7 @@ abort-controller@^3.0.0:
dependencies: dependencies:
event-target-shim "^5.0.0" event-target-shim "^5.0.0"
accepts@~1.3.7, accepts@~1.3.8: accepts@~1.3.8:
version "1.3.8" version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
@ -4093,12 +4132,12 @@ axios-retry@3.1.9:
dependencies: dependencies:
is-retry-allowed "^1.1.0" is-retry-allowed "^1.1.0"
axios@1.15.2: axios@1.16.0:
version "1.15.2" version "1.16.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.0.tgz#f8e5dd931cef2a5f8c32216d5784eda2f8750eb7"
integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== integrity sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==
dependencies: dependencies:
follow-redirects "^1.15.11" follow-redirects "^1.16.0"
form-data "^4.0.5" form-data "^4.0.5"
proxy-from-env "^2.1.0" proxy-from-env "^2.1.0"
@ -4328,26 +4367,10 @@ bn.js@^5.2.1, bn.js@^5.2.2:
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.3.tgz#16a9e409616b23fef3ccbedb8d42f13bff80295e"
integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w== integrity sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==
body-parser@1.19.0: body-parser@~1.20.5:
version "1.19.0" version "1.20.6"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.6.tgz#60c789c78e0992d906da0a29d71ae01d15c1ed76"
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== integrity sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==
dependencies:
bytes "3.1.0"
content-type "~1.0.4"
debug "2.6.9"
depd "~1.1.2"
http-errors "1.7.2"
iconv-lite "0.4.24"
on-finished "~2.3.0"
qs "6.7.0"
raw-body "2.4.0"
type-is "~1.6.17"
body-parser@~1.20.3:
version "1.20.4"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f"
integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==
dependencies: dependencies:
bytes "~3.1.2" bytes "~3.1.2"
content-type "~1.0.5" content-type "~1.0.5"
@ -4357,7 +4380,7 @@ body-parser@~1.20.3:
http-errors "~2.0.1" http-errors "~2.0.1"
iconv-lite "~0.4.24" iconv-lite "~0.4.24"
on-finished "~2.4.1" on-finished "~2.4.1"
qs "~6.14.0" qs "~6.15.1"
raw-body "~2.5.3" raw-body "~2.5.3"
type-is "~1.6.18" type-is "~1.6.18"
unpipe "~1.0.0" unpipe "~1.0.0"
@ -4535,11 +4558,6 @@ builtins@^4.0.0:
dependencies: dependencies:
semver "^7.0.0" semver "^7.0.0"
bytes@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
bytes@3.1.2, bytes@~3.1.2: bytes@3.1.2, bytes@~3.1.2:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@ -5026,13 +5044,6 @@ constants-browserify@^1.0.0:
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ== integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==
content-disposition@0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
dependencies:
safe-buffer "5.1.2"
content-disposition@~0.5.4: content-disposition@~0.5.4:
version "0.5.4" version "0.5.4"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@ -5079,7 +5090,7 @@ convert-source-map@^2.0.0:
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
cookie-signature@1.0.6, cookie-signature@~1.0.6: cookie-signature@~1.0.6:
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
@ -5092,7 +5103,7 @@ cookie-universal@2.2.2:
"@types/cookie" "^0.3.3" "@types/cookie" "^0.3.3"
cookie "^0.4.0" cookie "^0.4.0"
cookie@0.4.0, cookie@^0.4.0: cookie@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
@ -6656,50 +6667,14 @@ expect@^27.5.1:
jest-matcher-utils "^27.5.1" jest-matcher-utils "^27.5.1"
jest-message-util "^27.5.1" jest-message-util "^27.5.1"
express@4.17.1: express@4.22.2, express@^4.17.3:
version "4.17.1" version "4.22.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" resolved "https://registry.yarnpkg.com/express/-/express-4.22.2.tgz#c17ae0981e5efc24b22272f0e041c4662503b700"
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==
dependencies:
accepts "~1.3.7"
array-flatten "1.1.1"
body-parser "1.19.0"
content-disposition "0.5.3"
content-type "~1.0.4"
cookie "0.4.0"
cookie-signature "1.0.6"
debug "2.6.9"
depd "~1.1.2"
encodeurl "~1.0.2"
escape-html "~1.0.3"
etag "~1.8.1"
finalhandler "~1.1.2"
fresh "0.5.2"
merge-descriptors "1.0.1"
methods "~1.1.2"
on-finished "~2.3.0"
parseurl "~1.3.3"
path-to-regexp "0.1.7"
proxy-addr "~2.0.5"
qs "6.7.0"
range-parser "~1.2.1"
safe-buffer "5.1.2"
send "0.17.1"
serve-static "1.14.1"
setprototypeof "1.1.1"
statuses "~1.5.0"
type-is "~1.6.18"
utils-merge "1.0.1"
vary "~1.1.2"
express@^4.17.3:
version "4.22.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069"
integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==
dependencies: dependencies:
accepts "~1.3.8" accepts "~1.3.8"
array-flatten "1.1.1" array-flatten "1.1.1"
body-parser "~1.20.3" body-parser "~1.20.5"
content-disposition "~0.5.4" content-disposition "~0.5.4"
content-type "~1.0.4" content-type "~1.0.4"
cookie "~0.7.1" cookie "~0.7.1"
@ -6718,7 +6693,7 @@ express@^4.17.3:
parseurl "~1.3.3" parseurl "~1.3.3"
path-to-regexp "~0.1.12" path-to-regexp "~0.1.12"
proxy-addr "~2.0.7" proxy-addr "~2.0.7"
qs "~6.14.0" qs "~6.15.1"
range-parser "~1.2.1" range-parser "~1.2.1"
safe-buffer "5.2.1" safe-buffer "5.2.1"
send "~0.19.0" send "~0.19.0"
@ -6825,10 +6800,10 @@ file-entry-cache@^6.0.1:
dependencies: dependencies:
flat-cache "^3.0.4" flat-cache "^3.0.4"
file-saver@2.0.2: file-saver@2.0.5:
version "2.0.2" version "2.0.5"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.2.tgz#06d6e728a9ea2df2cce2f8d9e84dfcdc338ec17a" resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
integrity sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw== integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
filelist@^1.0.4: filelist@^1.0.4:
version "1.0.6" version "1.0.6"
@ -6844,19 +6819,6 @@ fill-range@^7.1.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
finalhandler@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
dependencies:
debug "2.6.9"
encodeurl "~1.0.2"
escape-html "~1.0.3"
on-finished "~2.3.0"
parseurl "~1.3.3"
statuses "~1.5.0"
unpipe "~1.0.0"
finalhandler@~1.3.1: finalhandler@~1.3.1:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88"
@ -6937,7 +6899,7 @@ focus-trap@7.6.5:
dependencies: dependencies:
tabbable "^6.2.0" tabbable "^6.2.0"
follow-redirects@1.16.0, follow-redirects@^1.0.0, follow-redirects@^1.15.11: follow-redirects@1.16.0, follow-redirects@^1.0.0, follow-redirects@^1.16.0:
version "1.16.0" version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
@ -7433,9 +7395,9 @@ html-tags@^2.0.0:
integrity sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g== integrity sha512-+Il6N8cCo2wB/Vd3gqy/8TZhTD3QvcVeQLCnZiGkGCH3JP28IgGAY41giccp2W4R3jfyJPAP318FQTa1yU7K7g==
html-webpack-plugin@^5.0.0, html-webpack-plugin@^5.1.0: html-webpack-plugin@^5.0.0, html-webpack-plugin@^5.1.0:
version "5.6.7" version "5.6.8"
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz#429bab4e12abf3c07e1c608886608e2df2c06b11" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz#5425dbc63eb533ffa59e92342be94894a125fc23"
integrity sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw== integrity sha512-MZmKQcTnhEh1SPSyMiEytIeDZDUoBZVorNHivQGXMASHf/BSGGOrKa2xQ5bGx3TCe1n109ecCt+cpww7wwWhKA==
dependencies: dependencies:
"@types/html-minifier-terser" "^6.0.0" "@types/html-minifier-terser" "^6.0.0"
html-minifier-terser "^6.0.2" html-minifier-terser "^6.0.2"
@ -7458,7 +7420,7 @@ http-deceiver@^1.2.7:
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw== integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==
http-errors@1.7.2, http-errors@~1.7.2: http-errors@~1.7.2:
version "1.7.2" version "1.7.2"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
@ -9103,11 +9065,6 @@ meow@^12.0.1:
resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6" resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6"
integrity sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw== integrity sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==
merge-descriptors@1.0.3: merge-descriptors@1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
@ -9876,11 +9833,6 @@ path-parse@^1.0.7:
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==
path-to-regexp@~0.1.12: path-to-regexp@~0.1.12:
version "0.1.13" version "0.1.13"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d"
@ -10317,7 +10269,7 @@ proto-list@~1.2.1:
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==
proxy-addr@~2.0.5, proxy-addr@~2.0.7: proxy-addr@~2.0.7:
version "2.0.7" version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
@ -10377,7 +10329,7 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qs@6.15.3, qs@6.7.0, qs@^6.12.3, qs@~6.14.0: qs@6.15.3, qs@^6.12.3, qs@~6.15.1:
version "6.15.3" version "6.15.3"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b"
integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==
@ -10420,16 +10372,6 @@ range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
dependencies:
bytes "3.1.0"
http-errors "1.7.2"
iconv-lite "0.4.24"
unpipe "1.0.0"
raw-body@~2.5.3: raw-body@~2.5.3:
version "2.5.3" version "2.5.3"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2"
@ -10697,10 +10639,10 @@ ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3:
hash-base "^3.1.2" hash-base "^3.1.2"
inherits "^2.0.4" inherits "^2.0.4"
roarr@7.21.6: roarr@7.21.7:
version "7.21.6" version "7.21.7"
resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.21.6.tgz#91c7f0c4b83de49a76b801e73782086baf4a7c8d" resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.21.7.tgz#b77c762660378234e1370c3e333596a744c0f6b1"
integrity sha512-bE9gaDGzWu4BxkyxUEVHfnHruvF4gX9NAZBz86EVJoun2J18exVR/3Wwk0hhu8MWXtabkTVveNcOk1lzHaZbVw== integrity sha512-6X9ID9jb83kPkDxm5xLb9rZdGm6ksdDjpeL0iW5weKelJFi+NVBSowRpEuH8CdpX2x+7RW0TZaezibE7yQB7pg==
dependencies: dependencies:
fast-printf "^1.6.9" fast-printf "^1.6.9"
safe-stable-stringify "^2.4.3" safe-stable-stringify "^2.4.3"
@ -10734,16 +10676,16 @@ safe-array-concat@^1.1.3:
has-symbols "^1.1.0" has-symbols "^1.1.0"
isarray "^2.0.5" isarray "^2.0.5"
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-push-apply@^1.0.0: safe-push-apply@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5"
@ -11775,7 +11717,7 @@ type-fest@^4.4.0, type-fest@^4.8.3:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"
integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
type-is@~1.6.17, type-is@~1.6.18: type-is@~1.6.18:
version "1.6.18" version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@ -11918,7 +11860,7 @@ universalify@^2.0.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
unpipe@1.0.0, unpipe@~1.0.0: unpipe@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
@ -12012,6 +11954,14 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
vee-validate@4.15.0:
version "4.15.0"
resolved "https://registry.yarnpkg.com/vee-validate/-/vee-validate-4.15.0.tgz#eb77a9c867669d34abbc33ca5e16f2a991eb7ad5"
integrity sha512-PGJh1QCFwCBjbHu5aN6vB8macYVWrajbDvgo1Y/8fz9n/RVIkLmZCJDpUgu7+mUmCOPMxeyq7vXUOhbwAqdXcA==
dependencies:
"@vue/devtools-api" "^7.5.2"
type-fest "^4.8.3"
vee-validate@^4.15.1: vee-validate@^4.15.1:
version "4.15.1" version "4.15.1"
resolved "https://registry.yarnpkg.com/vee-validate/-/vee-validate-4.15.1.tgz#66e787e88916baa05f42e53bc0848e0878d50f91" resolved "https://registry.yarnpkg.com/vee-validate/-/vee-validate-4.15.1.tgz#66e787e88916baa05f42e53bc0848e0878d50f91"
@ -12542,36 +12492,6 @@ xtend@^4.0.2:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
xterm-addon-canvas@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/xterm-addon-canvas/-/xterm-addon-canvas-0.5.0.tgz#95d056cec6da42a51b2c47746a011409020c388c"
integrity sha512-QOo/eZCMrCleAgMimfdbaZCgmQRWOml63Ued6RwQ+UTPvQj3Av9QKx3xksmyYrDGRO/AVRXa9oNuzlYvLdmoLQ==
xterm-addon-fit@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz#48ca99015385141918f955ca7819e85f3691d35f"
integrity sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==
xterm-addon-search@0.13.0:
version "0.13.0"
resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz#21286f4db48aa949fbefce34bb8bc0c9d3cec627"
integrity sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==
xterm-addon-web-links@0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz#c65b18588d1f613e703eb6feb7f129e7ff1c63e7"
integrity sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==
xterm-addon-webgl@0.16.0:
version "0.16.0"
resolved "https://registry.yarnpkg.com/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz#9872d08a64136f893b27ef9a6412136d3bf563c4"
integrity sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==
xterm@5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.2.1.tgz#b3fea7bdb55b9be1d4b31f4cd1091f26ac42afb8"
integrity sha512-cs5Y1fFevgcdoh2hJROMVIWwoBHD80P1fIP79gopLHJIE4kTzzblanoivxTiQ4+92YM9IxS36H1q0MxIJXQBcA==
y18n@^4.0.0: y18n@^4.0.0:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
@ -12677,3 +12597,8 @@ yocto-queue@^1.0.0:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00"
integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==
zod@3.24.3:
version "3.24.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==