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