Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]anda110605 1f37a23845 ci: retry transient yarn install failures
Co-authored-by: a110605 <5744158+a110605@users.noreply.github.com>
2026-09-04 15:24:15 +00:00
copilot-swe-agent[bot] 8e93404849 Initial plan 2026-09-04 15:22:06 +00:00
marcelofukumoto 00584f1d19 chore(deps): bump @rancher/shell to 3.0.13 (#1130)
Clears 26 of the 54 open Dependabot alerts (axios, dompurify, serve-static, and
drops vulnerable send / @tootallnate/once copies).

3.0.13 ships the flat-config ESLint 10 toolchain, so lint now routes through the
@rancher/shell launcher (keeping the legacy .eslintrc on ESLint 7); a prelint step
installs an ESLint-7-compatible eslint-plugin-jest into the isolated toolchain.

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-09-04 10:36:52 +02:00
Tim SerongandVolker Theile 4fa6470343 feat: LHv2: graduate from "Experimental" to "Preview" (#1129)
In Harvester v1.9.0, we're moving LHv2 from "Experimental" to "Preview",
so I've added a new preview label to the settings interface.  I figure
given Experimental had the "error" color, I should make "Preview" use
the warning color, to make it a bit less alarming :-)

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

Signed-off-by: Tim Serong <tserong@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-09-01 11:30:38 +10:00
Andy Lee d550046189 fix: vGPU doesn't display when creating harvester downstream cluster (#1123)
* fix: vGPU doesn't display in edit VM page

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

* fix: align PCI passthrough action buttons height

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

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-25 16:24:37 +08:00
Po Han Huang 5f32a9ade5 feat: improve readability for vm migration UI (#1112)
Signed-off-by: pohanhuang <pohan.huang@suse.com>
2026-08-18 12:32:02 +08:00
marcelofukumoto 3f39d561be security: fix 15 transitive Dependabot alerts via yarn.lock re-resolution (#1115)
Re-resolve 15 vulnerable transitive dependencies to their latest in-range
patched versions in yarn.lock only (no package.json changes): @babel/core,
@babel/plugin-transform-modules-systemjs, brace-expansion, fast-uri, form-data,
http-proxy-middleware, immutable, joi, js-cookie, launch-editor, nanoid,
shell-quote, svgo, websocket-driver, ws.

The remaining alerts are pinned by parent packages (chiefly @rancher/shell) or
need a cross-major bump, so they can't be resolved by the lockfile alone; they
are tracked separately.

Signed-off-by: Marcelo Fukumoto <marceloyfukumoto@gmail.com>
2026-08-17 10:30:05 +02:00
Andy Lee 7c84269ed6 fix: exclude forklift namespace from system classification (#1114)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-17 15:23:23 +10:00
Andy Lee f63be73b11 fix: ACCESS_MODE undefined error in forklift mapping page (#1111) 2026-08-14 10:01:27 +08:00
Andy Lee fabc1cec21 fix: prevent duplicated cloud-config for empty user data (#1109)
* fix: prevent duplicated cloud-config for empty user data

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

* refactor: AI review

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-12 14:20:25 +08:00
Andy Lee ed2b6f2551 fix: remove map ownerref writeback and delete maps via plan refs (#1105)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-10 18:14:48 +08:00
renovate[bot] 72282ae50e deps: update dependency @types/lodash to v4.17.25 (#1099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 10:10:31 +08:00
Volker Theile 8fba4e7050 fix: Rename to Windows Unattended & Sysprep (#1096)
Rename title and other mentions of Sysprep to prevent misunderstandings regarding functionality. For the average Windows administrator who simply wants to automate the installation of a Windows ISO, it isn't immediately intuitive when the term “Sysprep” is used on its own.

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

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

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

* refactor: PR comment

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

---------

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

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

* fix: move filter labels/schedule buttons position

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

* feat: improve serial console

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

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

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

* refactor: clear searach result after close

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

* refactor: fine tune serial console

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

---------

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

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

* fix: guard onQuotasInput against undefined payload

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

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-08-04 13:27:30 +08:00
27 changed files with 2569 additions and 1634 deletions
+12 -1
View File
@@ -12,4 +12,15 @@ runs:
- name: Install packages
shell: bash
run: yarn install
run: |
for attempt in 1 2 3; do
if yarn install --frozen-lockfile; then
exit 0
fi
if [ "$attempt" -lt 3 ]; then
sleep 10
fi
done
exit 1
+7 -3
View File
@@ -8,12 +8,16 @@
- [ ] Yes, the backend owner is @
- [ ] No, frontend-only.
### Related Issue #
### Related Issue
<!-- Link the issue as harvester/harvester#<issue_number> -->
harvester/harvester#<issue_number>
### Test screenshot or video (Required)
<!-- Attach screenshot or video of the changes and eventual comparison if you find it necessary -->
### Test Steps
<!-- Provide the test steps for reviewer to verify the issue -->
1. Go to ... page
### Test screenshot or video
<!-- Attach screenshot or video of the changes and eventual comparison -->
@@ -1,6 +1,7 @@
name: Build Standalone on PR Merge
on:
workflow_dispatch: # manually trigger on Github Actions only support PR branch pushed to harvester/harvester-ui-extension
push:
branches:
- main
@@ -29,3 +30,35 @@ jobs:
id-token: write
with:
CI_BRANCH: ${{github.ref_name}}
comment-on-pr:
name: Comment build result on PR
# Only for manual runs so the existing push / pull_request flows are untouched.
if: github.event_name == 'workflow_dispatch'
needs:
- build
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Add build artifact comment to the PR opened from this branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
# Match scripts/version: sanitize the branch and map main -> latest.
if [ "$BRANCH" = "main" ]; then
DIR="latest"
else
DIR=$(printf '%s' "$BRANCH" | sed -E 's/[^a-zA-Z0-9.-]+/-/g')
fi
URL="https://releases.rancher.com/harvester-ui/dashboard/${DIR}/index.html"
PR=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$BRANCH" --json number --jq '.[0].number // empty')
if [ -z "$PR" ]; then
echo "No open PR found for branch $BRANCH, skipping comment."
exit 0
fi
BODY=$(printf '🚀 **UI Artifact Build Status:** `SUCCESS` ✅\n> ui-index: %s\n> ui-source: external' "$URL")
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$BODY"
+13 -5
View File
@@ -7,7 +7,7 @@
},
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.29.7",
"@rancher/shell": "3.0.12-rc.3",
"@rancher/shell": "3.0.13",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
@@ -15,6 +15,12 @@
"cache-loader": "^4.1.0",
"color": "4.2.3",
"ip": "2.0.1",
"@xterm/xterm": "6.0.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/addon-canvas": "0.7.0",
"node-polyfill-webpack-plugin": "^3.0.0",
"elkjs": "^0.11.0",
"vue-draggable-next": "^2.2.1",
@@ -29,7 +35,7 @@
"glob": "7.2.3",
"glob-parent": "6.0.2",
"json5": "2.2.3",
"@types/lodash": "4.17.24",
"@types/lodash": "4.17.25",
"merge": "2.1.1",
"node-forge": "1.4.0",
"nth-check": "2.1.1",
@@ -42,15 +48,17 @@
"dev": "NODE_ENV=dev ./node_modules/.bin/vue-cli-service serve",
"build": "./node_modules/.bin/vue-cli-service build",
"clean": "./node_modules/@rancher/shell/scripts/clean",
"lint": "./node_modules/.bin/eslint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/.bin/eslint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"lint": "./node_modules/@rancher/shell/scripts/lint --max-warnings 0 --ext .js,.ts,.vue .",
"lint:fix": "./node_modules/@rancher/shell/scripts/lint --fix --max-warnings 0 --ext .js,.ts,.vue .",
"build-pkg": "./node_modules/@rancher/shell/scripts/build-pkg.sh",
"serve-pkgs": "./node_modules/@rancher/shell/scripts/serve-pkgs",
"publish-pkgs": "./node_modules/@rancher/shell/scripts/extension/publish",
"parse-tag-name": "./node_modules/@rancher/shell/scripts/extension/parse-tag-name",
"commitlint": "commitlint --edit",
"prepare": "husky",
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh"
"agents:generate": "./scripts/generate-agent-and-persona-mds.sh",
"prelint": "npm --prefix node_modules/@rancher/shell/eslint-legacy install eslint-plugin-jest@^24 --no-save --no-audit --no-fund --legacy-peer-deps --loglevel=error",
"prelint:fix": "npm --prefix node_modules/@rancher/shell/eslint-legacy install eslint-plugin-jest@^24 --no-save --no-audit --no-fund --legacy-peer-deps --loglevel=error"
},
"devDependencies": {
"@commitlint/load": "^19.8.1",
+5
View File
@@ -271,4 +271,9 @@ export default {
.filter-label .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>
@@ -116,6 +116,11 @@ export default {
.vm-schedule-dropdown .v-popper__arrow-container {
display: none;
}
.fixed-header-actions .middle .vm-schedule-filter + .btn-group {
display: inline-flex;
vertical-align: middle;
}
</style>
<style lang="scss" scoped>
+442 -63
View File
@@ -1,7 +1,6 @@
<script>
import { allHash } from '@shell/utils/promise';
import debounce from 'lodash/debounce';
import Socket, {
EVENT_CONNECTED,
EVENT_CONNECTING,
@@ -22,74 +21,98 @@ export default {
data() {
return {
socket: null,
terminal: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: []
socket: null,
terminal: null,
textDecoder: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
onResize: null,
isOpen: false,
isOpening: false,
backlog: [],
firstTime: true,
queue: [],
isDraining: false,
drainScheduled: false,
drainTimer: null,
showSearch: false,
searchQuery: '',
searchOptions: {
caseSensitive: false,
regex: false,
wholeWord: false,
incremental: true,
decorations: {
matchBackground: '#B45309',
matchBorder: '#FDBA74',
matchOverviewRuler: '#FDBA74',
activeMatchBackground: '#EA580C',
activeMatchBorder: '#FED7AA',
activeMatchColorOverviewRuler: '#FED7AA',
},
},
};
},
computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() {
return {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
cursorWidth: 2,
scrollback: 5000,
useStyle: true,
fontSize: 12,
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 16,
lineHeight: 1.3,
};
},
},
watch: {
queue: {
handler: debounce(async function(neu) {
if (neu.length === 0) {
return;
}
const msg = await Promise.all(neu);
(msg || []).forEach((m) => {
this.terminal.write(m);
});
this.queue = [];
}, 10),
deep: true
}
},
beforeUnmount() {
this.close();
},
watch: {
serialConsoleUrl() {
if (this.terminal) {
this.connect();
}
},
},
async mounted() {
this.textDecoder = new TextDecoder('utf-8');
await this.setupTerminal();
await this.connect();
this.onResize = debounce(() => this.fit(), 100);
window.addEventListener('resize', this.onResize);
this.scheduleFit();
},
methods: {
async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body'));
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm');
const xterm = await import(/* webpackChunkName: "xterm" */ '@xterm/xterm');
const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'),
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
});
const terminal = new xterm.Terminal({
theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim()
},
...this.xtermConfig,
@@ -97,25 +120,48 @@ export default {
this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon();
try {
this.webglAddon = new addons.webgl.WebGlAddon();
} catch (e) {
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
this.webglAddon = null;
}
terminal.loadAddon(this.fitAddon);
terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm);
if ( this.webglAddon ) {
// The webgl renderer can fail without throwing: the context may be lost
// mid-session, or it can attach but silently never paint, leaving the
// terminal's helper textarea at 0x0. Detect both cases and fall back to the
// canvas renderer so the terminal stays interactive.
try {
this.webglAddon = new addons.webgl.WebglAddon();
this.webglAddon.onContextLoss(() => {
if (!this.isUnmounted && this.terminal) {
this.fallbackToCanvas(terminal, addons);
}
});
terminal.loadAddon(this.webglAddon);
// Detect the silent "attaches but never paints" failure after a render frame.
requestAnimationFrame(() => {
if (this.isUnmounted || !this.terminal) {
return;
}
const textarea = this.$refs.xterm?.querySelector('.xterm-helper-textarea');
const hasPainted = textarea && textarea.getBoundingClientRect().height > 0;
if (this.webglAddon && !hasPainted) {
this.fallbackToCanvas(terminal, addons);
this.fit();
}
});
} catch (e) {
// Some browsers (e.g. Safari) don't support the webgl renderer, so don't use it.
this.fallbackToCanvas(terminal, addons);
}
this.fit();
this.flush();
this.scheduleDrainQueue();
terminal.onData((input) => {
const msg = this.str2ab(input);
@@ -123,9 +169,132 @@ export default {
this.write(msg);
});
terminal.attachCustomKeyEventHandler((event) => {
const key = event.key?.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'f') {
event.preventDefault();
this.openSearch();
return false;
}
if (key === 'escape' && this.showSearch) {
event.preventDefault();
this.closeSearch();
return false;
}
return true;
});
this.terminal = terminal;
},
openSearch() {
this.showSearch = true;
this.$nextTick(() => {
const input = this.$refs.searchInput;
if (input) {
input.focus();
input.select();
}
});
},
closeSearch() {
this.showSearch = false;
this.searchQuery = '';
this.clearSearchHighlights();
this.terminal?.focus();
},
clearSearchHighlights() {
this.searchAddon?.clearActiveDecoration();
this.searchAddon?.clearDecorations();
this.terminal?.clearSelection();
// Disposing the search decorations doesn't repaint the webgl/canvas
// layer, so force a refresh to remove the lingering highlight.
if (this.terminal) {
this.terminal.refresh(0, this.terminal.rows - 1);
}
},
findNext() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findNext(this.searchQuery, this.searchOptions);
},
findPrevious() {
if (!this.searchAddon || !this.searchQuery) {
return;
}
this.searchAddon.findPrevious(this.searchQuery, this.searchOptions);
},
onSearchInput() {
if (!this.searchQuery) {
this.clearSearchHighlights();
return;
}
this.findNext();
},
onSearchKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
this.findPrevious();
} else {
this.findNext();
}
}
if (event.key === 'Escape') {
event.preventDefault();
this.closeSearch();
}
},
scheduleFit() {
this.$nextTick(() => {
requestAnimationFrame(() => {
this.fit();
// Re-fit after layout settles to avoid undersized rows on first paint.
setTimeout(() => this.fit(), 120);
});
});
},
// Dispose the webgl renderer (if any) and switch to the canvas renderer.
// Used when webgl fails to construct, loses its context, or silently never paints.
fallbackToCanvas(terminal, addons) {
if (this.isUnmounted) {
return;
}
this.webglAddon?.dispose();
this.webglAddon = null;
if (!this.canvasAddon) {
this.canvasAddon = new addons.canvas.CanvasAddon();
terminal.loadAddon(this.canvasAddon);
}
this.fit();
},
str2ab(str) {
const enc = new TextEncoder();
@@ -140,12 +309,131 @@ export default {
}
},
enqueueMessage(messagePromise) {
this.queue.push(messagePromise);
this.scheduleDrainQueue();
},
scheduleDrainQueue() {
if (this.drainScheduled || this.isDraining || !this.terminal) {
return;
}
this.drainScheduled = true;
const shouldDrainImmediately = this.queue.length <= 3;
const runDrain = () => {
this.drainScheduled = false;
this.drainTimer = null;
this.drainQueue();
};
if (shouldDrainImmediately) {
runDrain();
return;
}
this.drainTimer = requestAnimationFrame(runDrain);
},
async decodeMessageData(data) {
if (typeof data === 'string') {
return data;
}
if (!this.textDecoder) {
this.textDecoder = new TextDecoder('utf-8');
}
if (data instanceof Blob) {
const buffer = await data.arrayBuffer();
return this.textDecoder.decode(new Uint8Array(buffer), { stream: true });
}
if (data instanceof ArrayBuffer) {
return this.textDecoder.decode(new Uint8Array(data), { stream: true });
}
if (ArrayBuffer.isView(data)) {
const view = data;
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
return this.textDecoder.decode(bytes, { stream: true });
}
return String(data || '');
},
async drainQueue() {
if (this.isDraining || !this.terminal) {
return;
}
this.isDraining = true;
try {
while (this.queue.length > 0) {
const pendingMessages = this.queue.splice(0, this.queue.length);
const settledMessages = await Promise.allSettled(pendingMessages);
const messages = settledMessages
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
const output = messages.filter(Boolean).join('');
if (output) {
await this.writeOutputInChunks(output);
}
if (this.queue.length > 0) {
await this.nextFrame();
}
}
} finally {
this.isDraining = false;
// If data arrived between the last while-check and releasing the lock,
// immediately schedule another drain so output never gets stuck.
if (this.queue.length > 0 && this.terminal) {
this.scheduleDrainQueue();
}
}
},
async writeOutputInChunks(output) {
if (!output) {
return;
}
const chunkSize = output.length > 20000 ? 16000 : output.length > 8000 ? 8000 : output.length;
if (chunkSize === output.length) {
this.terminal.write(output);
return;
}
for (let index = 0; index < output.length; index += chunkSize) {
this.terminal.write(output.slice(index, index + chunkSize));
if (index + chunkSize < output.length) {
await this.nextFrame();
}
}
},
nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
},
clear() {
this.terminal.clear();
},
getSocketUrl() {
return `${ this.value?.getSerialConsolePath }`;
return this.serialConsoleUrl;
},
async connect() {
@@ -181,6 +469,7 @@ export default {
this.fit();
this.flush();
}
this.scheduleFit();
if (this.firstTime) {
this.socket.send(this.str2ab('\n'));
@@ -195,7 +484,7 @@ export default {
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
this.queue.push(e.detail.data.text());
this.enqueueMessage(this.decodeMessageData(e.detail.data));
});
this.socket.connect();
@@ -218,22 +507,19 @@ export default {
}
this.fitAddon.fit();
const { rows, cols } = this.fitAddon.proposeDimensions();
if ( !this.isOpen ) {
return;
}
const message = JSON.stringify({
Width: cols,
Height: rows
});
this.socket.send(this.str2ab(message));
},
close() {
if (this.onResize) {
window.removeEventListener('resize', this.onResize);
this.onResize = null;
}
if (this.drainTimer) {
cancelAnimationFrame(this.drainTimer);
this.drainTimer = null;
}
if ( this.socket ) {
this.socket.disconnect();
}
@@ -248,6 +534,44 @@ export default {
<template>
<div class="harvester-shell-container">
<div
v-if="showSearch"
class="shell-search"
>
<input
ref="searchInput"
v-model="searchQuery"
type="text"
class="shell-search-input"
placeholder="Search in terminal"
@input="onSearchInput"
@keydown="onSearchKeydown"
>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Shift+Enter"
@click="findPrevious"
>
Prev
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Enter"
@click="findNext"
>
Next
</button>
<button
class="btn role-secondary btn-sm shell-search-btn"
type="button"
title="Esc"
@click="closeSearch"
>
Close
</button>
</div>
<div
ref="xterm"
class="shell-body"
@@ -257,18 +581,73 @@ export default {
</template>
<style lang="scss">
@import '../../../../node_modules/xterm/css/xterm.css';
@import '../../../../node_modules/@xterm/xterm/css/xterm.css';
body, #__nuxt, #__layout, MAIN {
height: 100%;
}
.harvester-shell-container {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
min-height: 0;
overflow: hidden;
.shell-body, .terminal.xterm {
.resize-observer {
display: none;
}
.shell-search {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
position: absolute;
top: 8px;
right: 10px;
z-index: 5;
padding: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--body-bg) 88%, transparent);
}
.shell-search-input {
min-width: 220px;
max-width: 420px;
flex: 0 1 320px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--body-bg);
color: var(--body-text);
padding: 6px 8px;
}
.shell-search-btn {
min-width: 64px;
}
.shell-body {
flex: 1 1 auto;
height: 100%;
width: 100%;
min-height: 0;
padding: 0 10px;
box-sizing: border-box;
}
.terminal.xterm,
.terminal.xterm .xterm-viewport,
.terminal.xterm .xterm-screen,
.terminal.xterm .xterm-scrollable-element {
width: 100%;
height: 100%;
}
.terminal.xterm .xterm-scrollable-element {
min-height: 100%;
}
}
</style>
+17 -12
View File
@@ -231,14 +231,21 @@ export default {
v-if="setting.customized"
class="modified"
>
Modified
{{ t('advancedSettings.tags.modified.label') }}
</span>
<span
v-if="setting.experimental"
v-clean-tooltip="t('advancedSettings.experimental')"
v-clean-tooltip="t('advancedSettings.tags.experimental.description')"
class="experimental"
>
Experimental
{{ t('advancedSettings.tags.experimental.label') }}
</span>
<span
v-if="setting.preview"
v-clean-tooltip="t('advancedSettings.tags.preview.description')"
class="preview"
>
{{ t('advancedSettings.tags.preview.label') }}
</span>
</h1>
<h2 v-clean-html="t(setting.description, getDocLinkParams(setting) || {}, true)">
@@ -352,21 +359,19 @@ export default {
flex: 1;
}
.modified {
.modified,
.experimental,
.preview {
margin-left: 10px;
border: 1px solid var(--primary);
border: 1px solid;
border-radius: 5px;
padding: 2px 10px;
font-size: 12px;
}
.experimental {
margin-left: 10px;
border: 1px solid var(--error);
border-radius: 5px;
padding: 2px 10px;
font-size: 12px;
}
.modified { border-color: var(--primary); }
.experimental { border-color: var(--error); }
.preview { border-color: var(--warning); }
.no-search-match {
text-align: center;
+5 -1
View File
@@ -55,6 +55,10 @@ export default {
const host = window.location.host;
const prefix = window.location.pathname.replace(this.$route.path, '');
const params = this.$route?.params;
const popupWidth = Math.max(800, screen.width - 200);
const popupHeight = Math.max(600, screen.height - 200);
const popupLeft = Math.max(0, window.screenX + Math.floor((window.outerWidth - popupWidth) / 2));
const popupTop = Math.max(0, window.screenY + Math.floor((window.outerHeight - popupHeight) / 2));
const url = `https://${ host }${ prefix }/${ PRODUCT_NAME }/c/${ params.cluster }/console/${ uid }/${ type }`;
@@ -63,7 +67,7 @@ export default {
window.open(
url,
'_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`
);
});
},
@@ -15,7 +15,7 @@ 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 DEFAULT_ACCESS_MODE = ACCESS_MODE?.READ_WRITE_MANY ?? 'ReadWriteMany';
const props = defineProps({
providerName: { type: String, default: '' },
@@ -268,9 +268,9 @@ const buildStorageEntries = () => {
capacity: 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
accessModes: [DEFAULT_ACCESS_MODE],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
@@ -310,9 +310,9 @@ const buildStorageEntriesFromProvider = (datastoresData) => {
capacity: ds.capacity || 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
accessModes: [DEFAULT_ACCESS_MODE],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedAccessModes: [DEFAULT_ACCESS_MODE],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
@@ -273,19 +273,6 @@ const startMigrationAction = async() => {
await plan.save();
const planOwnerRef = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
networkMap.metadata.ownerReferences = [planOwnerRef];
await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
@@ -8,16 +8,22 @@ 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 {
READ_WRITE_ONCE = 'ReadWriteOnce',
READ_ONLY_MANY = 'ReadOnlyMany',
READ_WRITE_MANY = 'ReadWriteMany',
READ_WRITE_ONCE_POD = 'ReadWriteOncePod'
} = ACCESS_MODE || {};
const ACCESS_MODE_OPTIONS = [READ_WRITE_ONCE, READ_WRITE_MANY, READ_ONLY_MANY, READ_WRITE_ONCE_POD];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
accessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
accessModes: { type: Array, default: () => ['ReadWriteMany'] },
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
inheritedAccessModes: { type: Array, default: () => ['ReadWriteMany'] },
});
const emit = defineEmits(['apply', 'close']);
@@ -26,7 +32,7 @@ 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 localAccessMode = ref(props.accessModes?.[0] || READ_WRITE_MANY);
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
@@ -42,7 +48,7 @@ const apply = () => {
// 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;
localAccessMode.value = props.inheritedAccessModes?.[0] || READ_WRITE_MANY;
};
const cancel = () => {
+3 -1
View File
@@ -111,15 +111,17 @@ export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.KUBECONFIG_DEFAULT_TOKEN_TTL_MINUTES]: { kind: 'number', featureFlag: 'kubeconfigDefaultTokenTTLMinutesSetting' },
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_ENABLED]: {
kind: 'boolean',
experimental: true,
preview: true,
featureFlag: 'longhornV2LVMSupport'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_HUGEPAGE_ENABLED]: {
kind: 'boolean',
preview: true,
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.LONGHORN_V2_DATA_ENGINE_MEMORY_SIZE]: {
kind: 'number',
preview: true,
featureFlag: 'longhornV2HugepageSettings'
},
[HCI_SETTING.ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO]: { kind: 'string', from: 'import' },
@@ -390,14 +390,17 @@ export default {
:vmi-resource="vmi"
:vmim-resource="vmim"
/>
<DashboardMetrics
<div
v-if="showVmMetrics && liveMigrationProgressEnabled"
:detail-url="VM_MIGRATION_DETAIL_URL"
graph-height="640px"
:has-summary-and-detail="false"
:vars="graphVars"
class="mb-30"
/>
class="migration-metrics-scroll mb-30"
>
<DashboardMetrics
:detail-url="VM_MIGRATION_DETAIL_URL"
graph-height="960px"
:has-summary-and-detail="false"
:vars="graphVars"
/>
</div>
<Events
v-if="liveMigrationProgressEnabled"
:resource="vmi"
@@ -436,3 +439,10 @@ export default {
</Tabbed>
</div>
</template>
<style lang="scss" scoped>
.migration-metrics-scroll {
max-height: 640px;
overflow-y: auto;
}
</style>
+14 -3
View File
@@ -9,7 +9,7 @@ import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types';
import { set } from '@shell/utils/object';
import { set, remove } from '@shell/utils/object';
import ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types';
@@ -142,10 +142,12 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore;
const vlans = this.$store.getters[`${ inStore }/all`](HCI.VLAN) || [];
return vlans.map((vlan) => ({
const options = vlans.map((vlan) => ({
label: vlan.id,
value: vlan.id,
}));
return [{ label: this.t('generic.none'), value: '' }, ...options];
}
},
@@ -160,6 +162,14 @@ export default {
}
},
methods: {
onVlanChange(value) {
if (value === '') {
remove(this.value.spec, 'vlan');
} else {
set(this.value, 'spec.vlan', value);
}
},
async saveSubnet(buttonCb) {
const errors = [];
const name = this.value?.metadata?.name;
@@ -283,12 +293,13 @@ export default {
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.vlan"
:value="value.spec.vlan ?? ''"
class="mb-20"
:options="vlanOptions"
:placeholder="t('harvester.subnet.vlan.placeholder')"
:label="t('harvester.subnet.vlan.label')"
:mode="mode"
@update:value="onVlanChange"
/>
</div>
</div>
@@ -246,4 +246,27 @@ export default {
.group-actions {
display: inline;
}
// Match the enable/disable passthrough bulk-action buttons height to the .btn (40px) on the right.
:deep(.bulk .rc-button.btn-medium.bulk-action:not(.btn-sm)) {
min-height: 40px;
}
// Make the collapsed "Actions" dropdown button 40px too.
:deep(.bulk .rc-button.btn-medium.bulk-actions-dropdown:not(.btn-sm)) {
min-height: 40px;
}
// Lay the bulk row out with flex so the "N selected" label can wrap below the buttons.
:deep(.bulk) {
display: flex;
flex-wrap: wrap;
align-items: center;
}
:deep(.bulk .action-availability) {
flex-basis: 100%;
margin-left: 0;
margin-top: 6px;
}
</style>
@@ -5,6 +5,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import Banner from '@components/Banner/Banner.vue';
import remove from 'lodash/remove';
import { set } from '@shell/utils/object';
import { uniq } from '@shell/utils/array';
import { HCI } from '../../../types';
import DeviceList from './DeviceList';
import CompatibilityMatrix from '../CompatibilityMatrix';
@@ -60,7 +61,15 @@ export default {
row.allowDisable = !vmDeviceNames.includes(row.metadata.name);
});
vmDevices.forEach(({ name }) => {
// When off, read from spec; otherwise the spec name is a placeholder ('provisioned'),
// so read the real allocated device names from the deviceAllocationDetails annotation.
const hostDeviceNames = this.vm.isOff ? [
...vmDevices.map(({ name }) => name),
] : [
...Object.values(this.vm?.provisionedHostDevices || {}).reduce((acc, devices) => [...acc, ...devices], []),
];
uniq(hostDeviceNames).forEach((name) => {
if (this.enabledDevices.find((device) => device?.metadata?.name === name)) {
selectedDevices.push(name);
}
@@ -63,6 +63,7 @@ export default {
RANCHER_TYPES,
fvFormRuleSets: [{ path: 'spec.displayName', rules: ['required'] }],
canEditPSPBindings: true,
isQuotasValid: true,
};
},
computed: {
@@ -190,15 +191,13 @@ export default {
this['membershipUpdate'] = update;
},
removeQuota(key) {
['resourceQuota', 'namespaceDefaultResourceQuota'].forEach((specProp) => {
if (this.value?.spec[specProp]?.limit && this.value?.spec[specProp]?.limit[key]) {
delete this.value?.spec[specProp]?.limit[key];
}
if (this.value?.spec[specProp]?.usedLimit && this.value?.spec[specProp]?.usedLimit[key]) {
delete this.value?.spec[specProp]?.usedLimit[key];
}
});
validateQuotas(isValid) {
this.isQuotasValid = isValid;
},
onQuotasInput({ projectLimit, nsLimit } = {}) {
this.value.spec.resourceQuota = { ...this.value.spec.resourceQuota, limit: projectLimit };
this.value.spec.namespaceDefaultResourceQuota = { ...this.value.spec.namespaceDefaultResourceQuota, limit: nsLimit };
}
},
};
@@ -212,7 +211,7 @@ export default {
:resource="value"
:subtypes="[]"
:can-yaml="false"
:validation-passed="fvFormIsValid"
:validation-passed="fvFormIsValid && isQuotasValid"
@error="e=>errors=e"
@finish="save"
@cancel="done"
@@ -267,10 +266,11 @@ export default {
:weight="9"
>
<ResourceQuota
v-model:value="value"
:value="value"
:mode="canEditTabElements"
:types="isStandaloneHarvester ? HARVESTER_TYPES : RANCHER_TYPES"
@remove="removeQuota"
@input="onQuotasInput"
@validationChanged="validateQuotas"
/>
</Tab>
<Tab
+12 -4
View File
@@ -835,13 +835,13 @@ harvester:
title: "Network Data:"
tip: "The network-data configuration allows you to customize the instance's networking interfaces by assigning subnet configuration, virtual device creation (bonds, bridges, VLANs) routes and DNS configuration. <a href='https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v1.html' target='_blank'>Learn more</a>"
sysprep:
title: Windows Sysprep Configuration
title: Windows Unattended & Sysprep Configuration
description: "Configure Windows automated installation using autounattend.xml. The configuration will be stored in a Kubernetes Secret and mounted as a CD-ROM during installation. <a href='https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/automate-windows-setup' target='_blank'>Learn more</a>"
createNew: Create new...
createNewTitle: Create Windows Sysprep Template
createNewTitle: Create Windows Unattended & Sysprep Template
xmlContent: autounattend.xml content
secret:
label: Windows Sysprep Template
label: Windows Unattended & Sysprep Template
validation:
invalidXml: Invalid XML syntax
invalidNamespace: Invalid unattend.xml namespace. Expected 'urn:schemas-microsoft-com:unattend'
@@ -2417,7 +2417,15 @@ typeDescription:
harvester: "Branding allows administrators to globally re-brand the UI by customizing the Harvester product name, logos and color scheme."
advancedSettings:
experimental: 'Experimental features allow users to test and evaluate early-access functionality prior to official supported releases'
tags:
modified:
label: Modified
experimental:
label: Experimental
description: 'Experimental: The feature is incomplete but its essential functionality is operational. The feature is not recommended for use in production environments.'
preview:
label: Technical Preview
description: 'Technical Preview: The feature is almost complete and its functionality is not expected to change significantly. Explore the feature extensively before using in production environments.'
descriptions:
'harv-vlan': Default Network Interface name of the VLAN network.
'harv-backup-target': Custom backup target to store virtual machine backups.
+12
View File
@@ -38,6 +38,10 @@ export const SSH_EXISTING_TYPE = {
export default {
methods: {
hasCloudConfigComment(userScript) {
if (typeof userScript === 'string' && /^\s*#cloud-config(\s|$)/.test(userScript)) {
return true;
}
// Check that userData contains: #cloud-config
const userDataDoc = userScript ? YAML.parseDocument(userScript) : YAML.parseDocument({});
const items = userDataDoc?.contents?.items || [];
@@ -52,6 +56,14 @@ export default {
exist = true;
}
if (userDataDoc?.contents?.commentBefore === 'cloud-config' || userDataDoc?.contents?.commentBefore?.includes('cloud-config\n')) {
exist = true;
}
if (userDataDoc?.contents?.comment === 'cloud-config' || userDataDoc?.contents?.comment?.includes('cloud-config\n')) {
exist = true;
}
items.map((item) => {
const key = item.key;
+3 -2
View File
@@ -1211,9 +1211,10 @@ export default {
userDataDoc = config.installAgent ? this.mergeQGA({ userDataDoc, ...config }) : this.deleteQGA({ userDataDoc, ...config });
const userDataYaml = userDataDoc.toString();
const userDataValue = userDataDoc.toJSON();
if (userDataYaml === '{}\n') {
// When the YAML parsed value is '{}\n', it means that the userData is empty, then undefined is returned.
if (userDataValue === null || (typeof userDataValue === 'object' && !Array.isArray(userDataValue) && isEmpty(userDataValue))) {
// Empty userData should not create cloud-init content like `null`.
return undefined;
}
+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) {
return data;
}
@@ -5,6 +5,7 @@ import SYSTEM_NAMESPACES from '@shell/config/system-namespaces';
import { get } from '@shell/utils/object';
import { NAMESPACE } from '@shell/config/types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '@pkg/harvester/config/harvester';
import { FORKLIFT_NAMESPACE } from '@pkg/harvester/config/harvester-map';
import { HCI } from '../../types';
const OBSCURE_NAMESPACE_PREFIX = [
@@ -93,6 +94,10 @@ export default class HciNamespace extends namespace {
}
get isSystem() {
if (this.metadata?.name === FORKLIFT_NAMESPACE) {
return false;
}
const systemNamespaces = ['fleet-default'];
if (systemNamespaces.includes(this.metadata.name)) {
@@ -1315,6 +1315,18 @@ export default class VirtVm extends HarvesterResource {
}
}
get provisionedHostDevices() {
// Rancher-provisioned VMs store hostDevices.name as a placeholder ('provisioned'); the
// real allocated PCI device names live in this annotation, keyed by resourceName.
try {
const deviceAllocationDetails = JSON.parse(this.metadata?.annotations[HCI_ANNOTATIONS.VM_DEVICE_ALLOCATION_DETAILS] || '{}');
return deviceAllocationDetails?.hostdevices || {};
} catch (error) {
return {};
}
}
get schedulingVMBackupFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('schedulingVMBackup');
}
@@ -1,10 +1,9 @@
<script>
import Loading from '@shell/components/Loading';
import { HCI } from '../../../../../types';
import SerialConsole from '../../../../../components/SerialConsole';
export default {
components: { SerialConsole, Loading },
components: { SerialConsole },
async fetch() {
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
@@ -38,16 +37,44 @@ export default {
</script>
<template>
<Loading v-if="$fetchState.pending" />
<SerialConsole
v-else
ref="serialConsole"
v-model:value="vmi"
/>
<div class="serial-console-page">
<SerialConsole
ref="serialConsole"
:value="vmi || {}"
/>
</div>
</template>
<style lang="scss" scoped>
body, #__nuxt, #__layout, main {
<style lang="scss">
html,
body,
#__nuxt,
#__layout,
main {
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>
@@ -68,29 +68,35 @@ export default defineComponent({
return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || '';
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => ({
name: resource?.metadata?.name,
namespace: resource?.metadata?.namespace || namespace,
}));
.map((resource) => resource?.metadata?.name)
.filter(Boolean);
const matchedNetworkMap = existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => this.ownedByPlan(resource, plan));
const matchedStorageMap = existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => this.ownedByPlan(resource, plan));
const matchedNetworkMap = networkMapRef ? existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMapRef.name && resource?.metadata?.namespace === networkMapRef.namespace) : null;
const matchedStorageMap = storageMapRef ? existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMapRef.name && resource?.metadata?.namespace === storageMapRef.namespace) : null;
return {
planName,
namespace,
migrations,
networkMap: matchedNetworkMap ? {
namespace: matchedNetworkMap?.metadata?.namespace || namespace,
namespace: matchedNetworkMap?.metadata?.namespace || networkMapRef?.namespace || namespace,
name: matchedNetworkMap?.metadata?.name,
} : null,
} : (networkMapRef ? {
namespace: networkMapRef.namespace,
name: networkMapRef.name,
} : null),
storageMap: matchedStorageMap ? {
namespace: matchedStorageMap?.metadata?.namespace || namespace,
namespace: matchedStorageMap?.metadata?.namespace || storageMapRef?.namespace || namespace,
name: matchedStorageMap?.metadata?.name,
} : null,
} : (storageMapRef ? {
namespace: storageMapRef.namespace,
name: storageMapRef.name,
} : null),
};
});
},
@@ -113,6 +119,20 @@ export default defineComponent({
methods: {
resourceNames,
getPlanMapRef(plan, mapType) {
const ref = plan?.spec?.map?.[mapType];
const name = ref?.name;
if (!name) {
return null;
}
return {
name,
namespace: ref?.namespace || plan?.metadata?.namespace || '',
};
},
/**
* Determine whether a related resource (migration / network map / storage map)
* is owned by the given plan by inspecting its `metadata.ownerReferences`.
@@ -137,9 +157,8 @@ export default defineComponent({
const existing = this.existingRelatedResources;
const targets = new Map();
const addTarget = (type, resource) => {
const name = resource?.metadata?.name;
const ns = resource?.metadata?.namespace;
const addTarget = (type, name, namespace) => {
const ns = namespace || '';
if (!name) {
return;
@@ -153,17 +172,20 @@ export default defineComponent({
};
for (const plan of this.value || []) {
existing[HCI.FORKLIFT_NETWORK_MAP]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_NETWORK_MAP, resource));
const networkMapRef = this.getPlanMapRef(plan, 'network');
const storageMapRef = this.getPlanMapRef(plan, 'storage');
existing[HCI.FORKLIFT_STORAGE_MAP]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_STORAGE_MAP, resource));
if (networkMapRef) {
addTarget(HCI.FORKLIFT_NETWORK_MAP, networkMapRef.name, networkMapRef.namespace);
}
if (storageMapRef) {
addTarget(HCI.FORKLIFT_STORAGE_MAP, storageMapRef.name, storageMapRef.namespace);
}
existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource));
.forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource?.metadata?.name, resource?.metadata?.namespace));
}
return [...targets.values()];
@@ -256,11 +278,11 @@ export default defineComponent({
<div>• {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
<template v-if="summary.migrations.length">
<div
v-for="migration in summary.migrations"
:key="`${ migration.namespace }/${ migration.name }`"
v-for="migrationName in summary.migrations"
:key="migrationName"
>
<div class="ml-20">
- {{ migration.name }}
- {{ migrationName }}
</div>
</div>
</template>
+1804 -1463
View File
File diff suppressed because it is too large Load Diff