Compare commits

...
17 Commits
Author SHA1 Message Date
Harvester Bot 2b9d093b38 chore: bump version to 1.9.0-rc4 (#1094)
Signed-off-by: Harvester Bot <94133267+harvesterhci-io-github-bot@users.noreply.github.com>
2026-08-05 21:06:57 +08:00
mergify[bot]andAndy Lee 1b3666e15c fix(subnet): support None option for VLAN dropdown (#1085) (#1090)
(cherry picked from commit be6e9b5f06)

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

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



* fix: move filter labels/schedule buttons position



* feat: improve serial console



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



* refactor: clear searach result after close



* refactor: fine tune serial console



---------


(cherry picked from commit f97f14c811)

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



* fix: guard onQuotasInput against undefined payload



---------


(cherry picked from commit f8a29a1c54)

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

    Related issue: harvester/harvester#9650


(cherry picked from commit 9a935434c2)

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

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



* feat: add access/ volume mode model



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



* fix: bugs in VM selection table



* refactor: copilot review



---------


(cherry picked from commit afe632b723)

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


(cherry picked from commit b87f367583)

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

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

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

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




(cherry picked from commit e013534373)

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

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

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

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

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

---------

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

* fix: CI_BRANCH

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

---------

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

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



* refactor: copilot review



---------


(cherry picked from commit a6bd7ec00e)

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


(cherry picked from commit 790cade04d)

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



* refactor: customize empty table rows message



* refactor: fixed the table column



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



* style: remove unneeded overflow css



---------


(cherry picked from commit 2903e86c02)

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



* refactor: wordings



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

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

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



---------


(cherry picked from commit 2d747e435a)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
2026-07-23 14:41:01 +08:00
31 changed files with 1999 additions and 636 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ module.exports = {
'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: [
+9 -3
View File
@@ -1,13 +1,13 @@
{ {
"name": "harvester-ui-extension", "name": "harvester-ui-extension",
"version": "1.9.0-rc3", "version": "1.9.0-rc4",
"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,6 +15,12 @@
"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",
@@ -34,7 +40,7 @@
"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"
}, },
+5
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>
@@ -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>
+433 -54
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,
@@ -24,72 +23,96 @@ export default {
return { return {
socket: null, socket: null,
terminal: null, terminal: null,
textDecoder: null,
fitAddon: null, fitAddon: null,
searchAddon: null, searchAddon: null,
webglAddon: null, webglAddon: null,
onResize: null,
isOpen: false, isOpen: false,
isOpening: false, isOpening: false,
backlog: [], backlog: [],
firstTime: true, firstTime: true,
queue: [] 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>
+5 -1
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`
); );
}); });
}, },
@@ -7,10 +7,15 @@ import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string'; import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift'; import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class'; import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue'; import MappingColumn from './MappingColumn.vue';
import StorageDefaultsModal from './StorageDefaultsModal.vue';
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
const DEFAULT_ACCESS_MODES = [ACCESS_MODE.READ_WRITE_MANY];
const props = defineProps({ const props = defineProps({
providerName: { type: String, default: '' }, providerName: { type: String, default: '' },
@@ -35,6 +40,10 @@ const allStorageMaps = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
const { networkEntries, storageEntries } = toRefs(props.stepData); const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE; const NAMESPACE = FORKLIFT_NAMESPACE;
@@ -105,7 +114,7 @@ const applyNetworkMapTargets = (mapSpec) => {
}); });
}; };
const applyStorageMapTargets = (mapSpec) => { const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
if (!mapSpec) { if (!mapSpec) {
return; return;
} }
@@ -117,6 +126,30 @@ const applyStorageMapTargets = (mapSpec) => {
if (match?.destination?.storageClass) { if (match?.destination?.storageClass) {
entry.target = 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;
}
} }
}); });
}; };
@@ -134,12 +167,39 @@ const applyDefaultStorageMap = () => {
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default` (sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
); );
applyStorageMapTargets(defaultMap?.spec?.map); applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
}; };
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target)); const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target)); const 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(() => { const canSave = computed(() => {
if (props.useAllProviderData) { if (props.useAllProviderData) {
return true; return true;
@@ -207,6 +267,12 @@ const buildStorageEntries = () => {
type: ds.type || '', type: ds.type || '',
capacity: 0, capacity: 0,
target: '', target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedFromProvider: false,
overridden: false,
usedBy: [], usedBy: [],
_key: `stor-${ ds.id }`, _key: `stor-${ ds.id }`,
}; };
@@ -243,6 +309,12 @@ const buildStorageEntriesFromProvider = (datastoresData) => {
type: ds.type || '', type: ds.type || '',
capacity: ds.capacity || 0, capacity: ds.capacity || 0,
target: '', target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedFromProvider: false,
overridden: false,
usedBy: [], usedBy: [],
_key: `stor-${ ds.id || ds.name }`, _key: `stor-${ ds.id || ds.name }`,
})); }));
@@ -461,7 +533,7 @@ const init = async() => {
if (!hasExistingStorageTargets) { if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) { if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map); applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
} else { } else {
applyDefaultStorageMap(); applyDefaultStorageMap();
} }
@@ -511,6 +583,9 @@ init();
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')" :placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData" :show-used-by="!useAllProviderData"
:clearable="useAllProviderData" :clearable="useAllProviderData"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
> >
<template #source-detail="{ entry }"> <template #source-detail="{ entry }">
<span <span
@@ -520,6 +595,19 @@ init();
</template> </template>
</MappingColumn> </MappingColumn>
</div> </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> </div>
</template> </template>
@@ -42,6 +42,7 @@ const {
username, username,
password, password,
skipTlsVerify, skipTlsVerify,
vddkInitImage,
testPassed, testPassed,
testResult, testResult,
testError, testError,
@@ -97,6 +98,7 @@ watch(selectedProvider, (val) => {
username.value = ''; username.value = '';
password.value = ''; password.value = '';
skipTlsVerify.value = false; skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null; createdProvider.value = null;
createdSecret.value = null; createdSecret.value = null;
} else { } else {
@@ -107,6 +109,7 @@ watch(selectedProvider, (val) => {
if (provider) { if (provider) {
providerName.value = provider.metadata.name; providerName.value = provider.metadata.name;
url.value = provider.spec?.url || ''; url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret; const secretRef = provider.spec?.secret;
@@ -239,6 +242,18 @@ const testConnection = async(buttonCb) => {
if (props.editMode && createdProvider.value) { if (props.editMode && createdProvider.value) {
try { try {
createdProvider.value.spec.url = url.value; 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(); await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret; const secretRef = createdProvider.value.spec?.secret;
@@ -283,20 +298,28 @@ const testConnection = async(buttonCb) => {
const namespace = FORKLIFT_NAMESPACE; const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`; const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const provider = await store.dispatch(`${ inStore }/create`, { const spec = {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec: {
type: 'vsphere', type: 'vsphere',
url: url.value, url: url.value,
secret: { secret: {
name: secretName, name: secretName,
namespace, 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(); await provider.save();
@@ -420,7 +443,8 @@ defineExpose({ testConnection, clickTestButton });
/> />
</div> </div>
<div> <div class="row">
<div class="col span-6">
<LabeledInput <LabeledInput
v-model:value="url" v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')" :label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
@@ -432,6 +456,16 @@ defineExpose({ testConnection, clickTestButton });
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }} {{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p> </p>
</div> </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="row">
<div class="col span-6"> <div class="col span-6">
@@ -1,9 +1,9 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard'; import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE } from '../../config/types';
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
@@ -16,10 +16,18 @@ const props = defineProps({
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false }, showUsedBy: { type: Boolean, default: false },
clearable: { 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 selectOptions = computed(() => { const emit = defineEmits(['edit-defaults']);
if (!props.clearable) {
// 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 props.options;
} }
@@ -34,14 +42,16 @@ const selectOptions = computed(() => {
disabled: true, disabled: true,
kind: 'divider', kind: 'divider',
}, },
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options, ...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> </script>
<template> <template>
@@ -85,12 +95,35 @@ const selectOptions = computed(() => {
<div class="mapping-target"> <div class="mapping-target">
<LabeledSelect <LabeledSelect
v-model:value="entry.target" v-model:value="entry.target"
:options="selectOptions" :options="optionsFor(entry)"
:placeholder="placeholder+'...'" :placeholder="placeholder+'...'"
:searchable="true" :searchable="true"
/> />
</div> </div>
</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"> <div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by"> <span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b> {{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
@@ -177,6 +210,41 @@ const selectOptions = computed(() => {
line-height: 20px; 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 { .bg-light-gray {
background-color: var(--category-active) !important; background-color: var(--category-active) !important;
border: 0; border: 0;
@@ -2,11 +2,12 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard'; import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
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 MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types'; import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types'; import { HCI } from '../../types';
@@ -23,17 +24,19 @@ const props = defineProps({
stepData: { type: Object, required: true }, stepData: { type: Object, required: true },
}); });
const emit = defineEmits(['ready']); const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const vms = ref([]); const vms = computed(() => props.selectedVms);
const networkMappings = ref([]); const networkMappings = ref([]);
const storageMappings = ref([]); const storageMappings = ref([]);
const planName = ref(''); const planName = ref('');
const targetNamespace = ref(''); const targetNamespace = ref('');
const namespaceOptions = ref([]); const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
@@ -43,6 +46,24 @@ targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE; 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) => { watch(planName, (val) => {
props.stepData.planName = val; props.stepData.planName = val;
}, { immediate: true }); }, { immediate: true });
@@ -51,8 +72,8 @@ watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val; props.stepData.targetNamespace = val;
}); });
watch([planName, targetNamespace], ([name, namespace]) => { watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!name && !!namespace); emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true }); }, { immediate: true });
const formatNetworkTarget = (target = '') => { const formatNetworkTarget = (target = '') => {
@@ -87,7 +108,7 @@ const totalStorageGB = computed(() => {
return bytesToGB(totalBytes); return bytesToGB(totalBytes);
}); });
const vmCards = computed(() => { const vmRows = computed(() => {
return vms.value.map((vm) => { return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0; const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
@@ -101,23 +122,93 @@ const vmCards = computed(() => {
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-'; const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-'; 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 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 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 { return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id, id: vm.id,
name: vm.name || vm.id, name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os, os,
cpus, resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
memGB, resourcesSub: `${ memGB } • ${ diskDisplay }`,
diskDisplay, powerState,
powerStateClass,
networkMappings: vmNetworkMappings, networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings, 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 startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
@@ -201,8 +292,6 @@ const startMigrationAction = async() => {
}; };
const init = async() => { const init = async() => {
vms.value = props.selectedVms;
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
try { try {
@@ -213,6 +302,22 @@ const init = async() => {
namespaceOptions.value = []; 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) { if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({ networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id, source: entry.name || entry.id,
@@ -244,12 +349,31 @@ defineExpose({ startMigration: startMigrationAction });
<div <div
class="review-migration-content" class="review-migration-content"
> >
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary --> <!-- Migration Details Summary -->
<div class="migration-details"> <div class="migration-details">
<div class="section-header">
<h3 class="section-title m-0"> <h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }} {{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3> </h3>
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row"> <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 <LabeledSelect
v-model:value="targetNamespace" v-model:value="targetNamespace"
class="namespace-select" class="namespace-select"
@@ -258,110 +382,144 @@ defineExpose({ startMigration: startMigrationAction });
:options="namespaceOptions" :options="namespaceOptions"
required required
/> />
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
</div> </div>
<div class="details-grid span-9">
<div class="detail-column"> <div class="summary-row">
<div class="detail-item"> <div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="detail-value detail-value-large">{{ vms.length }}</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>
</div>
<div class="detail-column"> <div class="stats-column">
<div class="detail-item"> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ providerName }}</span> <span class="detail-value">{{ providerName }}</span>
</div> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
</div> <span class="detail-value">{{ vms.length }}</span>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ targetNamespace }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span> <span class="detail-value">{{ totalVCpu }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span> <span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span> <span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
</div> </div>
</div> </div>
<div class="detail-column grid-column-2">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
</span>
</div>
</div>
</div>
</div> </div>
<!-- Virtual Machines --> <!-- Virtual Machines -->
<div class="vm-section"> <div class="vm-section">
<h3 class="section-title m-0"> <SortableTable
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }}) class="vm-sortable-table"
</h3> :rows="vmRows"
:headers="vmHeaders"
<div class="vm-cards-grid"> :search="false"
<RcItemCard :table-actions="false"
v-for="vm in vmCards" :row-actions="false"
:id="vm.id" :groupable="false"
:key="vm.id" :paging="true"
:variant="'small'" key-field="_key"
:header="{ title: { text: vm.name } }"
> >
<template #item-card-content> <template #header-left>
<div class="vm-card-content"> <h4 class="section-title m-0">
<div class="vm-card-specs"> {{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
<span class="vm-os text-deemphasized">{{ vm.os }}</span> </h4>
<span class="vm-resources"> </template>
<i <template #header-right>
class="icon icon-disk" <a
aria-hidden="true" href="#"
/> class="edit-selection-link"
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }} @click.prevent="onEditSelection"
</span> >
</div> {{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
<MappingsCell </a>
:network-entries="vm.networkMappings.map(m => `${m.source} → ${formatNetworkTarget(m.target)}`)" </template>
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)" <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> </div>
</template> </template>
</RcItemCard> <template #cell:resources="{ row }">
</div> <span>{{ row.resources }}</span><br>
</div> <span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<!-- Cold Migration Warning --> <template #cell:powerState="{ row }">
<Banner <BadgeState
color="warning" :label="row.powerState"
class="m-0" :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"
> >
<div> <span class="mapping-source">{{ m.source }}</span>
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br> <span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }} </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> </div>
</Banner>
<!-- Error banner --> <!-- Error banner -->
<Banner <Banner
@@ -390,43 +548,62 @@ defineExpose({ startMigration: startMigrationAction });
} }
.name-row { .name-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
.namespace-select {
max-width: 280px;
flex-shrink: 0;
}
.name-input {
flex: 1;
}
}
.details-grid {
display: grid; display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: 12px clamp(12px, 1vw, 96px);
gap: 12px clamp(64px, 8vw, 128px); align-items: start;
line-height: 20px;
} }
.detail-column { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
&.grid-column-2 { .section-description {
grid-column: span 2; font-size: 14px;
} }
} }
.detail-item { .migration-mode-column {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between; 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 { .detail-label {
font-size: 14px; font-size: 14px;
@@ -436,45 +613,58 @@ defineExpose({ startMigration: startMigrationAction });
.detail-value { .detail-value {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
&.detail-value-large {
font-size: 24px;
} }
.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-cards-grid { .vm-name-cell {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.vm-card-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
line-height: 20px; .vm-name {
font-weight: 600;
} }
.vm-card-specs { .vm-id {
display: flex; font-size: 12px;
flex-direction: row; }
gap: 16px;
align-items: center;
.vm-os {
font-size: 14px;
} }
.vm-resources { .mapping-cell {
font-size: 14px; .mapping-target {
color: var(--muted); margin-left: 4px;
display: inline-flex; white-space: nowrap;
align-items: center;
gap: 8px;
} }
} }
.mapping-cell + .mapping-cell {
margin-top: 8px;
}
.banner-title { .banner-title {
font-weight: 600; font-weight: 600;
} }
@@ -25,8 +25,9 @@ const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]); const selectedVMs = ref([]);
const loading = ref(true); const loading = ref(true);
const networkMap = ref({}); // Restored from stepData so friendly names survive step navigation / remount.
const datastoreMap = ref({}); const networkMap = ref(props.stepData.networkMap || {});
const datastoreMap = ref(props.stepData.datastoreMap || {});
const sortableTableRef = ref(null); const sortableTableRef = ref(null);
const allVMsSelected = ref(false); const allVMsSelected = ref(false);
const errors = ref([]); const errors = ref([]);
@@ -94,7 +95,7 @@ const showSelectAllBanner = computed(() => {
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead')); const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = [ const headers = ref([
{ {
name: 'vmName', name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName', labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
@@ -139,7 +140,7 @@ const headers = [
subLabel: t('harvester.addons.vmMigration.generic.identifier'), subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200, width: 200,
}, },
]; ]);
const buildTableRows = () => { const buildTableRows = () => {
return discoveredVMs.value.map((vm) => { return discoveredVMs.value.map((vm) => {
@@ -190,6 +191,7 @@ const buildTableRows = () => {
return { return {
_original: vm, _original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name, _key: vm.id || vm.vmId || vm.metadata?.name,
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
vmName: vm.name || vm.metadata?.name || '-', vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-', vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-', os: vm.guestName || vm.guestOS || vm.os || '-',
@@ -239,6 +241,10 @@ const clearSelection = () => {
if (table) { if (table) {
table.clearSelection(); table.clearSelection();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
} }
}); });
}; };
@@ -249,6 +255,49 @@ const selectAllVMs = () => {
selectedVMs.value = discoveredVMs.value.slice(); 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( watch(
() => sortableTableRef.value?.page, () => sortableTableRef.value?.page,
() => { () => {
@@ -318,6 +367,9 @@ const fetchVMs = async() => {
return map; return map;
}, {}); }, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
lastFetchedAt.value = Date.now(); lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows(); tableRows.value = buildTableRows();
}; };
@@ -374,6 +426,10 @@ const init = async() => {
tableRows.value = buildTableRows(); tableRows.value = buildTableRows();
loading.value = false; loading.value = false;
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
await nextTick();
sortSelectedToFront();
return; return;
} }
@@ -441,7 +497,6 @@ init();
:row-actions="false" :row-actions="false"
:groupable="false" :groupable="false"
:paging="true" :paging="true"
:rows-per-page="20"
key-field="_key" key-field="_key"
@selection="onSelect" @selection="onSelect"
> >
@@ -450,7 +505,20 @@ init();
<h3 class="m-0"> <h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }} {{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3> </h3>
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span> <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> </div>
</template> </template>
<template #cell:vmName="{ row }"> <template #cell:vmName="{ row }">
@@ -466,7 +534,7 @@ init();
<template #cell:powerState="{ row }"> <template #cell:powerState="{ row }">
<BadgeState <BadgeState
:label="row.powerState" :label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'" :color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
/> />
</template> </template>
<template #cell:network="{ row }"> <template #cell:network="{ row }">
@@ -533,6 +601,23 @@ init();
.text-deemphasized { .text-deemphasized {
font-size: 13px; 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 { .vm-name-cell {
@@ -0,0 +1,175 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import AppModal from '@shell/components/AppModal';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
const ACCESS_MODE_OPTIONS = [ACCESS_MODE.READ_WRITE_ONCE, ACCESS_MODE.READ_WRITE_MANY, ACCESS_MODE.READ_ONLY_MANY];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
accessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
});
const emit = defineEmits(['apply', 'close']);
const store = useStore();
const { t } = useI18n(store);
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
const localAccessMode = ref(props.accessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY);
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
const apply = () => {
emit('apply', {
volumeMode: localVolumeMode.value,
accessModes: [localAccessMode.value],
});
emit('close');
};
// Repopulate the dropdowns with the provider default values; the user then applies.
const reset = () => {
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
localAccessMode.value = props.inheritedAccessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY;
};
const cancel = () => {
emit('close');
};
</script>
<template>
<app-modal
class="storage-defaults-modal"
name="storageDefaultsDialog"
:width="620"
height="auto"
:click-to-close="false"
@close="cancel"
>
<Card
class="storage-defaults-card"
:show-highlight-border="false"
>
<template #title>
<h4 class="text-default-text">
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
</h4>
</template>
<template #body>
<p class="text-deemphasized description">
<template v-if="showInherited">
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
</template>
<template v-else>
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
</template>
</p>
<div class="settings-box">
<LabeledSelect
v-model:value="localVolumeMode"
class="volume-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
:options="volumeModeOptions"
:searchable="false"
:clearable="false"
/>
<LabeledSelect
v-model:value="localAccessMode"
class="access-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
:options="accessModeOptions"
:searchable="false"
:clearable="false"
/>
</div>
</template>
<template #actions>
<div class="buttons">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
</button>
<div class="right-buttons">
<button
v-if="showInherited"
class="btn role-secondary mr-10"
@click="reset"
>
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
</button>
<button
class="btn role-primary"
:disabled="!localAccessMode"
@click="apply"
>
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
</button>
</div>
</div>
</template>
</Card>
</app-modal>
</template>
<style lang="scss" scoped>
.storage-defaults-modal {
z-index: 45;
}
.storage-defaults-card {
margin: 0;
padding: 20px;
}
.description {
margin-bottom: 16px;
line-height: 20px;
}
.settings-box {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px;
border-radius: 6px;
background-color: var(--category-active);
}
.volume-mode {
max-width: 280px;
}
.access-mode {
max-width: 280px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right-buttons {
display: flex;
}
}
</style>
@@ -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: '',
shareable: false,
errors: [], errors: [],
allPVCs: [], 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"
+14 -3
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>
@@ -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;
}, },
@@ -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"
@@ -1062,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"
@@ -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
@@ -132,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>
+45 -3
View File
@@ -798,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.
@@ -1893,11 +1897,14 @@ harvester:
providerSelect: Provider providerSelect: Provider
createNew: Create new provider createNew: Create new provider
requirementsTitle: Requirements requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher." 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 name: Name
urlLabel: vCenter/ESXi URL urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk" urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https:// 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 skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments skipSslHint: Not recommended for production environments
testConnection: testConnection:
@@ -1919,6 +1926,7 @@ harvester:
refreshNow: Refresh now refreshNow: Refresh now
availableVms: Available Virtual Machines availableVms: Available Virtual Machines
selected: selected selected: selected
clearAll: Clear All
saveSelection: Save Selection and Continue saveSelection: Save Selection and Continue
selectAllBanner: selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page." pageOnly: "Your current selection includes only VMs currently shown on the page."
@@ -1965,7 +1973,7 @@ harvester:
description: Map VMware networks and datastores to Harvester and Longhorn target resources description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue save: Save Mappings and Continue
noTemplate: Start from scratch noTemplate: Start from scratch
removeMap: Remove Map removeMap: Remove mapping
networkMapping: networkMapping:
title: Network Mapping title: Network Mapping
description: Map VMware port groups to Harvester networks description: Map VMware port groups to Harvester networks
@@ -1977,24 +1985,58 @@ harvester:
storageMapping: storageMapping:
title: Storage Mapping title: Storage Mapping
description: Map VMware datastores to Harvester storage classes description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Longhorn Storage Volume placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template 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: reviewMigration:
title: Review Migration Plan title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster. description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name planName: Name
planNamePlaceholder: "e.g. my-migration-plan" 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 migrationDetails: Migration Details
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs totalVms: Total VMs
vcpu: vCPU vcpu: vCPU
memory: Memory memory: Memory
storage: Storage storage: Storage
source: Source source: Source
provider: Provider
targetNamespace: Namespace targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace" targetNamespacePlaceholder: "Select target namespace"
migrationMode: Migration Mode migrationMode: Migration Mode
coldMigration: Cold Migration 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 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" 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" 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 startMigration: Start Migration Plan
+79 -22
View File
@@ -616,6 +616,7 @@ export default {
type, type,
storageClassName, storageClassName,
hotpluggable, hotpluggable,
shareable: DISK.shareable || false,
volumeStatus, volumeStatus,
dataSource, dataSource,
namespace, namespace,
@@ -789,18 +790,41 @@ export default {
} }
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`; const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const annotations = vm.metadata.annotations;
Object.keys(vm.metadata.annotations).forEach((key) => { const desired = {};
if (key.startsWith(staticIpPrefix)) {
delete vm.metadata.annotations[key];
}
});
this.networkRows.forEach((row) => { this.networkRows.forEach((row) => {
if (row.name && row.staticIp) { if (row.name && row.staticIp) {
vm.metadata.annotations[`${ staticIpPrefix }${ 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() {
@@ -915,7 +939,6 @@ export default {
} }
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) { 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' }
@@ -941,11 +964,18 @@ export default {
volumes.push(cloudinitdisk); 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,
@@ -1142,6 +1172,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);
@@ -1220,6 +1260,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;
@@ -1344,13 +1388,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);
} }
@@ -1401,7 +1454,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']);
} }
@@ -1416,7 +1468,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 || '';
@@ -1425,6 +1477,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);
@@ -1450,7 +1511,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']);
} }
@@ -1483,7 +1543,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;
} }
@@ -1904,8 +1964,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;
} }
}, },
@@ -1965,9 +2023,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();
}, },
+5
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;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "harvester", "name": "harvester",
"description": "Rancher UI Extension for Harvester", "description": "Rancher UI Extension for Harvester",
"version": "1.9.0-rc3", "version": "1.9.0-rc4",
"private": false, "private": false,
"rancher": { "rancher": {
"annotations": { "annotations": {
@@ -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"
v-model:value="vmi" :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>
@@ -179,8 +179,10 @@ const rows = computed(() => {
overallProgress = Math.round(overallProgress * 10) / 10; overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it // If no step-level error but the plan itself is failed, surface it.
if (!errorMsg && plan.planFailed) { // 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') }`; errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
} }
@@ -190,7 +192,11 @@ const rows = computed(() => {
const cluster = routeParams.cluster || store.getters['clusterId']; const cluster = routeParams.cluster || store.getters['clusterId'];
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean); 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)); const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planFailed && !plan.planCanceled && !!targetVm && !!product && !!cluster; // 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 ? { const vmDetailLocation = canNavigateToVm ? {
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`, name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
params: { params: {
@@ -5,6 +5,7 @@ import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types'; import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue'; import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue'; import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
@@ -46,6 +47,7 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -162,7 +164,7 @@ const onFinish = async(buttonCb) => {
buttonCb(true); buttonCb(true);
currentRouter().push(providerListLocation); currentRouter().push(providerListLocation);
} catch (err) { } catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)]; errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
buttonCb(false); buttonCb(false);
} }
}; };
@@ -193,6 +195,7 @@ const init = async() => {
stepData.provider.selectedProvider = fetchedProvider.metadata.name; stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name; stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || ''; stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider; stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret; const secretRef = fetchedProvider.spec?.secret;
@@ -231,7 +234,7 @@ const init = async() => {
// Maps may not exist yet // Maps may not exist yet
} }
} catch (err) { } catch (err) {
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })]; errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })];
} }
initialLoading.value = false; initialLoading.value = false;
@@ -41,6 +41,7 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -173,6 +174,24 @@ const onReviewReady = (ready) => {
reviewReady.value = 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 // Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => { watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
@@ -191,13 +210,13 @@ watch(() => stepData.provider.providerName, (newVal, oldVal) => {
} }
}); });
// Clear mappings and review when VM selection changes // 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) => { watch(selectedVMs, (newVal, oldVal) => {
const newIds = new Set(newVal.map((v) => v.id));
const oldIds = new Set(oldVal.map((v) => v.id)); const oldIds = new Set(oldVal.map((v) => v.id));
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id)); const added = newVal.some((v) => !oldIds.has(v.id));
if (oldVal.length > 0 && changed) { if (oldVal.length > 0 && added) {
stepData.mappings.networkEntries = []; stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = []; stepData.mappings.storageEntries = [];
mappingsReady.value = false; mappingsReady.value = false;
@@ -290,6 +309,9 @@ const onCancel = () => {
:mapping-entries="stepData.mappings" :mapping-entries="stepData.mappings"
:step-data="stepData.review" :step-data="stepData.review"
@ready="onReviewReady" @ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/> />
</template> </template>
</CruResource> </CruResource>
@@ -68,40 +68,28 @@ export default defineComponent({
return (this.value || []).map((plan) => { return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-'; const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || ''; const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
const migrationsMap = new Map(); const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => ({
name: resource?.metadata?.name,
namespace: resource?.metadata?.namespace || namespace,
}));
history const matchedNetworkMap = existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => this.ownedByPlan(resource, plan));
.map((entry) => ({ const matchedStorageMap = existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => this.ownedByPlan(resource, plan));
name: entry?.migration?.name,
namespace: entry?.migration?.namespace || namespace,
}))
.filter((entry) => !!entry.name)
.forEach((entry) => {
const matched = existing[HCI.FORKLIFT_MIGRATION].find((resource) => resource?.metadata?.name === entry.name && resource?.metadata?.namespace === entry.namespace);
if (matched) {
migrationsMap.set(`${ entry.namespace }/${ entry.name }`, entry);
}
});
const matchedNetworkMap = networkMap?.name && existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMap.name && resource?.metadata?.namespace === (networkMap.namespace || namespace));
const matchedStorageMap = storageMap?.name && existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMap.name && resource?.metadata?.namespace === (storageMap.namespace || namespace));
return { return {
planName, planName,
namespace, namespace,
migrations: [...migrationsMap.values()], migrations,
networkMap: matchedNetworkMap ? { networkMap: matchedNetworkMap ? {
namespace: networkMap.namespace || namespace, namespace: matchedNetworkMap?.metadata?.namespace || namespace,
name: networkMap.name, name: matchedNetworkMap?.metadata?.name,
} : null, } : null,
storageMap: matchedStorageMap ? { storageMap: matchedStorageMap ? {
namespace: storageMap.namespace || namespace, namespace: matchedStorageMap?.metadata?.namespace || namespace,
name: storageMap.name, name: matchedStorageMap?.metadata?.name,
} : null, } : null,
}; };
}); });
@@ -125,53 +113,57 @@ export default defineComponent({
methods: { methods: {
resourceNames, resourceNames,
/**
* 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() { buildDeleteTargets() {
const existing = this.existingRelatedResources;
const targets = new Map(); const targets = new Map();
const addTarget = (type, resource) => {
const name = resource?.metadata?.name;
const ns = resource?.metadata?.namespace;
if (!name) {
return;
}
const key = `${ type }|${ ns }|${ name }`;
targets.set(key, {
type, name, namespace: ns
});
};
for (const plan of this.value || []) { for (const plan of this.value || []) {
const namespace = plan?.metadata?.namespace || ''; existing[HCI.FORKLIFT_NETWORK_MAP]
const networkMap = plan?.spec?.map?.network; .filter((resource) => this.ownedByPlan(resource, plan))
const storageMap = plan?.spec?.map?.storage; .forEach((resource) => addTarget(HCI.FORKLIFT_NETWORK_MAP, resource));
const history = plan?.status?.migration?.history || [];
if (networkMap?.name) { existing[HCI.FORKLIFT_STORAGE_MAP]
const ns = networkMap.namespace || namespace; .filter((resource) => this.ownedByPlan(resource, plan))
const key = `${ HCI.FORKLIFT_NETWORK_MAP }|${ ns }|${ networkMap.name }`; .forEach((resource) => addTarget(HCI.FORKLIFT_STORAGE_MAP, resource));
targets.set(key, { existing[HCI.FORKLIFT_MIGRATION]
type: HCI.FORKLIFT_NETWORK_MAP, .filter((resource) => this.ownedByPlan(resource, plan))
name: networkMap.name, .forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource));
namespace: ns,
});
}
if (storageMap?.name) {
const ns = storageMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_STORAGE_MAP }|${ ns }|${ storageMap.name }`;
targets.set(key, {
type: HCI.FORKLIFT_STORAGE_MAP,
name: storageMap.name,
namespace: ns,
});
}
for (const entry of history) {
const migrationName = entry?.migration?.name;
if (!migrationName) {
continue;
}
const migrationNs = entry?.migration?.namespace || namespace;
const key = `${ HCI.FORKLIFT_MIGRATION }|${ migrationNs }|${ migrationName }`;
targets.set(key, {
type: HCI.FORKLIFT_MIGRATION,
name: migrationName,
namespace: migrationNs,
});
}
} }
return [...targets.values()]; return [...targets.values()];
+20 -10
View File
@@ -24,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/removeFavorite', TRIGGER); store.dispatch('type-map/addFavorite', TRIGGER);
}, 600); setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
}, delay);
});
}; };
const hasAccessibleSchema = (t) => { const hasAccessibleSchema = (t) => {
@@ -92,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 }
); );
+16 -3
View File
@@ -73,8 +73,21 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) {
export function buildStorageMapEntries(entries = []) { export function buildStorageMapEntries(entries = []) {
return entries return entries
.filter((entry) => !!entry.target) .filter((entry) => !!entry.target)
.map((entry) => ({ .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 }, source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target }, destination,
})); };
});
} }
+143 -163
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"
@@ -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,22 +4367,6 @@ 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:
version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
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: body-parser@~1.20.3:
version "1.20.4" version "1.20.4"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f"
@@ -4362,6 +4385,24 @@ body-parser@~1.20.3:
type-is "~1.6.18" type-is "~1.6.18"
unpipe "~1.0.0" unpipe "~1.0.0"
body-parser@~1.20.5:
version "1.20.6"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.6.tgz#60c789c78e0992d906da0a29d71ae01d15c1ed76"
integrity sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==
dependencies:
bytes "~3.1.2"
content-type "~1.0.5"
debug "2.6.9"
depd "2.0.0"
destroy "~1.2.0"
http-errors "~2.0.1"
iconv-lite "~0.4.24"
on-finished "~2.4.1"
qs "~6.15.1"
raw-body "~2.5.3"
type-is "~1.6.18"
unpipe "~1.0.0"
bonjour-service@^1.0.11: bonjour-service@^1.0.11:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.3.0.tgz#80d867430b5a0da64e82a8047fc1e355bdb71722" resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.3.0.tgz#80d867430b5a0da64e82a8047fc1e355bdb71722"
@@ -4535,11 +4576,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 +5062,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 +5108,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 +5121,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,38 +6685,39 @@ 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:
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: dependencies:
accepts "~1.3.7" accepts "~1.3.8"
array-flatten "1.1.1" array-flatten "1.1.1"
body-parser "1.19.0" body-parser "~1.20.5"
content-disposition "0.5.3" content-disposition "~0.5.4"
content-type "~1.0.4" content-type "~1.0.4"
cookie "0.4.0" cookie "~0.7.1"
cookie-signature "1.0.6" cookie-signature "~1.0.6"
debug "2.6.9" debug "2.6.9"
depd "~1.1.2" depd "2.0.0"
encodeurl "~1.0.2" encodeurl "~2.0.0"
escape-html "~1.0.3" escape-html "~1.0.3"
etag "~1.8.1" etag "~1.8.1"
finalhandler "~1.1.2" finalhandler "~1.3.1"
fresh "0.5.2" fresh "~0.5.2"
merge-descriptors "1.0.1" http-errors "~2.0.0"
merge-descriptors "1.0.3"
methods "~1.1.2" methods "~1.1.2"
on-finished "~2.3.0" on-finished "~2.4.1"
parseurl "~1.3.3" parseurl "~1.3.3"
path-to-regexp "0.1.7" path-to-regexp "~0.1.12"
proxy-addr "~2.0.5" proxy-addr "~2.0.7"
qs "6.7.0" qs "~6.15.1"
range-parser "~1.2.1" range-parser "~1.2.1"
safe-buffer "5.1.2" safe-buffer "5.2.1"
send "0.17.1" send "~0.19.0"
serve-static "1.14.1" serve-static "~1.16.2"
setprototypeof "1.1.1" setprototypeof "1.2.0"
statuses "~1.5.0" statuses "~2.0.1"
type-is "~1.6.18" type-is "~1.6.18"
utils-merge "1.0.1" utils-merge "1.0.1"
vary "~1.1.2" vary "~1.1.2"
@@ -6825,10 +6855,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 +6874,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 +6954,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 +7450,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 +7475,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 +9120,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 +9888,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 +10324,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 +10384,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.14.0, 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 +10427,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 +10694,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 +10731,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 +11772,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 +11915,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 +12009,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 +12547,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 +12652,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==