mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-11-15 13:26:15 +08:00
feat: improve VNC console with a version gate, in-use screen and confirmations (#1195)
* feat: improve vnc console Signed-off-by: Andy Lee <andy.lee@suse.com> * fix: keep VNC console retries from kicking the active session Builds on "feat: improve vnc console" (#1147) by Andy Lee. - Keep retrying with preserveSession after a failed reconnect attempt, so a displaced console waits for the other session to be released instead of failing immediately or taking the console back. - Close the RFB socket when noVNC asks for credentials, so a failed console does not hold the VNC session. - Restore Custom Shortcut Keys, which were removed in #1147. - Add unit tests for the console reconnect behavior. Related: harvester/harvester#10420 Signed-off-by: Alejandro Bonilla <abonilla@suse.com> * fix: expose VNC quality and compression settings - Add 0-9 sliders for the JPEG quality and compression level, which were already passed to noVNC but could not be changed. - Mark the connection status as a polite live region so screen readers announce connection changes. Related: harvester/harvester#10420 Signed-off-by: Alejandro Bonilla <abonilla@suse.com> * fix: gate VNC session protection by version and rework the in-use screen - Add the vncPreserveSession feature flag for Harvester v1.7.0, the first release whose KubeVirt (1.6.3) honours preserveSession. Without it the console connects as before, offers no Take Over and does not reconnect on its own after the session drops, so two open consoles no longer keep kicking each other out. - Replace the error page with a short card that says when the console is in use, and confirm Take Over in a modal instead of window.confirm. - Keep the overlay text readable on its dark background. - Show "Connected (view only)", translate the Ctrl+Alt+Del label and remove unused translation keys. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: show whether the VNC console has keyboard focus noVNC hides the canvas focus outline, so nothing showed where typing would go. Draw a ring around the console while it has focus, show a toolbar hint ("Typing goes to the virtual machine" / "Click the console to type"), focus the console when it connects and return focus to it after sending keys from the toolbar. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: confirm Ctrl+Alt+Del and Soft Reboot in the VNC console Both buttons can restart the virtual machine with one click: Soft Reboot always, and Ctrl+Alt+Del on most Linux guests. Ask in a modal first, as the VM list already does for Soft Reboot, and return focus to the console after. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: return focus to the VNC console after its confirm modal closes The modal gave focus back to the toolbar button that opened it, so typing did not reach the console until it was clicked again. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * refactor: drop the VNC keyboard focus hint from the toolbar The ring around the console already shows when it has keyboard focus. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> --------- Signed-off-by: Andy Lee <andy.lee@suse.com> Signed-off-by: Alejandro Bonilla <abonilla@suse.com> Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> Co-authored-by: Andy Lee <andy.lee@suse.com> Co-authored-by: Alejandro Bonilla <abonilla@suse.com>
This commit is contained in:
co-authored by
Andy Lee
Alejandro Bonilla
parent
5406c08f76
commit
2f72f25b6e
@@ -1,153 +1,540 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="connected && disconnected">
|
||||
<main class="main-layout error">
|
||||
<div class="text-center">
|
||||
<BrandImage
|
||||
file-name="error-desert-landscape.svg"
|
||||
width="900"
|
||||
height="300"
|
||||
/>
|
||||
<h1>
|
||||
{{ t('generic.notification.title.warning') }}
|
||||
</h1>
|
||||
<h2 class="text-secondary mt-20">
|
||||
{{ t('vncConsole.error.message') }}
|
||||
</h2>
|
||||
</div>
|
||||
</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';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
import ZoomableRFB from './ZoomableRFB';
|
||||
|
||||
export const CONSOLE_STATUS = {
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
RECONNECTING: 'reconnecting',
|
||||
DISCONNECTED: 'disconnected',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
export const CONSOLE_FAILURE = {
|
||||
IN_USE: 'inUse',
|
||||
UNAVAILABLE: 'unavailable',
|
||||
DROPPED: 'dropped',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
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: { ModalWithCard },
|
||||
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
settings: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
|
||||
// KubeVirt honours preserveSession from v1.6.3. Older versions ignore it, so every
|
||||
// connection takes the console over and automatic retries would kick the other user.
|
||||
preserveSession: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
vmRunning: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
},
|
||||
|
||||
components: { BrandImage },
|
||||
emits: ['status-changed', 'capabilities'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
rfb: null,
|
||||
connected: false,
|
||||
disconnected: false,
|
||||
reconnectDelay: 3000,
|
||||
reconnecting: false,
|
||||
maximumRetryTimes: 10,
|
||||
status: CONSOLE_STATUS.CONNECTING,
|
||||
statusDetail: '',
|
||||
desktopName: '',
|
||||
retryTimes: 0,
|
||||
setTimeout: null,
|
||||
maximumRetryTimes: MAXIMUM_RETRY_TIMES,
|
||||
reconnectTimer: null,
|
||||
isClosed: false,
|
||||
failure: '',
|
||||
showTakeOverModal: false,
|
||||
focused: 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 || (this.status === CONSOLE_STATUS.DISCONNECTED && this.failure === CONSOLE_FAILURE.DROPPED);
|
||||
},
|
||||
|
||||
canTakeOver() {
|
||||
return this.preserveSession && !this.isClosed;
|
||||
},
|
||||
|
||||
failureTitle() {
|
||||
return this.t(`harvester.virtualMachine.detail.console.failure.${ this.failure || CONSOLE_FAILURE.ERROR }`);
|
||||
},
|
||||
|
||||
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() {
|
||||
// noVNC hides the canvas focus outline, so track focus to show where typing goes.
|
||||
this.$refs.view.addEventListener('focusin', this.onFocusIn);
|
||||
this.$refs.view.addEventListener('focusout', this.onFocusOut);
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.connect();
|
||||
});
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.clearTimeout();
|
||||
this.$refs.view?.removeEventListener('focusin', this.onFocusIn);
|
||||
this.$refs.view?.removeEventListener('focusout', this.onFocusOut);
|
||||
this.isClosed = true;
|
||||
this.clearReconnectTimer();
|
||||
this.teardown();
|
||||
},
|
||||
|
||||
methods: {
|
||||
connect() {
|
||||
const rfb = new RFB(this.$refs.view, this.url);
|
||||
connect(takeOver = false) {
|
||||
if (!this.url || this.isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
this.clearTimeout();
|
||||
this.teardown();
|
||||
|
||||
this.connected = true;
|
||||
this.retryTimes = 0;
|
||||
this.reconnecting = false;
|
||||
});
|
||||
this.failure = '';
|
||||
this.status = this.retryTimes > 0 ? CONSOLE_STATUS.RECONNECTING : CONSOLE_STATUS.CONNECTING;
|
||||
|
||||
rfb.addEventListener('disconnect', (e) => {
|
||||
this.clearTimeout();
|
||||
let rfb;
|
||||
|
||||
this.disconnected = true;
|
||||
this.rfb = null;
|
||||
this.reconnect();
|
||||
});
|
||||
try {
|
||||
const url = new URL(this.url);
|
||||
|
||||
if (this.preserveSession) {
|
||||
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() {
|
||||
this.showTakeOverModal = true;
|
||||
},
|
||||
|
||||
cancelTakeOver() {
|
||||
this.showTakeOverModal = false;
|
||||
},
|
||||
|
||||
confirmTakeOver() {
|
||||
this.showTakeOverModal = false;
|
||||
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();
|
||||
this.focus();
|
||||
},
|
||||
|
||||
focus() {
|
||||
this.rfb?.focus();
|
||||
},
|
||||
|
||||
onFocusIn() {
|
||||
this.focused = true;
|
||||
},
|
||||
|
||||
onFocusOut() {
|
||||
this.focused = false;
|
||||
},
|
||||
|
||||
onDisconnect() {
|
||||
const wasConnected = this.status === CONSOLE_STATUS.CONNECTED;
|
||||
const wasRetrying = this.status === CONSOLE_STATUS.RECONNECTING;
|
||||
|
||||
this.teardown(false);
|
||||
|
||||
if (this.isClosed) {
|
||||
if (this.status !== CONSOLE_STATUS.FAILED) {
|
||||
this.status = CONSOLE_STATUS.DISCONNECTED;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasConnected && !this.preserveSession) {
|
||||
// Without preserveSession a retry would take the console back from whoever
|
||||
// opened it, and the two sessions would keep kicking each other out.
|
||||
this.clearReconnectTimer();
|
||||
this.failure = CONSOLE_FAILURE.DROPPED;
|
||||
this.statusDetail = this.t('harvester.virtualMachine.detail.console.failureDetail.dropped');
|
||||
this.status = CONSOLE_STATUS.DISCONNECTED;
|
||||
} else if (wasConnected) {
|
||||
this.scheduleReconnect();
|
||||
} else if (wasRetrying) {
|
||||
// Retries use preserveSession, so they fail while another session owns the
|
||||
// console. Keep retrying so the console is picked up once it is released,
|
||||
// instead of kicking the other session back out.
|
||||
this.statusDetail = this.t('harvester.virtualMachine.detail.console.status.inUse');
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.clearReconnectTimer();
|
||||
this.fail(this.preserveSession && this.vmRunning ? CONSOLE_FAILURE.IN_USE : CONSOLE_FAILURE.UNAVAILABLE);
|
||||
}
|
||||
},
|
||||
|
||||
// The browser cannot see why a WebSocket handshake failed. With preserveSession and a
|
||||
// running VM, a refused connection almost always means another session holds the console.
|
||||
fail(failure, detail) {
|
||||
this.failure = failure;
|
||||
this.statusDetail = detail ?? this.t(`harvester.virtualMachine.detail.console.failureDetail.${ failure }`);
|
||||
this.status = CONSOLE_STATUS.FAILED;
|
||||
},
|
||||
|
||||
// Authentication problems will not fix themselves, so stop retrying.
|
||||
onSecurityFailure(e) {
|
||||
this.isClosed = true;
|
||||
this.clearReconnectTimer();
|
||||
this.fail(CONSOLE_FAILURE.ERROR, e?.detail?.reason || '');
|
||||
},
|
||||
|
||||
// noVNC pauses the handshake until credentials are sent, so close the socket
|
||||
// to avoid holding the console while the failure is shown.
|
||||
onCredentialsRequired() {
|
||||
this.isClosed = true;
|
||||
this.clearReconnectTimer();
|
||||
this.teardown();
|
||||
this.fail(CONSOLE_FAILURE.ERROR, this.t('harvester.virtualMachine.detail.console.status.credentialsRequired'));
|
||||
},
|
||||
|
||||
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.reconnecting = false;
|
||||
this.connected = true;
|
||||
this.disconnected = true;
|
||||
this.fail(this.vmRunning ? CONSOLE_FAILURE.IN_USE : CONSOLE_FAILURE.UNAVAILABLE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.retryTimes += 1;
|
||||
this.reconnecting = true;
|
||||
this.connected = false;
|
||||
this.disconnected = false;
|
||||
this.status = CONSOLE_STATUS.RECONNECTING;
|
||||
|
||||
this.setTimeout = setTimeout(() => {
|
||||
const delay = Math.min(BASE_RECONNECT_DELAY * (2 ** (this.retryTimes - 1)), MAXIMUM_RECONNECT_DELAY);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}, delay);
|
||||
},
|
||||
|
||||
clearTimeout() {
|
||||
if (this.setTimeout) {
|
||||
clearTimeout(this.setTimeout);
|
||||
clearReconnectTimer() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.rfb.disconnect();
|
||||
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();
|
||||
this.rfb?.sendCtrlAltDel();
|
||||
},
|
||||
|
||||
sendKey(keysym, code, down) {
|
||||
this.rfb.sendKey(keysym, code, down);
|
||||
}
|
||||
this.rfb?.sendKey(keysym, code, down);
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="novnc-console">
|
||||
<div
|
||||
ref="view"
|
||||
class="novnc-screen"
|
||||
:class="{ 'is-focused': focused }"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showError || showReconnecting"
|
||||
class="novnc-overlay"
|
||||
>
|
||||
<div
|
||||
v-if="showError"
|
||||
class="novnc-overlay__card text-center"
|
||||
>
|
||||
<h3>
|
||||
{{ failureTitle }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="statusDetail"
|
||||
class="mt-10"
|
||||
>
|
||||
{{ statusDetail }}
|
||||
</p>
|
||||
<button
|
||||
class="btn role-primary mt-20"
|
||||
@click="reconnect"
|
||||
>
|
||||
{{ t('harvester.action.reconnect') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canTakeOver"
|
||||
class="btn role-secondary mt-20 ml-10"
|
||||
@click="takeOver"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.detail.console.takeOver.label') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center"
|
||||
>
|
||||
<h2>
|
||||
{{ t('vncConsole.reconnecting.message') }}:{{ retryTimes }} of {{ maximumRetryTimes }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="statusDetail"
|
||||
class="mt-10"
|
||||
>
|
||||
{{ statusDetail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="showTakeOverModal"
|
||||
name="takeOverConsole"
|
||||
:width="480"
|
||||
@finish="cancelTakeOver"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('harvester.virtualMachine.detail.console.takeOver.title') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<p>{{ t('harvester.virtualMachine.detail.console.takeOver.confirm') }}</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="take-over-actions">
|
||||
<button
|
||||
class="btn role-secondary mr-10"
|
||||
@click="cancelTakeOver"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
@click="confirmTakeOver"
|
||||
>
|
||||
{{ t('harvester.virtualMachine.detail.console.takeOver.label') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error {
|
||||
.novnc-console {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
.novnc-screen {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.novnc-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
// The overlay is always dark, so its text cannot follow the theme's text colours.
|
||||
color: #fff;
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 5rem;
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.desert-landscape {
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
&__card {
|
||||
max-width: 480px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.take-over-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// noVNC sets an inline `outline: none` on its canvas, so mark keyboard focus with a ring instead.
|
||||
.novnc-screen.is-focused canvas {
|
||||
box-shadow: 0 0 0 2px var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { STEVE } from '@shell/config/types';
|
||||
import ModalWithCard from '@shell/components/ModalWithCard';
|
||||
import { escapeHtml } from '@shell/utils/string';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import KeyTable from '@novnc/novnc/core/input/keysym';
|
||||
@@ -8,9 +9,29 @@ import { HCI } from '../../types';
|
||||
import NovncConsole from './NovncConsole';
|
||||
import NovncConsoleItem from './NovncConsoleItem';
|
||||
import NovncConsoleCustomKeys from './NovncConsoleCustomKeys';
|
||||
import { normalizeZoom } from './ZoomableRFB';
|
||||
|
||||
const CONSOLE_SETTINGS_KEY = 'harvester-vnc-console-settings';
|
||||
const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
|
||||
|
||||
export const CONFIRM_ACTION = {
|
||||
CTRL_ALT_DEL: 'ctrlAltDel',
|
||||
SOFT_REBOOT: 'softReboot',
|
||||
};
|
||||
|
||||
const VIEW_MODE = {
|
||||
SCALE: 'scale',
|
||||
NONE: 'none',
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
viewMode: VIEW_MODE.NONE,
|
||||
zoomPercent: 100,
|
||||
viewOnly: false,
|
||||
qualityLevel: 6,
|
||||
compressionLevel: 2,
|
||||
};
|
||||
|
||||
const SHORT_KEYS = {
|
||||
ControlLeft: {
|
||||
label: 'Ctrl',
|
||||
@@ -106,7 +127,7 @@ const F_KEYS = {
|
||||
export default {
|
||||
name: 'NovncConsoleWrapper',
|
||||
components: {
|
||||
NovncConsole, NovncConsoleItem, NovncConsoleCustomKeys
|
||||
ModalWithCard, NovncConsole, NovncConsoleItem, NovncConsoleCustomKeys
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
@@ -132,44 +153,68 @@ export default {
|
||||
keysRecord: [],
|
||||
vmResource: {},
|
||||
renderKeysModal: false,
|
||||
currentUser: null,
|
||||
hideCustomKeysBar: false,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
capabilities: {},
|
||||
isFullscreen: false,
|
||||
confirmAction: null,
|
||||
consoleStatus: {
|
||||
status: 'connecting',
|
||||
retryTimes: 0,
|
||||
maximumRetryTimes: 0,
|
||||
desktopName: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
preference() {
|
||||
return this.$store.getters['management/all'](STEVE.PREFERENCE)?.[0];
|
||||
},
|
||||
|
||||
// Custom keys are stored in the user preference, which is not available everywhere.
|
||||
hideCustomKeysBar() {
|
||||
return !this.preference?.data;
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
const preferedShortcutKeys = this.preference?.data?.[PREFERED_SHORTCUT_KEYS];
|
||||
|
||||
if (!preferedShortcutKeys) {
|
||||
return out;
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
out = JSON.parse(preferedShortcutKeys);
|
||||
return 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 [];
|
||||
}
|
||||
},
|
||||
|
||||
return out;
|
||||
preferredShortcutKeys() {
|
||||
return (this.savedShortcutKeys || []).map((item) => {
|
||||
return {
|
||||
label: item.map((K) => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join('+'),
|
||||
value: item
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
canSendKeys() {
|
||||
return this.consoleStatus.status === 'connected' && !this.settings.viewOnly;
|
||||
},
|
||||
|
||||
isDown() {
|
||||
return this.isEmpty(this.value);
|
||||
},
|
||||
|
||||
preserveSession() {
|
||||
return !!this.$store.getters['harvester-common/getFeatureEnabled']('vncPreserveSession');
|
||||
},
|
||||
|
||||
vmRunning() {
|
||||
return this.value?.status?.phase === 'Running';
|
||||
},
|
||||
|
||||
url() {
|
||||
const ip = `${ window.location.hostname }:${ window.location.port }`;
|
||||
|
||||
@@ -192,13 +237,16 @@ export default {
|
||||
...F_KEYS,
|
||||
};
|
||||
|
||||
out.AltLeft.keys = { PrintScreen: FUNCTION_KEYS.PrintScreen, ...F_KEYS };
|
||||
out.ControlLeft.keys = {
|
||||
AltLeft: {
|
||||
...Object.assign(SHORT_KEYS.AltLeft, {}),
|
||||
keys: { Delete: FUNCTION_KEYS.Delete }
|
||||
out.AltLeft = { ...SHORT_KEYS.AltLeft, keys: { PrintScreen: FUNCTION_KEYS.PrintScreen, ...F_KEYS } };
|
||||
out.ControlLeft = {
|
||||
...SHORT_KEYS.ControlLeft,
|
||||
keys: {
|
||||
AltLeft: {
|
||||
...SHORT_KEYS.AltLeft,
|
||||
keys: { Delete: FUNCTION_KEYS.Delete }
|
||||
},
|
||||
...NORMAL_KEYS,
|
||||
},
|
||||
...NORMAL_KEYS,
|
||||
};
|
||||
|
||||
return out;
|
||||
@@ -208,14 +256,57 @@ export default {
|
||||
return !!this.vmResource?.actions?.softreboot;
|
||||
},
|
||||
|
||||
preferredShortcutKeys() {
|
||||
return (this.savedShortcutKeys || []).map((item) => {
|
||||
return {
|
||||
label: item.map((K) => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join('+'),
|
||||
value: item
|
||||
};
|
||||
});
|
||||
rfbSettings() {
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (status === 'connected' && this.settings.viewOnly) {
|
||||
return this.t('harvester.virtualMachine.detail.console.status.connectedViewOnly');
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -233,36 +324,39 @@ 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.isOpen = false;
|
||||
this.$refs.popover.hide();
|
||||
this.keysRecord = [];
|
||||
this.$refs.novncConsole.focus();
|
||||
},
|
||||
|
||||
sendCustomKeys(keys) {
|
||||
if (!this.canSendKeys || !this.$refs.novncConsole) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyList = [].concat(keys);
|
||||
|
||||
keyList.forEach((K) => {
|
||||
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), true);
|
||||
});
|
||||
|
||||
keyList.reverse().forEach((K) => {
|
||||
[...keyList].reverse().forEach((K) => {
|
||||
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), false);
|
||||
});
|
||||
},
|
||||
|
||||
reconnect() {
|
||||
this.$refs.novncConsole.reconnect();
|
||||
},
|
||||
|
||||
softReboot() {
|
||||
this.vmResource.doSoftReboot();
|
||||
this.$refs.novncConsole.focus();
|
||||
},
|
||||
|
||||
showKeysModal() {
|
||||
@@ -272,116 +366,406 @@ export default {
|
||||
hideKeysModal() {
|
||||
this.renderKeysModal = 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;
|
||||
},
|
||||
|
||||
// Both can restart the VM with one click (Ctrl+Alt+Del reboots most Linux guests), so ask first.
|
||||
ctrlAltDelete() {
|
||||
if (this.canSendKeys) {
|
||||
this.confirmAction = CONFIRM_ACTION.CTRL_ALT_DEL;
|
||||
}
|
||||
},
|
||||
|
||||
softReboot() {
|
||||
this.confirmAction = CONFIRM_ACTION.SOFT_REBOOT;
|
||||
},
|
||||
|
||||
// The modal returns focus to the console canvas (return-focus-selector) once it closes.
|
||||
cancelConfirm() {
|
||||
this.confirmAction = null;
|
||||
},
|
||||
|
||||
runConfirmedAction() {
|
||||
const action = this.confirmAction;
|
||||
|
||||
this.confirmAction = null;
|
||||
|
||||
if (action === CONFIRM_ACTION.CTRL_ALT_DEL && this.canSendKeys) {
|
||||
this.$refs.novncConsole?.ctrlAltDelete();
|
||||
} else if (action === CONFIRM_ACTION.SOFT_REBOOT) {
|
||||
this.vmResource.doSoftReboot();
|
||||
}
|
||||
},
|
||||
|
||||
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?.();
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="vm-console">
|
||||
<div
|
||||
ref="consoleContainer"
|
||||
class="vm-console"
|
||||
>
|
||||
<div class="combination-keys">
|
||||
<v-dropdown
|
||||
ref="popover"
|
||||
placement="top"
|
||||
trigger="click"
|
||||
:container="false"
|
||||
@auto-hide="keysRecord = []"
|
||||
>
|
||||
<button class="btn btn-sm bg-primary">
|
||||
{{ t("harvester.virtualMachine.detail.console.shortcutKeys") }}
|
||||
</button>
|
||||
|
||||
<template #popper>
|
||||
<novnc-console-item
|
||||
:items="keymap"
|
||||
:path="keysRecord"
|
||||
:pos="0"
|
||||
@update="update"
|
||||
@send-keys="sendKeys"
|
||||
/>
|
||||
</template>
|
||||
</v-dropdown>
|
||||
|
||||
<button
|
||||
v-if="hasSoftRebootAction"
|
||||
class="btn btn-sm bg-primary"
|
||||
@click="softReboot"
|
||||
>
|
||||
{{ t("harvester.action.softreboot") }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
@click="reconnect"
|
||||
>
|
||||
{{ 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="showKeysModal"
|
||||
>
|
||||
{{ t("harvester.virtualMachine.detail.console.management") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div
|
||||
v-for="(keys, index) in preferredShortcutKeys"
|
||||
:key="index"
|
||||
<div class="toolbar-group">
|
||||
<v-dropdown
|
||||
ref="popover"
|
||||
placement="top"
|
||||
trigger="click"
|
||||
:container="false"
|
||||
@auto-hide="keysRecord = []"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
:disabled="!canSendKeys"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
@click="sendCustomKeys(keys.value)"
|
||||
>
|
||||
{{ keys.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</v-dropdown>
|
||||
{{ t("harvester.virtualMachine.detail.console.shortcutKeys") }}
|
||||
</button>
|
||||
|
||||
<NovncConsoleCustomKeys
|
||||
v-if="renderKeysModal"
|
||||
:current-user="currentUser"
|
||||
@close="hideKeysModal"
|
||||
/>
|
||||
<template #popper>
|
||||
<novnc-console-item
|
||||
:items="keymap"
|
||||
:path="keysRecord"
|
||||
:pos="0"
|
||||
@update="update"
|
||||
@send-keys="sendKeys"
|
||||
/>
|
||||
</template>
|
||||
</v-dropdown>
|
||||
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
:disabled="!canSendKeys"
|
||||
@click="ctrlAltDelete"
|
||||
>
|
||||
{{ t("harvester.virtualMachine.detail.console.ctrlAltDel") }}
|
||||
</button>
|
||||
|
||||
<v-dropdown
|
||||
v-if="!hideCustomKeysBar"
|
||||
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="showKeysModal"
|
||||
>
|
||||
{{ t("harvester.virtualMachine.detail.console.management") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div
|
||||
v-for="(keys, index) in preferredShortcutKeys"
|
||||
:key="index"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
:disabled="!canSendKeys"
|
||||
@click="sendCustomKeys(keys.value)"
|
||||
>
|
||||
{{ keys.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</v-dropdown>
|
||||
|
||||
<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>
|
||||
|
||||
<label
|
||||
for="vnc-quality"
|
||||
class="console-panel__zoom-label"
|
||||
>
|
||||
<span>{{ t('harvester.virtualMachine.detail.console.settings.quality') }}</span>
|
||||
<span>{{ settings.qualityLevel }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="vnc-quality"
|
||||
v-model.number="settings.qualityLevel"
|
||||
class="console-panel__zoom"
|
||||
type="range"
|
||||
min="0"
|
||||
max="9"
|
||||
step="1"
|
||||
>
|
||||
|
||||
<label
|
||||
for="vnc-compression"
|
||||
class="console-panel__zoom-label"
|
||||
>
|
||||
<span>{{ t('harvester.virtualMachine.detail.console.settings.compression') }}</span>
|
||||
<span>{{ settings.compressionLevel }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="vnc-compression"
|
||||
v-model.number="settings.compressionLevel"
|
||||
class="console-panel__zoom"
|
||||
type="range"
|
||||
min="0"
|
||||
max="9"
|
||||
step="1"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</v-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<span
|
||||
class="console-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>{{ statusLabel }}</span>
|
||||
|
||||
<button
|
||||
v-if="hasSoftRebootAction"
|
||||
class="btn btn-sm bg-primary"
|
||||
@click="softReboot"
|
||||
>
|
||||
{{ t("harvester.action.softreboot") }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
@click="reconnect"
|
||||
>
|
||||
{{ t("harvester.action.reconnect") }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm bg-primary"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
{{ isFullscreen ? t('harvester.virtualMachine.detail.console.fullscreen.exit') : t('harvester.virtualMachine.detail.console.fullscreen.enter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NovncConsole
|
||||
v-if="url && !isDown"
|
||||
ref="novncConsole"
|
||||
:url="url"
|
||||
:settings="rfbSettings"
|
||||
:preserve-session="preserveSession"
|
||||
:vm-running="vmRunning"
|
||||
@status-changed="onStatusChanged"
|
||||
@capabilities="onCapabilities"
|
||||
/>
|
||||
<p v-if="isDown">
|
||||
{{ t("harvester.virtualMachine.detail.console.down") }}
|
||||
</p>
|
||||
|
||||
<ModalWithCard
|
||||
v-if="confirmAction"
|
||||
name="confirmConsoleAction"
|
||||
:width="480"
|
||||
return-focus-selector=".novnc-screen canvas"
|
||||
@finish="cancelConfirm"
|
||||
>
|
||||
<template #title>
|
||||
{{ t(`harvester.virtualMachine.detail.console.confirm.${ confirmAction }.title`) }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<p>{{ t(`harvester.virtualMachine.detail.console.confirm.${ confirmAction }.message`) }}</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
class="btn role-secondary mr-10"
|
||||
@click="cancelConfirm"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
data-testid="vnc-confirm-action"
|
||||
@click="runConfirmedAction"
|
||||
>
|
||||
{{ t(`harvester.virtualMachine.detail.console.confirm.${ confirmAction }.button`) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ModalWithCard>
|
||||
|
||||
<NovncConsoleCustomKeys
|
||||
v-if="renderKeysModal"
|
||||
@close="hideKeysModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vm-console {
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-rows: 30px auto;
|
||||
grid-template-rows: 32px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import NovncConsole, { CONSOLE_STATUS, CONSOLE_FAILURE } from '../NovncConsole.vue';
|
||||
|
||||
const mockInstances = [];
|
||||
|
||||
jest.mock('../ZoomableRFB', () => {
|
||||
return jest.fn().mockImplementation((el, url) => {
|
||||
const listeners = {};
|
||||
const rfb = {
|
||||
url,
|
||||
addEventListener: jest.fn((name, cb) => {
|
||||
listeners[name] = cb;
|
||||
}),
|
||||
removeEventListener: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
focus: jest.fn(),
|
||||
emit: (name, detail) => listeners[name]?.({ detail }),
|
||||
};
|
||||
|
||||
mockInstances.push(rfb);
|
||||
|
||||
return rfb;
|
||||
});
|
||||
});
|
||||
|
||||
const URL = 'wss://example.com/apis/subresources.kubevirt.io/v1/namespaces/default/virtualmachineinstances/vm/vnc';
|
||||
|
||||
const mountConsole = (props = {}) => {
|
||||
mockInstances.length = 0;
|
||||
jest.useFakeTimers();
|
||||
|
||||
return shallowMount(NovncConsole, { props: { url: URL, ...props } });
|
||||
};
|
||||
|
||||
const lastRFB = () => mockInstances[mockInstances.length - 1];
|
||||
|
||||
describe('component: NovncConsole', () => {
|
||||
it('should connect with preserveSession so an existing session is not kicked', async() => {
|
||||
mountConsole();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(new window.URL(lastRFB().url).searchParams.get('preserveSession')).toBe('true');
|
||||
});
|
||||
|
||||
it('should keep retrying while the console is held by another session', async() => {
|
||||
const wrapper = mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
lastRFB().emit('connect');
|
||||
|
||||
// Dropped by another session taking over.
|
||||
lastRFB().emit('disconnect');
|
||||
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
|
||||
|
||||
// The first retry is rejected because the other session is still active.
|
||||
jest.advanceTimersByTime(1000);
|
||||
lastRFB().emit('disconnect');
|
||||
|
||||
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
|
||||
expect(wrapper.vm.retryTimes).toBe(2);
|
||||
expect(wrapper.vm.statusDetail).toBe('%harvester.virtualMachine.detail.console.status.inUse%');
|
||||
expect(new window.URL(lastRFB().url).searchParams.get('preserveSession')).toBe('true');
|
||||
});
|
||||
|
||||
it('should fail with a take over option after the retries are exhausted', async() => {
|
||||
const wrapper = mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
lastRFB().emit('connect');
|
||||
lastRFB().emit('disconnect');
|
||||
|
||||
for (let i = 0; i < wrapper.vm.maximumRetryTimes; i++) {
|
||||
jest.runOnlyPendingTimers();
|
||||
lastRFB().emit('disconnect');
|
||||
}
|
||||
|
||||
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.FAILED);
|
||||
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.IN_USE);
|
||||
expect(wrapper.vm.isClosed).toBe(false);
|
||||
});
|
||||
|
||||
it('should show the console as in use when a running VM refuses the first connection', async() => {
|
||||
const wrapper = mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
lastRFB().emit('disconnect');
|
||||
|
||||
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.FAILED);
|
||||
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.IN_USE);
|
||||
expect(wrapper.vm.canTakeOver).toBe(true);
|
||||
});
|
||||
|
||||
it('should not claim the console is in use when the VM is not running', async() => {
|
||||
const wrapper = mountConsole({ vmRunning: false });
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
lastRFB().emit('disconnect');
|
||||
|
||||
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.UNAVAILABLE);
|
||||
});
|
||||
|
||||
it('should only take over after the modal is confirmed', async() => {
|
||||
const wrapper = mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
const connections = mockInstances.length;
|
||||
|
||||
wrapper.vm.takeOver();
|
||||
expect(wrapper.vm.showTakeOverModal).toBe(true);
|
||||
expect(mockInstances).toHaveLength(connections);
|
||||
|
||||
wrapper.vm.confirmTakeOver();
|
||||
|
||||
expect(wrapper.vm.showTakeOverModal).toBe(false);
|
||||
expect(new window.URL(lastRFB().url).searchParams.get('preserveSession')).toBe('false');
|
||||
});
|
||||
|
||||
it('should not take over when the modal is cancelled', async() => {
|
||||
const wrapper = mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
const connections = mockInstances.length;
|
||||
|
||||
wrapper.vm.takeOver();
|
||||
wrapper.vm.cancelTakeOver();
|
||||
|
||||
expect(wrapper.vm.showTakeOverModal).toBe(false);
|
||||
expect(mockInstances).toHaveLength(connections);
|
||||
});
|
||||
|
||||
it('should focus the console once it connects, so typing reaches the VM', async() => {
|
||||
mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
lastRFB().emit('connect');
|
||||
|
||||
expect(lastRFB().focus).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should mark the console while it has keyboard focus', async() => {
|
||||
const wrapper = mountConsole();
|
||||
const view = wrapper.find('.novnc-screen');
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
view.element.dispatchEvent(new FocusEvent('focusin'));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(view.classes()).toContain('is-focused');
|
||||
|
||||
view.element.dispatchEvent(new FocusEvent('focusout'));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(view.classes()).not.toContain('is-focused');
|
||||
});
|
||||
|
||||
describe('when the cluster does not support preserveSession', () => {
|
||||
it('should connect without the parameter and offer no take over', async() => {
|
||||
const wrapper = mountConsole({ preserveSession: false });
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(new window.URL(lastRFB().url).searchParams.has('preserveSession')).toBe(false);
|
||||
|
||||
lastRFB().emit('disconnect');
|
||||
|
||||
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.UNAVAILABLE);
|
||||
expect(wrapper.vm.canTakeOver).toBe(false);
|
||||
});
|
||||
|
||||
it('should not reconnect on its own after the session drops', async() => {
|
||||
const wrapper = mountConsole({ preserveSession: false });
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
lastRFB().emit('connect');
|
||||
const connections = mockInstances.length;
|
||||
|
||||
lastRFB().emit('disconnect');
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.DISCONNECTED);
|
||||
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.DROPPED);
|
||||
expect(wrapper.vm.showError).toBe(true);
|
||||
expect(mockInstances).toHaveLength(connections);
|
||||
});
|
||||
});
|
||||
|
||||
it('should close the socket when credentials are required', async() => {
|
||||
const wrapper = mountConsole();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
const rfb = lastRFB();
|
||||
|
||||
rfb.emit('credentialsrequired');
|
||||
|
||||
expect(rfb.disconnect).toHaveBeenCalledWith();
|
||||
expect(wrapper.vm.rfb).toBeNull();
|
||||
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.FAILED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import NovncConsoleWrapper, { CONFIRM_ACTION } from '../NovncConsoleWrapper.vue';
|
||||
|
||||
jest.mock('../ZoomableRFB', () => ({ normalizeZoom: (value) => value }));
|
||||
jest.mock('@novnc/novnc/core/input/keysym', () => ({}), { virtual: true });
|
||||
jest.mock('@novnc/novnc/core/input/util', () => ({}), { virtual: true });
|
||||
|
||||
const consoleRef = {
|
||||
ctrlAltDelete: jest.fn(),
|
||||
focus: jest.fn(),
|
||||
};
|
||||
|
||||
const mountWrapper = () => {
|
||||
consoleRef.ctrlAltDelete.mockClear();
|
||||
consoleRef.focus.mockClear();
|
||||
|
||||
const wrapper = shallowMount(NovncConsoleWrapper, {
|
||||
props: {
|
||||
value: {
|
||||
id: 'default/vm', metadata: { name: 'vm' }, status: { phase: 'Running' }
|
||||
}
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'management/all': () => [],
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
},
|
||||
},
|
||||
$fetchState: { pending: false },
|
||||
},
|
||||
stubs: {
|
||||
NovncConsole: {
|
||||
template: '<div />',
|
||||
methods: {
|
||||
ctrlAltDelete: consoleRef.ctrlAltDelete,
|
||||
focus: consoleRef.focus,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.vm.onStatusChanged({ status: 'connected' });
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('component: NovncConsoleWrapper', () => {
|
||||
it('should only send Ctrl+Alt+Del after it is confirmed', async() => {
|
||||
const wrapper = mountWrapper();
|
||||
|
||||
wrapper.vm.ctrlAltDelete();
|
||||
expect(wrapper.vm.confirmAction).toBe(CONFIRM_ACTION.CTRL_ALT_DEL);
|
||||
expect(consoleRef.ctrlAltDelete).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.vm.runConfirmedAction();
|
||||
|
||||
expect(consoleRef.ctrlAltDelete).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.vm.confirmAction).toBeNull();
|
||||
});
|
||||
|
||||
it('should not send Ctrl+Alt+Del when the confirmation is cancelled', () => {
|
||||
const wrapper = mountWrapper();
|
||||
|
||||
wrapper.vm.ctrlAltDelete();
|
||||
wrapper.vm.cancelConfirm();
|
||||
|
||||
expect(consoleRef.ctrlAltDelete).not.toHaveBeenCalled();
|
||||
expect(wrapper.vm.confirmAction).toBeNull();
|
||||
});
|
||||
|
||||
it('should only soft reboot after it is confirmed', () => {
|
||||
const wrapper = mountWrapper();
|
||||
const doSoftReboot = jest.fn();
|
||||
|
||||
wrapper.vm.vmResource = { doSoftReboot };
|
||||
|
||||
wrapper.vm.softReboot();
|
||||
expect(doSoftReboot).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.vm.runConfirmedAction();
|
||||
expect(doSoftReboot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not ask to send Ctrl+Alt+Del while keys cannot be sent', () => {
|
||||
const wrapper = mountWrapper();
|
||||
|
||||
wrapper.vm.onStatusChanged({ status: 'reconnecting' });
|
||||
wrapper.vm.ctrlAltDelete();
|
||||
|
||||
expect(wrapper.vm.confirmAction).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ const FEATURE_FLAGS = {
|
||||
'kubevirtMigration',
|
||||
'hotplugNic',
|
||||
'resumeUpgradePausedNode',
|
||||
'vncPreserveSession',
|
||||
],
|
||||
'v1.7.1': [],
|
||||
'v1.8.0': [
|
||||
|
||||
@@ -1021,6 +1021,51 @@ harvester:
|
||||
tips: Press the record button to capture your keyboard inputs.
|
||||
send: Send
|
||||
preferredKeys: Preferred Custom Shortcut Keys
|
||||
settings:
|
||||
label: Settings
|
||||
viewMode:
|
||||
label: Display
|
||||
scale: Scale the display to fit the window
|
||||
none: Manual zoom
|
||||
zoom: Zoom
|
||||
viewOnly: View only
|
||||
quality: JPEG quality
|
||||
compression: Compression level
|
||||
fullscreen:
|
||||
enter: Full Screen
|
||||
exit: Exit Full Screen
|
||||
ctrlAltDel: Ctrl+Alt+Del
|
||||
confirm:
|
||||
ctrlAltDel:
|
||||
title: Send Ctrl+Alt+Del?
|
||||
message: Windows opens its sign-in or security screen. Most Linux systems restart the virtual machine.
|
||||
button: Send
|
||||
softReboot:
|
||||
title: Soft reboot this virtual machine?
|
||||
message: The guest operating system is asked to restart. Unsaved work inside the virtual machine is lost.
|
||||
button: Soft Reboot
|
||||
takeOver:
|
||||
label: Take Over
|
||||
title: Take over this console?
|
||||
confirm: The other VNC session connected to this virtual machine will be disconnected.
|
||||
failure:
|
||||
inUse: This console is in use
|
||||
unavailable: Unable to connect
|
||||
dropped: Disconnected
|
||||
error: Connection failed
|
||||
failureDetail:
|
||||
inUse: Another VNC session is connected to this virtual machine. Retry once it is released, or take over the console to disconnect the other session.
|
||||
unavailable: The console is not available. Check that the virtual machine is running, then retry.
|
||||
dropped: The console connection was closed. Another VNC session may have opened this console.
|
||||
status:
|
||||
connecting: Connecting
|
||||
connected: Connected
|
||||
connectedViewOnly: Connected (view only)
|
||||
disconnected: Disconnected
|
||||
failed: Connection failed
|
||||
reconnecting: 'Reconnecting {retryTimes} of {maximumRetryTimes}'
|
||||
credentialsRequired: This console requires credentials that the dashboard cannot provide.
|
||||
inUse: Another VNC session may be using this console. Retrying until it is released.
|
||||
terminationGracePeriodSeconds:
|
||||
label: Termination Grace Period
|
||||
affinity:
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<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, Loading },
|
||||
components: {
|
||||
NovncConsoleWrapper: defineAsyncComponent({
|
||||
loader: () => import('../../../../../components/novnc/NovncConsoleWrapper.vue'),
|
||||
loadingComponent: Loading,
|
||||
}),
|
||||
Loading,
|
||||
TabTitle
|
||||
},
|
||||
|
||||
async fetch() {
|
||||
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
|
||||
@@ -28,17 +36,21 @@ 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
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('featureEnabled', () => {
|
||||
expect(featureEnabled('hotplugCdRom', 'v1.8.0-dev-20260101')).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable vncPreserveSession only from Harvester v1.7.0, whose KubeVirt honours it', () => {
|
||||
expect(featureEnabled('vncPreserveSession', 'v1.6.1')).toBe(false);
|
||||
expect(featureEnabled('vncPreserveSession', 'v1.7.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown features', () => {
|
||||
expect(featureEnabled('doesNotExist', 'v1.8.0')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
module.exports = require('@rancher/shell/pkg/vue.config')(__dirname);
|
||||
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 } };
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user