feat: redesign the VNC console (#1233)

* chore: bump @rancher/shell to 3.0.15-rc.1 for the Rc components

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

* feat: rebuild the VNC console toolbar with Rc components

Name and namespace, a connection status pill with its own menu, Rc tertiary buttons with the design's icons, and the keyboard hint under the screen. noVNC's grey backdrop is gone and the screen keeps a thin frame that turns into the keyboard-focus ring.

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

* feat: move VNC shortcut keys into one Rc dropdown menu

Saved shortcuts, Ctrl and Alt toggles that apply to the next key, Print Screen, F1 to F12 and the way into custom shortcuts now share the Shortcut keys menu, replacing the cascading popover and the separate custom keys menu. Saved shortcuts can carry a name; the old list format still reads. Focus goes back to the screen after a key is sent.

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

* feat: replace the VNC settings popover with the View options panel

Automatic or Manual scaling on the shell ButtonGroup, a zoom slider with step buttons, JPEG quality and compression with their trade-off spelled out, and Reset to defaults. The screen now scales to fit by default, inside its focus ring. View only is gone with the old panel.

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

* feat: confirm Ctrl+Alt+Del and Soft reboot in an RcModal

Small RcModal with the VM's name in the title and a destructive primary button, coloured the way the shell's own dialogs do it. Esc and the background now close it too, and the keyboard goes back to the screen afterwards.

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

* feat: rebuild VNC custom shortcuts as an RcModal with a key recorder

Record a combination by pressing it, name it, and see the saved ones as RcTags with a delete button each. Every change is saved straight away. Keys keep the format older versions read; names live in their own preference so those versions keep working.

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

* feat: show VNC connecting and connection failures as console overlays

A spinner with the VM's name while connecting (and the attempt count while retrying), and a card with an icon badge, title, explanation and Rc buttons when the console cannot be used: in use, unavailable, connection lost or failed. Take over confirms in an RcModal.

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

* feat: float a compact VNC bar over the screen in full screen

Name, connection status and Exit full screen in a floating bar instead of the toolbar, with the keyboard on the screen. Take over leaves full screen first, since its modal opens outside the console.

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

* fix: VNC console review fixes

Show a not-running state instead of connecting forever when the VM has no instance, and find the VM by its uid so the name shows. Keep a failed shortcut save from looking saved. Offer Take over only when another session holds the console, and Reconnect now while retrying. Wrap the toolbar on narrow windows, keep the screen black so overlays read in the light theme, keep the destructive button red on focus, announce only the status label, let Esc and Tab leave the key recorder, release held modifiers when the menu closes, and let Ctrl+T and Ctrl+N through in full screen.

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

* feat: use the redrawn VNC console icons

Power, maximize, minimize and wifi-off on the design's 24px grid, the 20px connecting spinner, and a status card badge that stays dark on the black screen. The screen is only black while there is no live picture.

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

* feat: run standalone Harvester Prime with the SUSE brand

Standalone Harvester reports ui-brand=harvester, which has no stylesheet, so Prime only got a green primary on top of the default palette: 33 of the 36 SUSE theme tokens differed from Rancher Prime (blue tertiary buttons, another green, dark text on primary buttons, no SUSE font). On the Prime flavor the brand is now switched to suse in this browser, and all 36 match Rancher Prime in light and dark.

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

* fix: name recorded VNC shortcut letters after the key pressed

macOS Option turns letters into symbols (Option+G gives ©), so a recorded Alt+Cmd+G was labelled and sent as ©. Letter keys now take their name from the key code when recording, labelling and sending, which also fixes shortcuts saved before.

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

* fix: give the VNC shortcut menu chips the secondary button colours

The key chips, held modifiers and the manage item used the link colour, so they stayed blue under the SUSE brand. They now take RcButton's secondary, active and tertiary tokens and follow the brand like the toolbar buttons.

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

* fix: VNC console review fixes, round three

Wait for the instance to run before connecting and retry refusals right after a start, keep Reconnect now counting retries, stop key sending once the VM is down, only rename option-mangled letters (Alt without Ctrl, dead keys) so other layouts keep their keys, give the keyboard back to View when its panel closes, add a shortcut with Enter, keep long shortcut names to two lines, only replace Harvester's own brand and refresh the favicon, and stop relying on stylesheet order for the spinner and danger button.

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

* feat: change the VNC menu keys with the held modifiers

The keys under Hold modifiers follow what is held, so the menu sends what the old cascading menu did: Ctrl+N/T/W/Y with Ctrl, Ctrl+Alt+Del with Ctrl and Alt (through the same confirmation as its button), and Print Screen and F1-F12 alone or with Alt. Ctrl+Alt+F1-F12 stays for switching Linux consoles. The recorder points to the menu for browser-reserved combinations.

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

* feat: bring View only back to the VNC View menu

The View menu has a View only switch again, as the old settings panel did: the screen stays live but no mouse or keyboard input reaches the VM, the status reads Connected (view only), the key menus are disabled and the hint says how to turn it off. The design does not have it yet.

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

* feat: move View only into the VNC connection menu

View only is how this session uses the connection, so it sits in the Connected menu (also in full screen) as a checkbox item, and the View menu is only about how the screen is shown.

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

* feat: keep the VNC console's controls usable around the connection

Full screen goes over the whole page and its bar gets Shortcut keys and Ctrl+Alt+Del, so menus and the confirmation show there too and Take over no longer has to leave full screen. Soft reboot works whatever the console's state, since it does not go through it. The Shortcut keys menu always opens: while keys cannot be sent it says why and disables only the keys, so custom shortcuts can still be managed. The focus ring now sits 2px off the screen so it shows against a dark framebuffer edge in the light theme.

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

* feat: disable the VNC console's input controls while View only is on

View only sends nothing to the VM, so Shortcut keys (managing shortcuts included), Ctrl+Alt+Del and Soft reboot are disabled instead of the menu opening to explain why. The menu still opens while disconnected, as there are shortcuts to manage. The hint under the screen takes the new wording, read raw since t() would escape its quotes.

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

* feat: fill the window with the VNC console in full screen

The screen takes the whole window, with no padding, ring or inset, and black around a picture of another shape. It always fits in full screen, whatever View mode is saved, which comes back on leaving. The bar floats over the screen, slightly transparent until it is hovered, holds the keyboard focus or has a menu open, and no longer has Ctrl+Alt+Del, which stays in the Shortcut keys menu.

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

* feat: show the mouse over the VNC console

noVNC hides the mouse over the screen until the guest sends a pointer image, and a text console or a guest that draws its own pointer never does. Show the system pointer unless one arrives, so it can be seen in the window and in full screen. A guest that sends an image keeps it.

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

* fix: close the VNC console's menus on a click on the screen in Firefox

Firefox delivers the click to the canvas, where noVNC stops it, so an open menu never saw a click outside it. Full screen makes this matter, with little else left to click. noVNC does nothing else with a click, so let it through.

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

* feat: make View only a toggle in the VNC console toolbar

View only moves from the Connected menu to the shared ToggleSwitch between the connection status and Shortcut keys, in the toolbar and in the full screen bar, so its state is always in view and the status no longer has to repeat it. Turning it off hands the keyboard back to the console. The full screen bar no longer gets only half the window from its left: 50%, which would have squeezed its buttons with the wider content; it wraps when it really does not fit.

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

* feat: leave View only out of the VNC console's full screen bar

View only is set from the toolbar. A session that enters full screen with it on stays that way, with Shortcut keys disabled, and changes it after leaving.

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

* feat: make View only a checkbox in the VNC console toolbar

It takes the shared Checkbox instead of ToggleSwitch, in the same place between the connection status and Shortcut keys. Clicking its label now turns it off as well as on.

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

* fix: save the VNC view settings only when the user changes them

The previous console wrote its defaults to localStorage the first time it
opened, so Manual zoom at 100% looked like a choice and the new Automatic
default never reached anyone who had used the console. Settings now live
under a new key and are saved only when the user changes them.

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

* fix: keep View only on when the VNC View panel resets its settings

View only moved to the toolbar, but Reset to defaults in the View panel
still set it back to off, so the screen started sending the mouse and
keyboard to the VM again. The reset now leaves View only as it is.

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

* fix: give the VNC quality and compression sliders their full height

The slider rule's flex: 1 is meant for the zoom row. In the quality and
compression sections, which are columns, it set the height instead and
left those two sliders 4px tall, so a click on the visible thumb missed.

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

* fix: reach the VNC shortcut keys from the keyboard after holding a modifier

RcDropdown collects its items when the menu opens, and holding Ctrl or
Alt swaps the keys below the toggles while it is open, so the arrow keys
stopped at Alt. The menu now collects its items again on each key press,
as RcDropdown does for its submenus.

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

* fix: show a failed custom shortcut save inside the dialog

The dialog only reported a failed save with a growl, and the console
page's layout has no growls, so a delete or an add that could not be
saved changed nothing and said nothing. The error now shows as an alert
in the dialog until the next change or until it closes.

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

* fix: keep the focus in the custom shortcuts dialog after adding or deleting

The delete buttons and Add shortcut were disabled while a change saved,
and disabling the button that was just pressed dropped the keyboard's
focus to the page. They now stay enabled, and a change made while one saves is
ignored. After a delete the next row's button takes the focus, or the last
one's, and after an add, or once the list is empty, the recorder does.

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

* fix: record a modifier pressed on its own as a custom VNC shortcut

The recorder held every modifier for the key that ends the combination,
so the Windows key on its own could not be saved any more, while the old
dialog could. Modifiers released before any other key are now the
combination, once the last of them is released.

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

* fix: stop the custom shortcut form saying a new shortcut is already saved

The list shows a new shortcut while it saves, and the form kept it until
the save came back, so every add showed "This key combination is already
saved." as an alert for as long as the save took. The form now empties
as soon as Add shortcut is pressed, and the shortcut goes back in it if
the save fails, unless the next one was started meanwhile.

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

* fix: make the VNC Reconnect button an RcButton

The plain button took the global 40px button height inside the 32px
Disconnected pill, so its focus ring stuck out above and below the pill.
As a small ghost RcButton it is 24px tall and its ring stays inside.

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

* fix: carry View only over from the previous VNC console

The new storage key dropped a View only that someone turned on in the
previous console, so their mouse and keyboard reached the VM again
after the upgrade. View only was off unless the user turned it on, so
it is read from the previous key until this console saves its own
settings; the display settings still start fresh.

The comment on the key now says the settings are written once the user
changes one, rather than only the ones changed.

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

* fix: warn that a VNC soft reboot loses unsaved work

The soft reboot confirmation said the console disconnects while the VM
restarts and reconnects when it is back, which it does not: a soft
reboot restarts the guest in the same VM instance and pod, so the
console stays connected. It also dropped the previous dialog's warning
that unsaved work is lost, which is back.

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

---------

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
(cherry picked from commit f3ca9e4c44)

# Conflicts:
#	pkg/harvester/components/novnc/NovncConsoleCustomKeys.vue
#	pkg/harvester/components/novnc/NovncConsoleWrapper.vue
#	pkg/harvester/components/novnc/__tests__/NovncConsoleCustomKeys.test.js
#	pkg/harvester/components/novnc/__tests__/NovncConsoleWrapper.test.js
This commit is contained in:
Marcelo Fukumoto
2026-11-13 03:43:43 +00:00
committed by Mergify
parent 27e6f2940e
commit 61884fd806
21 changed files with 3391 additions and 551 deletions
@@ -0,0 +1,224 @@
<script>
import { RcButton } from '@components/RcButton';
import { RcDropdown, RcDropdownItem, RcDropdownSeparator, RcDropdownTrigger } from '@components/RcDropdown';
import { RcIcon } from '@components/RcIcon';
import RcStatusIndicator from '@components/Pill/RcStatusIndicator';
import { CONSOLE_STATUS } from './NovncConsole';
export const CONNECTION_STATE = {
CONNECTING: 'connecting',
CONNECTED: 'connected',
DISCONNECTED: 'disconnected',
NOT_RUNNING: 'notRunning',
};
export default {
name: 'NovncConnectionStatus',
components: {
RcButton, RcDropdown, RcDropdownItem, RcDropdownSeparator, RcDropdownTrigger, RcIcon, RcStatusIndicator
},
props: {
status: {
type: String,
default: CONSOLE_STATUS.CONNECTING,
},
label: {
type: String,
default: '',
},
vmName: {
type: String,
default: '',
},
},
emits: ['reconnect'],
computed: {
// The console reports five states; the toolbar only tells the user whether they can type.
state() {
if (this.status === CONNECTION_STATE.NOT_RUNNING) {
return CONNECTION_STATE.NOT_RUNNING;
}
if (this.status === CONSOLE_STATUS.CONNECTED) {
return CONNECTION_STATE.CONNECTED;
}
if (this.status === CONSOLE_STATUS.DISCONNECTED || this.status === CONSOLE_STATUS.FAILED) {
return CONNECTION_STATE.DISCONNECTED;
}
return CONNECTION_STATE.CONNECTING;
},
},
};
</script>
<template>
<div class="connection-status">
<!-- Only the label is announced, not the menu that opens next to it. -->
<span
class="sr-only"
role="status"
aria-live="polite"
>{{ label }}</span>
<rc-dropdown
v-if="state === 'connected'"
:aria-label="t('harvester.virtualMachine.detail.console.connection.menuLabel')"
placement="bottom-start"
>
<rc-dropdown-trigger
variant="ghost"
size="small"
class="connection-status__pill"
data-testid="vnc-connection-status"
>
<template #before>
<rc-status-indicator
shape="disc"
status="success"
/>
</template>
{{ label }}
<template #after>
<rc-icon
type="chevron-down"
size="inherit"
/>
</template>
</rc-dropdown-trigger>
<template #dropdownCollection>
<p class="connection-status__description">
{{ t('harvester.virtualMachine.detail.console.connection.description', { name: vmName }) }}
</p>
<rc-dropdown-separator />
<rc-dropdown-item
data-testid="vnc-connection-reconnect"
@click="$emit('reconnect')"
>
<template #before>
<rc-icon
type="refresh"
size="inherit"
/>
</template>
{{ t('harvester.action.reconnect') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
<span
v-else-if="state === 'notRunning'"
class="connection-status__pill"
data-testid="vnc-connection-status"
>
<rc-status-indicator
shape="disc"
status="unknown"
/>
{{ label }}
</span>
<span
v-else-if="state === 'connecting'"
class="connection-status__pill"
data-testid="vnc-connection-status"
>
<rc-icon
type="spinner"
size="inherit"
class="icon-spin"
/>
{{ label }}
</span>
<span
v-else
class="connection-status__pill connection-status__pill--disconnected"
data-testid="vnc-connection-status"
>
<rc-status-indicator
shape="disc"
status="error"
/>
{{ label }}
<span
class="connection-status__divider"
aria-hidden="true"
/>
<rc-button
variant="ghost"
size="small"
class="connection-status__reconnect"
data-testid="vnc-connection-reconnect"
@click="$emit('reconnect')"
>
<template #before>
<rc-icon
type="refresh"
size="inherit"
/>
</template>
{{ t('harvester.action.reconnect') }}
</rc-button>
</span>
</div>
</template>
<style lang="scss" scoped>
.connection-status {
display: flex;
align-items: center;
// Shared by the dropdown trigger and the plain pills, so every state has the same shape.
&__pill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px 0 12px;
// The connected pill is an RcButton, which takes its padding from this property.
--rc-button-padding: 0 10px 0 12px;
border: 1px solid var(--border);
border-radius: 16px;
color: var(--body-text);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
&--disconnected {
border-color: var(--error);
}
}
&__divider {
width: 1px;
height: 16px;
background: var(--error);
}
// An RcButton keeps to its own small height, so its focus ring stays inside the pill.
&__reconnect {
--rc-button-padding: 0;
color: var(--error);
}
&__description {
max-width: 248px;
margin: 0;
padding: 4px 17px 8px;
color: var(--body-text);
font-size: 12px;
font-weight: 400;
line-height: 18px;
white-space: normal;
}
}
</style>
+143 -77
View File
@@ -1,5 +1,8 @@
<script>
import ModalWithCard from '@shell/components/ModalWithCard';
import { RcButton } from '@components/RcButton';
import { RcIcon } from '@components/RcIcon';
import { RcModal } from '@components/RcModal';
import NovncStatusCard from './NovncStatusCard';
import ZoomableRFB from './ZoomableRFB';
export const CONSOLE_STATUS = {
@@ -20,6 +23,8 @@ export const CONSOLE_FAILURE = {
const MAXIMUM_RETRY_TIMES = 10;
const BASE_RECONNECT_DELAY = 1000;
const MAXIMUM_RECONNECT_DELAY = 30000;
// A VM that just started can refuse VNC for a moment; refusals this soon are retried, not reported.
const STARTUP_GRACE = 60000;
const clampLevel = (value, fallback) => {
const level = Number.parseInt(value, 10);
@@ -34,7 +39,9 @@ const clampLevel = (value, fallback) => {
export default {
name: 'NovncConsole',
components: { ModalWithCard },
components: {
RcButton, RcIcon, RcModal, NovncStatusCard
},
props: {
url: {
@@ -58,6 +65,17 @@ export default {
type: Boolean,
default: true
},
vmName: {
type: String,
default: ''
},
// When the VM was seen starting to run on this page (ms), or 0.
startedAt: {
type: Number,
default: 0
},
},
emits: ['status-changed', 'capabilities'],
@@ -93,16 +111,17 @@ export default {
return this.status === CONSOLE_STATUS.FAILED || (this.status === CONSOLE_STATUS.DISCONNECTED && this.failure === CONSOLE_FAILURE.DROPPED);
},
// Only another session holding the console is something taking over can fix.
canTakeOver() {
return this.preserveSession && !this.isClosed;
return this.preserveSession && !this.isClosed && this.failure === CONSOLE_FAILURE.IN_USE;
},
failureTitle() {
return this.t(`harvester.virtualMachine.detail.console.failure.${ this.failure || CONSOLE_FAILURE.ERROR }`);
},
showReconnecting() {
return this.status === CONSOLE_STATUS.RECONNECTING;
showConnecting() {
return this.status === CONSOLE_STATUS.CONNECTING || this.status === CONSOLE_STATUS.RECONNECTING;
},
},
@@ -169,6 +188,8 @@ export default {
return;
}
// noVNC paints its own grey behind the framebuffer; let the console area show through.
rfb.background = 'transparent';
rfb.addEventListener('connect', this.onConnect);
rfb.addEventListener('disconnect', this.onDisconnect);
rfb.addEventListener('securityfailure', this.onSecurityFailure);
@@ -189,6 +210,13 @@ export default {
this.connect();
},
// Skip the wait before the next retry, still counting it as one, so a held console keeps
// being retried instead of turning into the in-use failure.
retryNow() {
this.clearReconnectTimer();
this.connect();
},
takeOver() {
this.showTakeOverModal = true;
},
@@ -262,7 +290,9 @@ export default {
// 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.statusDetail = this.isStartingUp() ? '' : this.t('harvester.virtualMachine.detail.console.status.inUse');
this.scheduleReconnect();
} else if (this.isStartingUp()) {
this.scheduleReconnect();
} else {
this.clearReconnectTimer();
@@ -270,6 +300,10 @@ export default {
}
},
isStartingUp() {
return !!this.startedAt && Date.now() - this.startedAt < STARTUP_GRACE;
},
// 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) {
@@ -369,7 +403,9 @@ export default {
const settings = this.settings || {};
// View only shows the screen but sends no mouse or keyboard input.
rfb.viewOnly = !!settings.viewOnly;
rfb.edgeToEdge = !!settings.edgeToEdge;
rfb.scaleViewport = !!settings.scaleViewport;
rfb.resizeSession = !!settings.resizeSession;
rfb.clipViewport = !!settings.clipViewport;
@@ -393,108 +429,120 @@ export default {
</script>
<template>
<div class="novnc-console">
<div
class="novnc-console"
:class="{ 'is-live': status === 'connected' }"
>
<div
ref="view"
class="novnc-screen"
:class="{ 'is-focused': focused }"
:class="{ 'is-focused': focused, 'is-edge-to-edge': settings.edgeToEdge }"
/>
<div
v-if="showError || showReconnecting"
v-if="showError || showConnecting"
class="novnc-overlay"
data-testid="vnc-overlay"
>
<div
<NovncStatusCard
v-if="showError"
class="novnc-overlay__card text-center"
:title="failureTitle"
:detail="statusDetail"
>
<h3>
{{ failureTitle }}
</h3>
<p
v-if="statusDetail"
class="mt-10"
>
{{ statusDetail }}
</p>
<button
class="btn role-primary mt-20"
<RcButton
variant="primary"
data-testid="vnc-overlay-reconnect"
@click="reconnect"
>
{{ t('harvester.action.reconnect') }}
</button>
<button
</RcButton>
<RcButton
v-if="canTakeOver"
class="btn role-secondary mt-20 ml-10"
variant="secondary"
data-testid="vnc-take-over"
@click="takeOver"
>
{{ t('harvester.virtualMachine.detail.console.takeOver.label') }}
</button>
</div>
</RcButton>
</NovncStatusCard>
<div
v-else
class="text-center"
class="novnc-overlay__connecting"
>
<h2>
{{ t('vncConsole.reconnecting.message') }}:{{ retryTimes }} of {{ maximumRetryTimes }}
</h2>
<p
v-if="statusDetail"
class="mt-10"
>
{{ statusDetail }}
<span class="novnc-overlay__spinner">
<RcIcon
type="spinner"
size="inherit"
class="icon-spin"
/>
</span>
<p class="novnc-overlay__connecting-label">
{{ t('harvester.virtualMachine.detail.console.overlay.connecting', { name: vmName }) }}
</p>
</div>
</div>
<ModalWithCard
v-if="showTakeOverModal"
name="takeOverConsole"
:width="480"
@finish="cancelTakeOver"
<p
v-if="retryTimes > 0"
class="novnc-overlay__detail"
>
<template #title>
{{ t('harvester.virtualMachine.detail.console.takeOver.title') }}
{{ t('harvester.virtualMachine.detail.console.overlay.attempt', { retryTimes, maximumRetryTimes }) }}
<template v-if="statusDetail">
{{ statusDetail }}
</template>
</p>
<RcButton
v-if="retryTimes > 0"
variant="secondary"
data-testid="vnc-reconnect-now"
@click="retryNow"
>
{{ t('harvester.virtualMachine.detail.console.overlay.reconnectNow') }}
</RcButton>
</div>
</div>
<template #content>
<RcModal
:show="showTakeOverModal"
size="small"
:title="t('harvester.virtualMachine.detail.console.takeOver.title')"
data-testid="vnc-take-over-modal"
@close="cancelTakeOver"
>
<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"
<template #primary-action>
<RcButton
variant="primary"
size="large"
data-testid="vnc-take-over-confirm"
@click="confirmTakeOver"
>
{{ t('harvester.virtualMachine.detail.console.takeOver.label') }}
</button>
</div>
</RcButton>
</template>
</ModalWithCard>
</RcModal>
</div>
</template>
<style lang="scss" scoped>
// Without a live picture the screen is black like a monitor, so the overlays read the same in
// every theme; once connected the framebuffer is the screen and the page shows around it.
.novnc-console {
position: relative;
box-sizing: border-box;
height: 100%;
padding: 8px;
overflow: hidden;
background: #000;
&.is-live {
background: transparent;
}
}
.novnc-screen {
height: 100%;
}
// The overlay sits on the black screen, so its text is light whatever the theme.
.novnc-overlay {
position: absolute;
top: 0;
@@ -506,35 +554,53 @@ export default {
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.
background: rgba(0, 0, 0, 0.6);
color: #fff;
text-align: center;
h2,
h3 {
color: #fff;
}
p {
&__detail {
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
line-height: 16px;
}
&__card {
&__connecting {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 480px;
padding: 0 20px;
}
// RcIcon has no 20px size, the one the design uses here, so it inherits it.
&__spinner {
display: flex;
font-size: 20px;
}
.take-over-actions {
display: flex;
justify-content: flex-end;
width: 100%;
&__connecting-label {
margin: 0;
font-size: 16px;
line-height: 21px;
}
}
</style>
<style lang="scss">
// noVNC sets an inline `outline: none` on its canvas, so mark keyboard focus with a ring instead.
// noVNC sets an inline `outline: none` on its canvas, so frame the screen with a ring instead:
// a thin border at rest and, while it holds the keyboard, a 2px ring set 2px off the screen so it
// never blends into a dark framebuffer edge (the SUSE light focus colour is a very dark green).
.novnc-screen canvas {
box-shadow: 0 0 0 1px var(--border);
}
.novnc-screen.is-focused canvas {
box-shadow: 0 0 0 2px var(--primary);
box-shadow: 0 0 0 2px var(--body-bg), 0 0 0 4px var(--primary-keyboard-focus);
}
.novnc-screen.is-edge-to-edge canvas {
box-shadow: none;
}
</style>
@@ -1,137 +1,113 @@
<script>
import { STEVE } from '@shell/config/types';
import { stringify } from '@shell/utils/error';
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';
import { RcButton } from '@components/RcButton';
import { RcIcon } from '@components/RcIcon';
import { RcModal } from '@components/RcModal';
import RcTag from '@components/Pill/RcTag';
import { LabeledInput } from '@components/Form/LabeledInput';
import NovncKeyRecorder from './NovncKeyRecorder';
import { readShortcuts, shortcutId, shortcutLabels, writeShortcuts } from './shortcuts';
export default {
name: 'NovncConsoleCustomKeys',
emits: ['close'],
components: {
ModalWithCard, Banner, AsyncButton
Banner, RcButton, RcIcon, RcModal, RcTag, LabeledInput, NovncKeyRecorder
},
props: {
show: {
type: Boolean,
default: false,
},
},
emits: ['close'],
data() {
return {
keysRecord: [],
addedShortcutKeys: [],
preferredShortcutKeys: [],
isRecording: false,
keys: [],
name: '',
saving: false,
error: '',
};
},
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;
preference() {
return this.$store.getters['management/all'](STEVE.PREFERENCE)?.[0];
},
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;
shortcuts() {
return readShortcuts(this.preference).map((shortcut) => ({ ...shortcut, labels: shortcutLabels(shortcut) }));
},
recordButton() {
if (this.isRecording) {
return 'harvester.virtualMachine.detail.console.record.stop';
}
isDuplicate() {
const id = shortcutId({ keys: this.keys });
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(',') }`;
return !!this.keys.length && this.shortcuts.some((shortcut) => shortcutId(shortcut) === id);
},
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;
return !!this.keys.length && !this.isDuplicate;
},
},
watch: {
savedShortcutKeys: {
handler() {
this.preferredShortcutKeys = [].concat(this.savedShortcutKeys) || [];
},
immediate: true
show(show) {
if (!show) {
this.error = '';
}
},
},
methods: {
closeRecordingModal() {
window.removeEventListener('keydown', this.handleShortcut);
this.$emit('close');
clear() {
this.keys = [];
this.name = '';
},
toggleRecording() {
this.isRecording = !this.isRecording;
// The buttons stay enabled while a change saves: disabling the one that was pressed would drop the
// keyboard's focus to the page.
async add() {
if (!this.canAdd || this.saving) {
return;
}
if (this.isRecording) {
this.keysRecord = [];
window.addEventListener('keydown', this.handleShortcut);
} else {
window.removeEventListener('keydown', this.handleShortcut);
const { name, keys } = this;
const shortcuts = [...this.shortcuts, { name: name.trim(), keys }];
// The list shows the new shortcut while it saves, and a form still holding it would say it is already
// saved, so the form empties now. Add shortcut turns disabled with it, so the recorder takes the focus.
this.clear();
this.$refs.recorder?.$el?.focus();
const added = await this.save(shortcuts);
// A failed save takes the shortcut out of the list again, so it goes back in the form, unless the next
// one was started meanwhile.
if (!added && !this.keys.length && !this.name) {
this.keys = keys;
this.name = name;
}
},
handleShortcut(event) {
event.preventDefault();
async remove(index) {
if (this.saving) {
return;
}
const {
key, keyCode, code, location, charCode
} = event;
const removed = await this.save(this.shortcuts.filter((_, i) => i !== index));
this.keysRecord.push({
key, keyCode, code, location, charCode
});
},
// The row may be gone with the button that had the focus: the delete button now in its place, or
// the last one, takes it, and the recorder once the list is empty.
if (removed) {
this.$nextTick(() => {
const buttons = this.$refs.list?.querySelectorAll('button') || [];
<<<<<<< HEAD
addShortcutKey() {
this.addedShortcutKeys.push([].concat(this.keysRecord));
},
@@ -150,71 +126,75 @@ export default {
return formatkey !== key;
});
=======
(buttons[index] || buttons[buttons.length - 1] || this.$refs.recorder?.$el)?.focus();
});
}
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
},
async saveKeys(buttonCb) {
const out = [].concat(this.preferredShortcutKeys, this.addedShortcutKeys);
const preference = this.$store.getters['management/all'](STEVE.PREFERENCE)?.[0];
// Every change is saved straight away, so closing the modal can never lose one.
async save(shortcuts) {
this.saving = true;
this.error = '';
try {
preference.data[PREFERED_SHORTCUT_KEYS] = JSON.stringify(out);
await preference.save();
this.closeRecordingModal();
buttonCb(true);
await writeShortcuts(this.preference, shortcuts.map(({ name, keys }) => ({ name, keys })));
return true;
} catch (err) {
buttonCb(false);
// The console page has no growls, so the dialog says it itself.
const title = this.t('harvester.virtualMachine.detail.console.customKeys.saveError');
const reason = stringify(err);
this.error = reason ? `${ title }: ${ reason }` : title;
return false;
} finally {
this.saving = false;
}
},
}
},
};
</script>
<template>
<ModalWithCard
name="recordShortcutKeys"
:width="550"
<RcModal
:show="show"
size="medium"
:title="t('harvester.virtualMachine.detail.console.customKeys.title')"
data-testid="vnc-custom-keys-modal"
@close="$emit('close')"
>
<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
v-if="error"
color="error"
role="alert"
data-testid="vnc-custom-keys-error"
>
{{ error }}
</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>
<p class="custom-keys__intro">
{{ t('harvester.virtualMachine.detail.console.customKeys.description') }}
</p>
<div class="displayed-keys mt-20">
<h4
v-clean-html="t('harvester.virtualMachine.detail.console.record.preferredKeys')"
class="text-default-text"
<section class="custom-keys__new">
<h3 class="custom-keys__heading">
{{ t('harvester.virtualMachine.detail.console.customKeys.new') }}
</h3>
<div class="custom-keys__field">
<span
id="vnc-key-combination-label"
class="custom-keys__label"
>{{ t('harvester.virtualMachine.detail.console.customKeys.combination') }}</span>
<NovncKeyRecorder
ref="recorder"
v-model:value="keys"
aria-labelledby="vnc-key-combination-label"
/>
<<<<<<< HEAD
<div class="displayed-banners">
<Banner
@@ -239,26 +219,185 @@ export default {
mode="done"
@click="saveKeys"
/>
=======
<p
v-if="isDuplicate"
class="custom-keys__error"
role="alert"
>
{{ t('harvester.virtualMachine.detail.console.customKeys.duplicate') }}
</p>
<p class="custom-keys__help">
{{ t('harvester.virtualMachine.detail.console.customKeys.reserved') }}
</p>
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
</div>
<LabeledInput
v-model:value="name"
:label="t('harvester.virtualMachine.detail.console.customKeys.name')"
data-testid="vnc-shortcut-name"
@keyup.enter="add"
/>
<div class="custom-keys__new-actions">
<RcButton
variant="secondary"
:disabled="!keys.length && !name"
@click="clear"
>
{{ t('harvester.virtualMachine.detail.console.customKeys.clear') }}
</RcButton>
<RcButton
variant="primary"
data-testid="vnc-add-shortcut"
:disabled="!canAdd"
@click="add"
>
{{ t('harvester.virtualMachine.detail.console.customKeys.add') }}
</RcButton>
</div>
</section>
<section v-if="shortcuts.length">
<h3 class="custom-keys__heading">
{{ t('harvester.virtualMachine.detail.console.customKeys.saved', { count: shortcuts.length }) }}
</h3>
<ul
ref="list"
class="custom-keys__list"
>
<li
v-for="(shortcut, index) in shortcuts"
:key="index"
class="custom-keys__item"
data-testid="vnc-custom-shortcut"
>
<span class="custom-keys__item-name">{{ shortcut.name || shortcut.labels.join('+') }}</span>
<span class="custom-keys__item-keys">
<RcTag
v-for="(label, i) in shortcut.labels"
:key="i"
type="active"
>
{{ label }}
</RcTag>
</span>
<RcButton
variant="tertiary"
:aria-label="t('harvester.virtualMachine.detail.console.customKeys.delete', { name: shortcut.name || shortcut.labels.join('+') }, true)"
@click="remove(index)"
>
<RcIcon
type="trash"
size="inherit"
/>
</RcButton>
</li>
</ul>
</section>
<template #actions="{ close }">
<RcButton
variant="primary"
size="large"
data-testid="vnc-custom-keys-done"
@click="close"
>
{{ t('harvester.virtualMachine.detail.console.customKeys.done') }}
</RcButton>
</template>
</ModalWithCard>
</RcModal>
</template>
<style lang="scss" scoped>
.displayed-keys {
.banner {
.custom-keys {
&__intro {
margin: 0;
}
&__new {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 20px 20px;
border: 1px solid var(--border);
border-radius: 8px;
}
&__heading {
margin: 0 0 12px;
font-size: 14px;
font-weight: 700;
line-height: 21px;
.custom-keys__new & {
margin: 0;
}
}
.displayed-banners {
max-height: 155px;
overflow: auto;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions {
width: 100%;
&__label {
color: var(--input-label);
font-size: 14px;
line-height: 21px;
}
&__help,
&__error {
margin: 0;
font-size: 12px;
line-height: 18px;
}
&__help {
color: var(--muted);
}
&__error {
color: var(--error);
}
&__new-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
&__list {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
&__item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px 12px 20px;
border: 1px solid var(--border);
border-radius: 8px;
}
&__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__item-keys {
display: flex;
gap: 6px;
}
}
</style>
@@ -1,108 +0,0 @@
<script>
export default {
name: 'NovncConsoleItem',
emits: ['send-keys', 'update'],
props: {
items: {
type: Object,
required: true,
default: () => {
return {};
}
},
path: {
type: Array,
required: true,
default: () => {
return [];
}
},
pos: {
type: Number,
required: true,
default: 0,
}
},
methods: {
keysDown(key, pos) {
this.addKeys({ key, pos });
this.$emit('send-keys');
},
addKeys({ key, pos }) {
this.$emit('update', { key, pos });
},
sendKeys() {
this.$emit('send-keys');
},
getOpenStatus(key, pos) {
return this.path[pos] === key;
}
}
};
</script>
<template>
<ul class="list-unstyled dropdown combination-keys__container">
<li
v-for="(item, key) in items"
:key="key"
>
<v-dropdown
v-if="!!item.keys"
placement="right-start"
trigger="click"
:container="false"
>
<span
:class="{ open: getOpenStatus(key, pos) }"
class="p-10 hand"
@click="addKeys({ key, pos })"
>{{ item.label }}</span>
<template #popper>
<novnc-console-item
:items="item.keys"
:path="path"
:pos="pos+1"
@update="addKeys"
@send-keys="sendKeys"
/>
</template>
</v-dropdown>
<span
v-else
class="p-10 hand"
@click="keysDown(key, pos)"
>{{ item.label }}</span>
</li>
</ul>
</template>
<style lang="scss" scoped>
.combination-keys__container {
max-width: 60px;
DIV, SPAN {
display: block;
text-align: center;
}
SPAN {
border-radius: 3px;
&:hover, &.open {
color: var(--primary-hover-text);
background: var(--primary-hover-bg);
}
}
}
</style>
@@ -1,31 +1,36 @@
<script>
import { STEVE } from '@shell/config/types';
import ModalWithCard from '@shell/components/ModalWithCard';
import { RcButton } from '@components/RcButton';
import { RcModal } from '@components/RcModal';
import { Checkbox } from '@components/Form/Checkbox';
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 NovncConsole, { CONSOLE_STATUS } from './NovncConsole';
import NovncConsoleCustomKeys from './NovncConsoleCustomKeys';
import NovncConnectionStatus, { CONNECTION_STATE } from './NovncConnectionStatus';
import NovncIcon from './NovncIcon';
import NovncShortcutKeysMenu from './NovncShortcutKeysMenu';
import NovncStatusCard from './NovncStatusCard';
import NovncViewOptions, { VIEW_MODE } from './NovncViewOptions';
import { baseKey, readShortcuts } from './shortcuts';
import { normalizeZoom } from './ZoomableRFB';
const CONSOLE_SETTINGS_KEY = 'harvester-vnc-console-settings';
const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
// The previous console saved its defaults under its key the first time it opened, so they can't tell a
// choice from a default. This one keeps its own key and writes it only once the user changes a setting.
const CONSOLE_SETTINGS_KEY = 'harvester-vnc-console-view';
// Only View only is taken from the previous console: it was off unless the user turned it on.
const PREVIOUS_SETTINGS_KEY = 'harvester-vnc-console-settings';
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,
viewMode: VIEW_MODE.SCALE,
zoomPercent: 100,
viewOnly: false,
qualityLevel: 6,
@@ -127,15 +132,29 @@ const F_KEYS = {
export default {
name: 'NovncConsoleWrapper',
components: {
ModalWithCard, NovncConsole, NovncConsoleItem, NovncConsoleCustomKeys
RcButton, RcModal, Checkbox, NovncConsole, NovncConsoleCustomKeys, NovncConnectionStatus, NovncIcon, NovncShortcutKeysMenu, NovncStatusCard, NovncViewOptions
},
// A VM that is not running has no instance, so find it by the uid in the route instead.
async fetch() {
const _hash = { vmResource: this.$store.dispatch('harvester/find', { type: HCI.VM, id: this.value.id }) };
const hash = await allHash(_hash);
if (this.value?.id) {
const hash = await allHash({ vmResource: this.$store.dispatch('harvester/find', { type: HCI.VM, id: this.value.id }) });
this.vmResource = hash.vmResource;
return;
}
const uid = this.$route?.params?.uid;
try {
const vms = uid ? await this.$store.dispatch('harvester/findAll', { type: HCI.VM }) : [];
this.vmResource = (vms || []).find((vm) => vm.metadata?.uid === uid) || {};
} catch (err) {
// Only the name is missing then; the not-running card still shows.
this.vmResource = {};
}
},
props: {
@@ -150,13 +169,13 @@ export default {
data() {
return {
keysRecord: [],
vmResource: {},
renderKeysModal: false,
settings: { ...DEFAULT_SETTINGS },
capabilities: {},
isFullscreen: false,
confirmAction: null,
startedAt: 0,
consoleStatus: {
status: 'connecting',
retryTimes: 0,
@@ -176,35 +195,55 @@ export default {
return !this.preference?.data;
},
savedShortcutKeys() {
const preferedShortcutKeys = this.preference?.data?.[PREFERED_SHORTCUT_KEYS];
if (!preferedShortcutKeys) {
return [];
}
try {
return JSON.parse(preferedShortcutKeys);
} catch (err) {
return [];
}
},
preferredShortcutKeys() {
return (this.savedShortcutKeys || []).map((item) => {
return {
label: item.map((K) => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join('+'),
value: item
};
});
return readShortcuts(this.preference);
},
isConnected() {
return this.consoleStatus.status === CONSOLE_STATUS.CONNECTED;
},
// A console that just went away can still report connected for a moment.
canSendKeys() {
return this.consoleStatus.status === 'connected' && !this.settings.viewOnly;
return !this.isDown && this.isConnected && !this.settings.viewOnly;
},
// Shared by the toolbar and the full screen bar.
shortcutMenuProps() {
return {
canSend: this.canSendKeys,
blockedReason: this.sendBlockedReason,
canManage: !this.hideCustomKeysBar,
disabled: !!this.settings.viewOnly || (!this.canSendKeys && this.hideCustomKeysBar),
shortcuts: this.preferredShortcutKeys,
};
},
sendBlockedReason() {
return this.canSendKeys ? '' : this.t('harvester.virtualMachine.detail.console.shortcutMenu.blockedDisconnected');
},
vmName() {
return this.value?.metadata?.name || this.vmResource?.metadata?.name || '';
},
vmNamespace() {
return this.value?.metadata?.namespace || this.vmResource?.metadata?.namespace || '';
},
// Without an instance there is no console to connect to, so the toolbar says so.
connectionStatus() {
return this.isDown ? CONNECTION_STATE.NOT_RUNNING : this.consoleStatus.status;
},
// VNC only exists while the instance runs. A starting instance (Pending, Scheduling) would refuse
// the first connection and leave "Unable to connect", so the console waits for Running instead.
isDown() {
return this.isEmpty(this.value);
return this.isEmpty(this.value) || !this.vmRunning;
},
isStarting() {
return !this.isEmpty(this.value) && ['Pending', 'Scheduling', 'Scheduled'].includes(this.value?.status?.phase);
},
preserveSession() {
@@ -230,36 +269,16 @@ export default {
};
},
keymap() {
const out = {
...SHORT_KEYS,
PrintScreen: FUNCTION_KEYS.PrintScreen,
...F_KEYS,
};
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,
},
};
return out;
},
hasSoftRebootAction() {
return !!this.vmResource?.actions?.softreboot;
},
rfbSettings() {
return {
viewOnly: this.settings.viewOnly,
scaleViewport: this.settings.viewMode === VIEW_MODE.SCALE,
viewOnly: !!this.settings.viewOnly,
// Full screen always fills the window; the saved mode is back on leaving it.
scaleViewport: this.isFullscreen || this.settings.viewMode === VIEW_MODE.SCALE,
edgeToEdge: this.isFullscreen,
zoomPercent: normalizeZoom(this.settings.zoomPercent),
resizeSession: false,
clipViewport: false,
@@ -269,22 +288,20 @@ export default {
};
},
viewModeOptions() {
return Object.values(VIEW_MODE).map((value) => ({
value,
label: this.t(`harvester.virtualMachine.detail.console.settings.viewMode.${ value }`)
}));
},
// The pill has one label per state it shows; retries are counted on the console overlay.
statusLabel() {
const { status, retryTimes, maximumRetryTimes } = this.consoleStatus;
if (status === 'reconnecting') {
return this.t('harvester.virtualMachine.detail.console.status.reconnecting', { retryTimes, maximumRetryTimes });
if (this.isDown) {
return this.t('harvester.virtualMachine.detail.console.status.notRunning');
}
if (status === 'connected' && this.settings.viewOnly) {
return this.t('harvester.virtualMachine.detail.console.status.connectedViewOnly');
const { status } = this.consoleStatus;
if (status === CONSOLE_STATUS.RECONNECTING) {
return this.t('harvester.virtualMachine.detail.console.status.connecting');
}
if (status === CONSOLE_STATUS.FAILED) {
return this.t('harvester.virtualMachine.detail.console.status.disconnected');
}
return this.t(`harvester.virtualMachine.detail.console.status.${ status }`);
@@ -292,10 +309,14 @@ export default {
},
watch: {
settings: {
deep: true,
handler(neu) {
window.localStorage.setItem(CONSOLE_SETTINGS_KEY, JSON.stringify(neu));
// The console unmounts without a last status, so start over as connecting for when it is back.
isDown(down, wasDown) {
if (down) {
this.consoleStatus = {
status: CONSOLE_STATUS.CONNECTING, retryTimes: 0, maximumRetryTimes: 0, desktopName: ''
};
} else if (wasDown) {
this.startedAt = Date.now();
}
},
},
@@ -315,30 +336,24 @@ export default {
},
close() {
this.$refs.novncConsole.disconnect();
this.$refs.novncConsole?.disconnect();
},
update({ key, pos }) {
this.keysRecord.splice(pos, this.keysRecord.length - pos, key);
},
// Send function key, e.g. ALT + F
sendKeys() {
// Press the keys in order, then release them in reverse, e.g. Ctrl+Alt+F2.
sendKeys(keys) {
if (!this.canSendKeys || !this.$refs.novncConsole) {
return;
}
this.keysRecord.forEach((key) => {
keys.forEach((key) => {
this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, true);
});
[...this.keysRecord].reverse().forEach((key) => {
[...keys].reverse().forEach((key) => {
this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, false);
});
this.$refs.popover.hide();
this.keysRecord = [];
this.$refs.novncConsole.focus();
this.focusConsole();
},
sendCustomKeys(keys) {
@@ -346,7 +361,8 @@ export default {
return;
}
const keyList = [].concat(keys);
const combination = [].concat(keys);
const keyList = combination.map((K) => baseKey(K, combination));
keyList.forEach((K) => {
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), true);
@@ -356,7 +372,29 @@ export default {
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), false);
});
this.$refs.novncConsole.focus();
this.focusConsole();
},
// The shortcut menu hands focus back to its trigger as it closes, so take it on the next tick.
focusConsole() {
this.$nextTick(() => this.$refs.novncConsole?.focus());
},
// RcModal's focus trap returns focus to the opener in a timeout once the modal unmounts,
// so queue ours behind it.
focusConsoleAfterModal() {
this.$nextTick(() => setTimeout(() => this.$refs.novncConsole?.focus()));
},
// Closing the View panel from the keyboard (Esc) leaves the focus nowhere; give it back to the
// View button, as the menus do. A click elsewhere keeps the focus it moved.
onViewHide() {
const focused = document.activeElement;
// floating-vue hides the panel without removing it, so the focus is still in it at this point.
if (!focused || focused === document.body || focused.closest('.v-popper__popper')) {
this.$refs.viewButton?.focus();
}
},
showKeysModal() {
@@ -365,20 +403,22 @@ export default {
hideKeysModal() {
this.renderKeysModal = false;
this.focusConsoleAfterModal();
},
reconnect() {
this.$refs.novncConsole.reconnect();
this.$refs.novncConsole?.reconnect();
},
loadSettings() {
try {
const saved = JSON.parse(window.localStorage.getItem(CONSOLE_SETTINGS_KEY) || '{}');
const saved = JSON.parse(window.localStorage.getItem(CONSOLE_SETTINGS_KEY) || 'null');
const previous = saved ? null : JSON.parse(window.localStorage.getItem(PREVIOUS_SETTINGS_KEY) || 'null');
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,
viewOnly: (saved || previous)?.viewOnly === true,
qualityLevel: saved?.qualityLevel ?? DEFAULT_SETTINGS.qualityLevel,
compressionLevel: saved?.compressionLevel ?? DEFAULT_SETTINGS.compressionLevel,
};
@@ -387,6 +427,31 @@ export default {
}
},
saveSettings() {
window.localStorage.setItem(CONSOLE_SETTINGS_KEY, JSON.stringify(this.settings));
},
updateSettings(settings) {
this.settings = settings;
this.saveSettings();
},
// Back to sending input, the keyboard should reach the VM without clicking the screen first.
setViewOnly(viewOnly) {
this.settings = { ...this.settings, viewOnly };
this.saveSettings();
if (!viewOnly) {
this.focusConsole();
}
},
// View only belongs to the toolbar, not the View panel, so the panel's reset leaves it as it is.
resetSettings() {
this.settings = { ...DEFAULT_SETTINGS, viewOnly: this.settings.viewOnly };
this.saveSettings();
},
onStatusChanged(info) {
this.consoleStatus = info;
},
@@ -403,18 +468,22 @@ export default {
},
softReboot() {
if (!this.settings.viewOnly) {
this.confirmAction = CONFIRM_ACTION.SOFT_REBOOT;
}
},
// The modal returns focus to the console canvas (return-focus-selector) once it closes.
// RcModal hands focus back to the toolbar button that opened it; the screen should get it.
cancelConfirm() {
this.confirmAction = null;
this.focusConsoleAfterModal();
},
runConfirmedAction() {
const action = this.confirmAction;
this.confirmAction = null;
this.focusConsoleAfterModal();
if (action === CONFIRM_ACTION.CTRL_ALT_DEL && this.canSendKeys) {
this.$refs.novncConsole?.ctrlAltDelete();
@@ -428,13 +497,23 @@ export default {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
<<<<<<< HEAD
await this.$refs.consoleContainer.requestFullscreen();
// Keyboard lock lets shortcuts such as Ctrl+W and F11 reach the guest.
await navigator.keyboard?.lock?.(['KeyW', 'F11']);
=======
// The whole page, not just the console: menus and modals render outside it.
await document.documentElement.requestFullscreen();
// Keyboard lock (Chromium) lets browser shortcuts such as Ctrl+W, Ctrl+T, Ctrl+N and F11
// reach the guest; outside full screen the browser keeps them.
await navigator.keyboard?.lock?.(['KeyW', 'KeyT', 'KeyN', 'F11']);
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
}
this.focusConsole();
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.value.metadata.name) }),
title: this.t('generic.notification.title.error', { name: escapeHtml(this.vmName) }),
err,
}, { root: true });
}
@@ -457,34 +536,152 @@ export default {
<div
ref="consoleContainer"
class="vm-console"
<<<<<<< HEAD
>
<div class="combination-keys">
<div class="toolbar-group">
=======
:class="{ 'vm-console--fullscreen': isFullscreen }"
>
<div
v-if="isFullscreen"
class="fullscreen-bar"
data-testid="vnc-fullscreen-bar"
>
<span class="fullscreen-bar__name">{{ vmName }}</span>
<NovncConnectionStatus
:status="connectionStatus"
:label="statusLabel"
:vm-name="vmName"
@reconnect="reconnect"
/>
<span
class="console-toolbar__divider"
aria-hidden="true"
/>
<NovncShortcutKeysMenu
v-bind="shortcutMenuProps"
@send-keys="sendKeys"
@send-shortcut="sendCustomKeys"
@manage="showKeysModal"
@ctrl-alt-del="ctrlAltDelete"
/>
<span
class="console-toolbar__divider"
aria-hidden="true"
/>
<RcButton
variant="tertiary"
data-testid="vnc-exit-fullscreen"
@click="toggleFullscreen"
>
<template #before>
<NovncIcon type="minimize" />
</template>
{{ t('harvester.virtualMachine.detail.console.fullscreen.exit') }}
</RcButton>
</div>
<header
v-else
class="console-toolbar"
>
<div class="console-toolbar__identity">
<div class="console-toolbar__vm">
<span
class="console-toolbar__vm-name"
data-testid="vnc-vm-name"
>{{ vmName }}</span>
<span class="console-toolbar__vm-namespace">{{ vmNamespace }}</span>
</div>
<NovncConnectionStatus
:status="connectionStatus"
:label="statusLabel"
:vm-name="vmName"
@reconnect="reconnect"
/>
<Checkbox
class="console-toolbar__view-only"
:value="!!settings.viewOnly"
:label="t('harvester.virtualMachine.detail.console.viewOnly')"
use-body-text-color
data-testid="vnc-view-only"
@update:value="setViewOnly"
/>
</div>
<div class="console-toolbar__group">
<NovncShortcutKeysMenu
v-bind="shortcutMenuProps"
@send-keys="sendKeys"
@send-shortcut="sendCustomKeys"
@manage="showKeysModal"
@ctrl-alt-del="ctrlAltDelete"
/>
</div>
<span
class="console-toolbar__divider"
aria-hidden="true"
/>
<div class="console-toolbar__group">
<RcButton
variant="tertiary"
data-testid="vnc-ctrl-alt-del"
:disabled="!canSendKeys"
@click="ctrlAltDelete"
>
<template #before>
<NovncIcon type="power" />
</template>
{{ t("harvester.virtualMachine.detail.console.ctrlAltDel") }}
</RcButton>
<RcButton
v-if="hasSoftRebootAction"
variant="tertiary"
left-icon="refresh-alt"
data-testid="vnc-soft-reboot"
:disabled="!!settings.viewOnly"
@click="softReboot"
>
{{ t("harvester.virtualMachine.detail.console.softReboot") }}
</RcButton>
</div>
<span class="console-toolbar__spacer" />
<div class="console-toolbar__group">
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
<v-dropdown
ref="popover"
placement="top"
placement="bottom-end"
trigger="click"
:container="false"
@auto-hide="keysRecord = []"
@hide="onViewHide"
>
<button
class="btn btn-sm bg-primary"
:disabled="!canSendKeys"
<RcButton
ref="viewButton"
variant="tertiary"
left-icon="show"
right-icon="chevron-down"
data-testid="vnc-view"
>
{{ t("harvester.virtualMachine.detail.console.shortcutKeys") }}
</button>
{{ t("harvester.virtualMachine.detail.console.view") }}
</RcButton>
<template #popper>
<novnc-console-item
:items="keymap"
:path="keysRecord"
:pos="0"
@update="update"
@send-keys="sendKeys"
<NovncViewOptions
:settings="settings"
@update="updateSettings"
@reset="resetSettings"
/>
</template>
</v-dropdown>
<<<<<<< HEAD
<button
class="btn btn-sm bg-primary"
:disabled="!canSendKeys"
@@ -645,13 +842,22 @@ export default {
<button
class="btn btn-sm bg-primary"
=======
<RcButton
variant="tertiary"
data-testid="vnc-fullscreen"
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
@click="toggleFullscreen"
>
<template #before>
<NovncIcon :type="isFullscreen ? 'minimize' : 'maximize'" />
</template>
{{ isFullscreen ? t('harvester.virtualMachine.detail.console.fullscreen.exit') : t('harvester.virtualMachine.detail.console.fullscreen.enter') }}
</button>
</div>
</RcButton>
</div>
</header>
<div class="console-area">
<NovncConsole
v-if="url && !isDown"
ref="novncConsole"
@@ -659,49 +865,57 @@ export default {
:settings="rfbSettings"
:preserve-session="preserveSession"
:vm-running="vmRunning"
:vm-name="vmName"
:started-at="startedAt"
@status-changed="onStatusChanged"
@capabilities="onCapabilities"
/>
<p v-if="isDown">
{{ t("harvester.virtualMachine.detail.console.down") }}
<div
v-if="isDown"
class="console-area__down"
data-testid="vnc-not-running"
>
<NovncStatusCard
icon="power"
:title="t(`harvester.virtualMachine.detail.console.notRunning.${ isStarting ? 'startingTitle' : 'title' }`)"
:detail="t(`harvester.virtualMachine.detail.console.notRunning.${ isStarting ? 'startingDetail' : 'detail' }`)"
/>
</div>
<p
v-if="!isFullscreen && !isDown"
class="console-area__hint"
>
{{ t(settings.viewOnly ? 'harvester.virtualMachine.detail.console.hintViewOnly' : 'harvester.virtualMachine.detail.console.hint', {}, true) }}
</p>
</div>
<RcModal
:show="!!confirmAction"
size="small"
:title="confirmAction ? t(`harvester.virtualMachine.detail.console.confirm.${ confirmAction }.title`, { name: vmName }) : ''"
data-testid="vnc-confirm-modal"
@close="cancelConfirm"
>
<p v-if="confirmAction">
{{ t(`harvester.virtualMachine.detail.console.confirm.${ confirmAction }.message`) }}
</p>
<ModalWithCard
<template #primary-action>
<RcButton
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"
variant="primary"
size="large"
class="confirm-modal__danger"
data-testid="vnc-confirm-action"
@click="runConfirmedAction"
>
{{ t(`harvester.virtualMachine.detail.console.confirm.${ confirmAction }.button`) }}
</button>
</div>
</RcButton>
</template>
</ModalWithCard>
</RcModal>
<NovncConsoleCustomKeys
v-if="renderKeysModal"
:show="renderKeysModal"
@close="hideKeysModal"
/>
</div>
@@ -713,16 +927,49 @@ export default {
height: 100vh;
height: 100dvh;
display: grid;
grid-template-rows: 32px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
background: var(--body-bg);
// The toolbar gives way to a bar floating over the screen, which takes the whole window. A picture
// of another shape keeps its proportions, with black around it like on a monitor.
&--fullscreen {
position: relative;
grid-template-rows: minmax(0, 1fr);
background: #000;
.console-area {
gap: 0;
padding: 0;
}
}
}
.combination-keys {
.fullscreen-bar {
// vnc.vue stretches the console's child divs to full height; the bar keeps its own.
height: auto;
position: absolute;
top: 16px;
left: 50%;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
background: rgb(40, 40, 40);
}
justify-content: center;
gap: 12px;
// From left: 50% it would only get half the window and squeeze its buttons; it wraps when it has to.
width: max-content;
max-width: calc(100vw - 16px);
padding: 4px 4px 4px 16px;
border: 1px solid var(--border);
border-radius: 24px;
background: var(--nav-bg);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
opacity: 0.7;
transform: translateX(-50%);
transition: opacity 0.15s ease;
<<<<<<< HEAD
.toolbar-group {
display: flex;
align-items: center;
@@ -752,18 +999,126 @@ export default {
justify-content: space-between;
margin-top: 8px;
font-variant-numeric: tabular-nums;
=======
// Solid while in use: the pointer is on it, the keyboard is in it, or one of its menus is open.
&:hover,
&:has(:focus-visible),
&:has([aria-expanded='true']) {
opacity: 1;
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
}
&__zoom {
width: 100%;
margin: 4px 0 8px;
&__name {
color: var(--body-text);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
}
&__option {
// Narrow windows wrap the toolbar instead of pushing the screen out of view.
.console-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 10px 16px;
background: var(--nav-bg);
&__identity {
display: flex;
align-items: center;
gap: 6px;
margin: 2px 0;
gap: 16px;
padding-right: 8px;
}
&__vm {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__vm-name {
color: var(--body-text);
font-size: 14px;
font-weight: 700;
line-height: 21px;
}
&__vm-namespace {
color: var(--muted);
font-size: 12px;
line-height: 18px;
}
// A Checkbox keeps 10px after its label for its place in a form; here the toolbar sets the gap.
&__view-only {
margin-right: -10px;
}
&__group {
display: flex;
align-items: center;
gap: 4px;
}
&__divider {
width: 1px;
height: 24px;
background: var(--border);
}
&__spacer {
flex: 1;
}
}
.console-area {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
padding: 24px;
// vnc.vue stretches every nested div to the full height; the hint needs its row.
> .novnc-console {
flex: 1 1 auto;
height: auto;
min-height: 0;
}
&__down {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 0;
background: #000;
}
&__hint {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 18px;
text-align: center;
}
}
// RcButton has no destructive variant; this is how the shell's own dialogs colour one. The class
// is doubled to outrank RcButton's variant rules instead of relying on stylesheet order.
.confirm-modal__danger.confirm-modal__danger.rc-button {
&,
&:focus,
&.focused {
background-color: var(--error);
color: var(--error-text);
}
&:hover:not(:disabled) {
background-color: var(--error-hover-bg);
color: var(--error-hover-text);
}
}
</style>
@@ -0,0 +1,73 @@
<script>
// Icons the console needs that RcIcon does not have yet. Paths come from the VNC console design, on
// its 24px grid.
const ICONS = {
power: {
viewBox: '0 0 24 24',
paths: [
'M11.1253 12V2C11.1253 1.51675 11.517 1.125 12.0003 1.125C12.4835 1.125 12.8753 1.51675 12.8753 2V12C12.8753 12.4832 12.4835 12.875 12.0003 12.875C11.517 12.875 11.1253 12.4832 11.1253 12Z',
'M17.7812 5.98086C18.123 5.63921 18.6779 5.64006 19.0195 5.98184C20.398 7.361 21.337 9.11746 21.7187 11.0297C22.1004 12.9421 21.9076 14.9251 21.1641 16.7279C20.4204 18.5309 19.1592 20.0733 17.54 21.1605C15.921 22.2477 14.0166 22.8311 12.0664 22.8373C10.1161 22.8434 8.20695 22.2728 6.58105 21.1957C4.95519 20.1186 3.68464 18.5838 2.92968 16.7855C2.1748 14.9875 1.96928 13.006 2.33886 11.0912C2.70859 9.17628 3.63766 7.41274 5.00781 6.0248C5.3473 5.68128 5.90132 5.67771 6.24511 6.01699C6.58896 6.35643 6.59224 6.91038 6.25292 7.2543C5.12564 8.39621 4.36185 9.84678 4.05761 11.4223C3.75341 12.9978 3.92278 14.6283 4.54394 16.1078C5.16512 17.5874 6.21008 18.8505 7.54785 19.7367C8.88557 20.6229 10.4559 21.0923 12.0605 21.0873C13.6651 21.0823 15.2323 20.6028 16.5644 19.7084C17.8966 18.8138 18.934 17.5443 19.5459 16.0609C20.1578 14.5775 20.317 12.9461 20.0029 11.3725C19.6888 9.79885 18.9157 8.35303 17.7812 7.21816C17.4399 6.87639 17.4396 6.3224 17.7812 5.98086Z',
],
},
maximize: {
viewBox: '0 0 24 24',
paths: [
'M2.125 19V16C2.125 15.5168 2.51675 15.125 3 15.125C3.48325 15.125 3.875 15.5168 3.875 16V19C3.875 19.2984 3.99312 19.5849 4.2041 19.7959C4.41508 20.0069 4.70163 20.125 5 20.125H8C8.48325 20.125 8.875 20.5168 8.875 21C8.875 21.4832 8.48325 21.875 8 21.875H5C4.2375 21.875 3.50596 21.5724 2.9668 21.0332C2.42763 20.494 2.125 19.7625 2.125 19ZM20.125 19V16C20.125 15.5168 20.5168 15.125 21 15.125C21.4832 15.125 21.875 15.5168 21.875 16V19C21.875 19.7625 21.5724 20.494 21.0332 21.0332C20.494 21.5724 19.7625 21.875 19 21.875H16C15.5168 21.875 15.125 21.4832 15.125 21C15.125 20.5168 15.5168 20.125 16 20.125H19C19.2984 20.125 19.5849 20.0069 19.7959 19.7959C20.0069 19.5849 20.125 19.2984 20.125 19ZM2.125 8V5C2.125 4.2375 2.42763 3.50596 2.9668 2.9668C3.50596 2.42763 4.2375 2.125 5 2.125H8C8.48325 2.125 8.875 2.51675 8.875 3C8.875 3.48325 8.48325 3.875 8 3.875H5C4.70163 3.875 4.41508 3.99312 4.2041 4.2041C3.99312 4.41508 3.875 4.70163 3.875 5V8C3.875 8.48325 3.48325 8.875 3 8.875C2.51675 8.875 2.125 8.48325 2.125 8ZM20.125 8V5C20.125 4.70163 20.0069 4.41508 19.7959 4.2041C19.5849 3.99312 19.2984 3.875 19 3.875H16C15.5168 3.875 15.125 3.48325 15.125 3C15.125 2.51675 15.5168 2.125 16 2.125H19C19.7625 2.125 20.494 2.42763 21.0332 2.9668C21.5724 3.50596 21.875 4.2375 21.875 5V8C21.875 8.48325 21.4832 8.875 21 8.875C20.5168 8.875 20.125 8.48325 20.125 8Z',
],
},
minimize: {
viewBox: '0 0 24 24',
paths: [
'M7.125 21V18C7.125 17.7016 7.00688 17.4151 6.7959 17.2041C6.58492 16.9931 6.29837 16.875 6 16.875H3C2.51675 16.875 2.125 16.4832 2.125 16C2.125 15.5168 2.51675 15.125 3 15.125H6C6.7625 15.125 7.49404 15.4276 8.0332 15.9668C8.57237 16.506 8.875 17.2375 8.875 18V21C8.875 21.4832 8.48325 21.875 8 21.875C7.51675 21.875 7.125 21.4832 7.125 21ZM15.125 21V18C15.125 17.2375 15.4276 16.506 15.9668 15.9668C16.506 15.4276 17.2375 15.125 18 15.125H21C21.4832 15.125 21.875 15.5168 21.875 16C21.875 16.4832 21.4832 16.875 21 16.875H18C17.7016 16.875 17.4151 16.9931 17.2041 17.2041C16.9931 17.4151 16.875 17.7016 16.875 18V21C16.875 21.4832 16.4832 21.875 16 21.875C15.5168 21.875 15.125 21.4832 15.125 21ZM7.125 6V3C7.125 2.51675 7.51675 2.125 8 2.125C8.48325 2.125 8.875 2.51675 8.875 3V6C8.875 6.7625 8.57237 7.49404 8.0332 8.0332C7.49404 8.57237 6.7625 8.875 6 8.875H3C2.51675 8.875 2.125 8.48325 2.125 8C2.125 7.51675 2.51675 7.125 3 7.125H6C6.29837 7.125 6.58492 7.00688 6.7959 6.7959C7.00688 6.58492 7.125 6.29837 7.125 6ZM15.125 6V3C15.125 2.51675 15.5168 2.125 16 2.125C16.4832 2.125 16.875 2.51675 16.875 3V6C16.875 6.29837 16.9931 6.58492 17.2041 6.7959C17.4151 7.00688 17.7016 7.125 18 7.125H21C21.4832 7.125 21.875 7.51675 21.875 8C21.875 8.48325 21.4832 8.875 21 8.875H18C17.2375 8.875 16.506 8.57237 15.9668 8.0332C15.4276 7.49404 15.125 6.7625 15.125 6Z',
],
},
'wifi-off': {
viewBox: '0 0 24 24',
paths: [
'M1.38128 1.38128C1.72299 1.03957 2.27785 1.03957 2.61956 1.38128L22.6196 21.3813C22.9613 21.723 22.9613 22.2779 22.6196 22.6196C22.2779 22.9613 21.723 22.9613 21.3813 22.6196L15.9448 17.183C15.6121 17.3797 15.1772 17.3385 14.8881 17.0551C14.117 16.2993 13.0802 15.8764 12.0004 15.8764C10.9207 15.8764 9.88381 16.2993 9.11273 17.0551C8.76768 17.3933 8.21371 17.3883 7.87542 17.0434C7.53715 16.6983 7.54301 16.1434 7.88812 15.8051C8.98629 14.7288 10.4628 14.1264 12.0004 14.1264C12.3272 14.1264 12.651 14.1543 12.9692 14.2075L9.88714 11.1254C8.27735 11.5087 6.79851 12.3224 5.61273 13.4848C5.26764 13.8231 4.71371 13.8182 4.37542 13.4731C4.03714 13.128 4.04303 12.5741 4.38812 12.2358C5.55033 11.0965 6.94797 10.2366 8.47503 9.71331L5.98577 7.22405C4.74971 7.80461 3.60363 8.56219 2.58343 9.47308C2.22296 9.79494 1.66993 9.76324 1.34808 9.40277C1.02629 9.04229 1.05697 8.48925 1.41741 8.16741C2.40785 7.28308 3.5045 6.52832 4.68011 5.91839L1.38128 2.61956C1.03957 2.27785 1.03957 1.72299 1.38128 1.38128ZM12.0102 19.1254C12.4934 19.1254 12.8852 19.5172 12.8852 20.0004C12.8852 20.4837 12.4934 20.8754 12.0102 20.8754H12.0004C11.5172 20.8754 11.1254 20.4837 11.1254 20.0004C11.1254 19.5172 11.5172 19.1254 12.0004 19.1254H12.0102ZM16.2426 10.9028C16.4843 10.4845 17.0195 10.341 17.4379 10.5825C18.2294 11.0398 18.9602 11.5956 19.6127 12.2358C19.9576 12.5741 19.9636 13.1281 19.6254 13.4731C19.287 13.818 18.7331 13.8231 18.3881 13.4848C17.8406 12.9476 17.2271 12.4818 16.5629 12.0981C16.1445 11.8563 16.001 11.3212 16.2426 10.9028ZM10.6352 4.1889C12.7929 4.00208 14.9661 4.25848 17.0209 4.94281C19.0757 5.62716 20.9686 6.72516 22.5834 8.16839C22.9436 8.49044 22.9748 9.04348 22.6528 9.40374C22.3308 9.76385 21.7777 9.7949 21.4174 9.47308C19.9807 8.18898 18.2964 7.21188 16.4682 6.60296C14.6399 5.99405 12.7055 5.7658 10.7856 5.93206C10.3043 5.97356 9.88011 5.61739 9.83831 5.13616C9.79662 4.65472 10.1537 4.23059 10.6352 4.1889Z',
],
},
};
export default {
name: 'NovncIcon',
props: {
type: {
type: String,
required: true,
validator: (value) => Object.keys(ICONS).includes(value),
},
},
computed: {
icon() {
return ICONS[this.type];
},
},
};
</script>
<template>
<svg
class="novnc-icon"
:viewBox="icon.viewBox"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
v-for="(d, i) in icon.paths"
:key="i"
:d="d"
/>
</svg>
</template>
<style lang="scss" scoped>
.novnc-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,232 @@
<script>
import RcStatusIndicator from '@components/Pill/RcStatusIndicator';
import RcTag from '@components/Pill/RcTag';
import { MODIFIER_KEYS, baseKey, keyLabel, recordKey } from './shortcuts';
export default {
name: 'NovncKeyRecorder',
components: { RcStatusIndicator, RcTag },
props: {
// The recorded keys, as stored for a shortcut.
value: {
type: Array,
default: () => [],
},
ariaLabelledby: {
type: String,
default: undefined,
},
},
emits: ['update:value'],
data() {
return { recording: false, pending: [] };
},
computed: {
state() {
if (this.recording) {
return 'recording';
}
return this.value.length ? 'captured' : 'empty';
},
labels() {
return this.value.map((K) => keyLabel(baseKey(K, this.value).key));
},
valueId() {
return `vnc-key-recorder-value-${ this.$.uid }`;
},
// Read out with the field's label, so screen readers hear what is recorded.
announcement() {
if (this.state === 'recording') {
return this.t('harvester.virtualMachine.detail.console.customKeys.recorder.recordingStatus');
}
return this.labels.join('+');
},
},
methods: {
start() {
this.pending = [];
this.recording = true;
},
stop() {
this.recording = false;
this.pending = [];
},
// Modifiers wait for the key they go with; the first other key ends the combination.
onKeydown(event) {
if (!this.recording) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.start();
}
return;
}
// Esc or Tab on their own leave the field, so it never traps the keyboard; with a modifier
// they are part of the combination (Ctrl+Shift+Esc).
if (!this.pending.length && (event.key === 'Escape' || event.key === 'Tab')) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}
this.stop();
return;
}
event.preventDefault();
event.stopPropagation();
if (event.repeat) {
return;
}
if (MODIFIER_KEYS.includes(event.key)) {
if (!this.pending.some((K) => K.key === event.key)) {
this.pending.push(recordKey(event));
}
return;
}
this.$emit('update:value', [...this.pending, recordKey(event, this.pending)]);
this.stop();
},
// Modifiers released before any other key are the combination themselves, such as the Windows key on
// its own. It waits for the last one, so Ctrl+Shift can be let go one key at a time.
onKeyup(event) {
if (!this.recording || !this.pending.length || !MODIFIER_KEYS.includes(event.key)) {
return;
}
if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) {
return;
}
event.preventDefault();
event.stopPropagation();
this.$emit('update:value', [...this.pending]);
this.stop();
},
},
};
</script>
<template>
<div
class="key-recorder"
:class="`key-recorder--${ state }`"
role="button"
tabindex="0"
:aria-labelledby="ariaLabelledby ? `${ ariaLabelledby } ${ valueId }` : valueId"
:aria-pressed="recording"
data-testid="vnc-key-recorder"
@click="start"
@keydown="onKeydown"
@keyup="onKeyup"
@blur="stop"
>
<span
:id="valueId"
class="sr-only"
aria-live="polite"
>{{ announcement }}</span>
<template v-if="state === 'recording'">
<span class="key-recorder__hint">{{ t('harvester.virtualMachine.detail.console.customKeys.recorder.recording') }}</span>
<span class="key-recorder__status">
<RcStatusIndicator
shape="disc"
status="error"
/>
{{ t('harvester.virtualMachine.detail.console.customKeys.recorder.recordingStatus') }}
</span>
</template>
<template v-else-if="state === 'captured'">
<span class="key-recorder__keys">
<RcTag
v-for="(label, i) in labels"
:key="i"
type="active"
>
{{ label }}
</RcTag>
</span>
<span class="key-recorder__again">{{ t('harvester.virtualMachine.detail.console.customKeys.recorder.again') }}</span>
</template>
<span
v-else
class="key-recorder__hint"
>{{ t('harvester.virtualMachine.detail.console.customKeys.recorder.empty') }}</span>
</div>
</template>
<style lang="scss" scoped>
.key-recorder {
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 0 14px;
border: 1px solid var(--input-border);
border-radius: 4px;
background: var(--input-bg);
cursor: pointer;
&--empty {
border-style: dashed;
}
&--recording {
border: 2px solid var(--primary-keyboard-focus);
padding: 0 13px;
}
&:focus-visible {
@include focus-outline;
}
&__hint {
color: var(--muted);
font-size: 14px;
line-height: 21px;
}
&__keys {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__status,
&__again {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-size: 12px;
line-height: 18px;
}
&__again {
color: var(--muted);
}
}
</style>
@@ -0,0 +1,404 @@
<script>
import { h, inject } from 'vue';
import {
RcDropdown, RcDropdownGroup, RcDropdownItem, RcDropdownItemCheckbox, RcDropdownSeparator, RcDropdownTrigger
} from '@components/RcDropdown';
import { RcIcon } from '@components/RcIcon';
import { shortcutLabels } from './shortcuts';
export const MODIFIERS = [
{ key: 'ControlLeft', label: 'Ctrl' },
{ key: 'AltLeft', label: 'Alt' },
];
export const FUNCTION_KEYS = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'];
// Ctrl alone offers the browser shortcuts the guest cannot get from the keyboard (the browser keeps
// Ctrl+N/T/W for itself), as the old cascading menu did.
export const CTRL_KEYS = [
{ key: 'KeyN', label: 'N' },
{ key: 'KeyT', label: 'T' },
{ key: 'KeyW', label: 'W' },
{ key: 'KeyY', label: 'Y' },
];
// RcDropdown collects its items when the menu opens, and the arrow keys walk that list. Holding a modifier
// swaps the keys below it while the menu is open, so, as RcDropdown does for its submenus, the items are
// collected again on each key press and the arrows reach the keys shown now.
export const ShortcutMenuBody = {
name: 'ShortcutMenuBody',
setup(_props, { slots }) {
const dropdown = inject('dropdownContext', null);
const collectItems = (event) => {
const menu = event.currentTarget.closest('[dropdown-menu-collection]');
if (dropdown?.dropdownItems && menu) {
dropdown.dropdownItems.value = [...menu.querySelectorAll('[dropdown-menu-item]')];
}
};
return () => h('div', { class: 'shortcut-menu', onKeydownCapture: collectItems }, slots.default?.());
},
};
export default {
name: 'NovncShortcutKeysMenu',
components: {
RcDropdown, RcDropdownGroup, RcDropdownItem, RcDropdownItemCheckbox, RcDropdownSeparator, RcDropdownTrigger, RcIcon, ShortcutMenuBody
},
props: {
disabled: {
type: Boolean,
default: false,
},
// Saved custom shortcuts, as { name, keys }.
shortcuts: {
type: Array,
default: () => [],
},
// Keys only go out over a connected console; the menu still opens to manage shortcuts.
canSend: {
type: Boolean,
default: true,
},
// Why keys cannot be sent right now, shown at the top of the menu.
blockedReason: {
type: String,
default: '',
},
// Custom shortcuts are stored in the user preference, which standalone setups may not have.
canManage: {
type: Boolean,
default: true,
},
},
emits: ['send-keys', 'send-shortcut', 'manage', 'ctrl-alt-del'],
data() {
return { held: [] };
},
computed: {
modifiers() {
return MODIFIERS;
},
functionKeys() {
return FUNCTION_KEYS;
},
holdsCtrl() {
return this.held.includes('ControlLeft');
},
holdsAlt() {
return this.held.includes('AltLeft');
},
// What the keys below the toggles offer follows what is held, so the menu sends the same
// combinations as the old one: Ctrl+N/T/W/Y, Ctrl+Alt+Del, Alt+Print Screen and Alt+F1-F12, plus
// Ctrl+Alt+F1-F12 to switch Linux consoles.
keyOptions() {
if (this.holdsCtrl && this.holdsAlt) {
return [{ key: 'Delete', label: 'Del' }];
}
if (this.holdsCtrl) {
return CTRL_KEYS;
}
return [{ key: 'PrintScreen', label: this.t('harvester.virtualMachine.detail.console.shortcutMenu.printScreen') }];
},
showFunctionKeys() {
return !this.holdsCtrl || this.holdsAlt;
},
heldCombo() {
return MODIFIERS.filter((M) => this.held.includes(M.key)).map((M) => M.label).join('+');
},
items() {
return this.shortcuts.map((shortcut, index) => {
const label = shortcutLabels(shortcut).join('+');
return {
index, name: shortcut.name || label, label, keys: shortcut.keys
};
});
},
},
methods: {
// Held modifiers belong to this visit of the menu; closing it without a key lets them go.
onOpen(open) {
if (!open) {
this.held = [];
}
},
toggleModifier(key, on) {
this.held = on ? [...this.held, key] : this.held.filter((K) => K !== key);
},
// Held modifiers apply to the next key only, then the toggles reset.
sendKey(key) {
const modifiers = MODIFIERS.map((M) => M.key).filter((K) => this.held.includes(K));
// Ctrl+Alt+Del restarts most guests, so it goes through the same confirmation as its button.
if (key === 'Delete' && this.holdsCtrl && this.holdsAlt) {
this.$emit('ctrl-alt-del');
} else {
this.$emit('send-keys', [...modifiers, key]);
}
this.held = [];
},
},
};
</script>
<template>
<rc-dropdown
:aria-label="t('harvester.virtualMachine.detail.console.shortcutKeys')"
placement="bottom-start"
@update:open="onOpen"
>
<rc-dropdown-trigger
variant="tertiary"
data-testid="vnc-shortcut-keys"
:disabled="disabled"
>
<template #before>
<rc-icon
type="keyboard"
size="inherit"
/>
</template>
{{ t('harvester.virtualMachine.detail.console.shortcutKeys') }}
<template #after>
<rc-icon
type="chevron-down"
size="inherit"
/>
</template>
</rc-dropdown-trigger>
<template #dropdownCollection>
<shortcut-menu-body>
<p
v-if="!canSend && blockedReason"
class="shortcut-menu__blocked"
data-testid="vnc-shortcut-blocked"
>
{{ blockedReason }}
</p>
<template v-if="canManage && items.length">
<rc-dropdown-group :label="t('harvester.virtualMachine.detail.console.shortcutMenu.yourShortcuts')">
<rc-dropdown-item
v-for="item in items"
:key="item.index"
:disabled="!canSend"
class="shortcut-menu__saved"
data-testid="vnc-saved-shortcut"
:title="item.name"
@click="$emit('send-shortcut', item.keys)"
>
<span class="shortcut-menu__saved-name">{{ item.name }}</span>
<template #after>
<span class="shortcut-menu__chip">{{ item.label }}</span>
</template>
</rc-dropdown-item>
</rc-dropdown-group>
<rc-dropdown-separator />
</template>
<rc-dropdown-group :label="t('harvester.virtualMachine.detail.console.shortcutMenu.holdModifiers')">
<div class="shortcut-menu__row">
<rc-dropdown-item-checkbox
v-for="modifier in modifiers"
:key="modifier.key"
:disabled="!canSend"
indicator="checkmark"
class="shortcut-menu__chip shortcut-menu__toggle"
:class="{ 'is-on': held.includes(modifier.key) }"
:data-testid="`vnc-hold-${ modifier.label.toLowerCase() }`"
:model-value="held.includes(modifier.key)"
@update:model-value="toggleModifier(modifier.key, $event)"
>
{{ modifier.label }}
</rc-dropdown-item-checkbox>
</div>
<p
v-if="heldCombo"
class="shortcut-menu__help"
>
{{ t('harvester.virtualMachine.detail.console.shortcutMenu.holdHint', { combo: heldCombo }) }}
</p>
</rc-dropdown-group>
<rc-dropdown-separator />
<rc-dropdown-group :label="t('harvester.virtualMachine.detail.console.shortcutMenu.keys')">
<div class="shortcut-menu__row">
<rc-dropdown-item
v-for="option in keyOptions"
:key="option.key"
:disabled="!canSend"
class="shortcut-menu__chip"
:data-testid="`vnc-key-${ option.key }`"
@click="sendKey(option.key)"
>
{{ option.label }}
</rc-dropdown-item>
</div>
</rc-dropdown-group>
<rc-dropdown-group
v-if="showFunctionKeys"
:label="t('harvester.virtualMachine.detail.console.shortcutMenu.functionKeys')"
>
<div class="shortcut-menu__grid">
<rc-dropdown-item
v-for="key in functionKeys"
:key="key"
:disabled="!canSend"
class="shortcut-menu__chip"
:data-testid="`vnc-key-${ key }`"
@click="sendKey(key)"
>
{{ key }}
</rc-dropdown-item>
</div>
</rc-dropdown-group>
<template v-if="canManage">
<rc-dropdown-separator />
<rc-dropdown-item
class="shortcut-menu__manage"
data-testid="vnc-manage-shortcuts"
@click="$emit('manage')"
>
<template #before>
<rc-icon
type="edit"
size="inherit"
/>
</template>
{{ t('harvester.virtualMachine.detail.console.shortcutMenu.manage') }}
</rc-dropdown-item>
</template>
</shortcut-menu-body>
</template>
</rc-dropdown>
</template>
<style lang="scss" scoped>
// The menu items are styled as the key chips of the design; `.shortcut-menu` raises the
// specificity above RcDropdownItem's own item styles.
.shortcut-menu {
width: 320px;
&__row,
&__grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 16px 8px;
}
&__grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
// The chips are the design's small secondary buttons, so they take RcButton's secondary tokens
// (with its fallbacks) and follow the brand like every other button.
.shortcut-menu__chip {
justify-content: center;
min-height: 24px;
margin: 0;
padding: 0 8px;
border: 1px solid var(--secondary-border, var(--primary));
border-radius: 4px;
background: var(--secondary, transparent);
color: var(--on-secondary, var(--primary));
font-size: 12px;
line-height: 16px;
white-space: nowrap;
&:hover {
background: var(--secondary-hover, var(--accent-btn));
}
}
.shortcut-menu__chip[disabled],
.shortcut-menu__saved[disabled] .shortcut-menu__chip {
&,
&:hover {
border-color: var(--disabled-bg, var(--border));
background: transparent;
color: var(--disabled-text);
cursor: not-allowed;
}
}
&__blocked {
margin: 0;
padding: 4px 16px 8px;
color: var(--muted);
font-size: 12px;
line-height: 18px;
}
.shortcut-menu__toggle.is-on {
&,
&:hover {
border-color: var(--active, var(--primary));
background: var(--active, var(--primary));
color: var(--on-active, var(--primary-text));
}
}
.shortcut-menu__saved {
margin: 0 9px;
padding: 4px 8px;
font-size: 14px;
// Long names stay on two lines; the full one is in the tooltip.
.shortcut-menu__saved-name {
display: -webkit-box;
min-width: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.shortcut-menu__chip {
display: inline-flex;
align-items: center;
}
}
&__help {
margin: 0;
padding: 0 16px 8px;
color: var(--muted);
font-size: 12px;
line-height: 18px;
}
// The design's tertiary button.
.shortcut-menu__manage {
color: var(--on-tertiary, var(--primary));
}
}
</style>
@@ -0,0 +1,104 @@
<script>
import NovncIcon from './NovncIcon';
// The card the console shows on its black screen when there is nothing to type into: a failed
// connection, or a virtual machine that is not running.
export default {
name: 'NovncStatusCard',
components: { NovncIcon },
props: {
icon: {
type: String,
default: 'wifi-off',
},
title: {
type: String,
required: true,
},
detail: {
type: String,
default: '',
},
},
};
</script>
<template>
<div class="status-card">
<span
class="status-card__badge"
aria-hidden="true"
>
<NovncIcon :type="icon" />
</span>
<h3 class="status-card__title">
{{ title }}
</h3>
<p
v-if="detail"
class="status-card__detail"
>
{{ detail }}
</p>
<div
v-if="$slots.default"
class="status-card__actions"
>
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
// Always on the black screen, whatever the theme, so the text is light.
.status-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 480px;
padding: 32px;
color: #fff;
text-align: center;
&__badge {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
.novnc-icon {
width: 24px;
height: 24px;
}
}
&__title {
margin: 0;
color: #fff;
font-size: 16px;
font-weight: 700;
}
&__detail {
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
line-height: 16px;
}
&__actions {
display: flex;
gap: 12px;
padding-top: 8px;
}
}
</style>
@@ -0,0 +1,302 @@
<script>
import ButtonGroup from '@shell/components/ButtonGroup';
import { RcButton } from '@components/RcButton';
import { RcIcon } from '@components/RcIcon';
import { MAXIMUM_ZOOM, MINIMUM_ZOOM, ZOOM_STEP } from './ZoomableRFB';
export const VIEW_MODE = {
SCALE: 'scale',
NONE: 'none',
};
const MAXIMUM_LEVEL = 9;
export default {
name: 'NovncViewOptions',
components: {
ButtonGroup, RcButton, RcIcon
},
props: {
settings: {
type: Object,
required: true,
},
},
emits: ['update', 'reset'],
computed: {
modeOptions() {
return [
{ value: VIEW_MODE.SCALE, label: this.t('harvester.virtualMachine.detail.console.viewOptions.automatic') },
{ value: VIEW_MODE.NONE, label: this.t('harvester.virtualMachine.detail.console.viewOptions.manual') },
];
},
isManual() {
return this.settings.viewMode === VIEW_MODE.NONE;
},
zoom() {
return {
min: MINIMUM_ZOOM, max: MAXIMUM_ZOOM, step: ZOOM_STEP
};
},
maximumLevel() {
return MAXIMUM_LEVEL;
},
},
methods: {
update(key, value) {
this.$emit('update', { ...this.settings, [key]: value });
},
stepZoom(direction) {
const next = this.settings.zoomPercent + (direction * ZOOM_STEP);
this.update('zoomPercent', Math.min(Math.max(next, MINIMUM_ZOOM), MAXIMUM_ZOOM));
},
// Feeds the filled part of the track, which native range inputs do not draw on their own.
fill(value, min, max) {
return { '--fill': `${ ((value - min) / (max - min)) * 100 }%` };
},
},
};
</script>
<template>
<div
class="view-options"
data-testid="vnc-view-options"
>
<section class="view-options__section">
<span class="view-options__label">{{ t('harvester.virtualMachine.detail.console.viewOptions.scaling') }}</span>
<ButtonGroup
class="view-options__mode"
size="medium"
:value="settings.viewMode"
:options="modeOptions"
@update:value="update('viewMode', $event)"
/>
<p class="view-options__help">
{{ t(`harvester.virtualMachine.detail.console.viewOptions.${ isManual ? 'manualHelp' : 'automaticHelp' }`) }}
</p>
</section>
<section class="view-options__section">
<label
for="vnc-zoom"
class="view-options__row"
>
<span class="view-options__label">{{ t('harvester.virtualMachine.detail.console.viewOptions.zoom') }}</span>
<span class="view-options__value">{{ settings.zoomPercent }}%</span>
</label>
<div class="view-options__zoom">
<RcButton
variant="secondary"
size="small"
:aria-label="t('harvester.virtualMachine.detail.console.viewOptions.zoomOut')"
:disabled="!isManual || settings.zoomPercent <= zoom.min"
@click="stepZoom(-1)"
>
<RcIcon
type="minus"
size="inherit"
/>
</RcButton>
<input
id="vnc-zoom"
class="view-options__slider"
type="range"
:min="zoom.min"
:max="zoom.max"
:step="zoom.step"
:value="settings.zoomPercent"
:disabled="!isManual"
:aria-valuetext="`${ settings.zoomPercent }%`"
:style="fill(settings.zoomPercent, zoom.min, zoom.max)"
@input="update('zoomPercent', Number($event.target.value))"
>
<RcButton
variant="secondary"
size="small"
:aria-label="t('harvester.virtualMachine.detail.console.viewOptions.zoomIn')"
:disabled="!isManual || settings.zoomPercent >= zoom.max"
@click="stepZoom(1)"
>
<RcIcon
type="plus"
size="inherit"
/>
</RcButton>
</div>
</section>
<hr class="view-options__divider">
<section
v-for="level in ['qualityLevel', 'compressionLevel']"
:key="level"
class="view-options__section"
>
<label
:for="`vnc-${ level }`"
class="view-options__row"
>
<span class="view-options__label">{{ t(`harvester.virtualMachine.detail.console.viewOptions.${ level }.label`) }}</span>
<span class="view-options__value">{{ t('harvester.virtualMachine.detail.console.viewOptions.levelOf', { value: settings[level], max: maximumLevel }) }}</span>
</label>
<input
:id="`vnc-${ level }`"
class="view-options__slider"
type="range"
min="0"
:max="maximumLevel"
step="1"
:value="settings[level]"
:style="fill(settings[level], 0, maximumLevel)"
@input="update(level, Number($event.target.value))"
>
<p class="view-options__help">
{{ t(`harvester.virtualMachine.detail.console.viewOptions.${ level }.help`) }}
</p>
</section>
<RcButton
variant="link"
class="view-options__reset"
data-testid="vnc-view-reset"
@click="$emit('reset')"
>
{{ t('harvester.virtualMachine.detail.console.viewOptions.reset') }}
</RcButton>
</div>
</template>
<style lang="scss" scoped>
.view-options {
display: flex;
flex-direction: column;
gap: 18px;
width: 336px;
padding: 16px;
box-sizing: border-box;
&__section {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 0;
}
&__label {
color: var(--body-text);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
&__value,
&__help {
color: var(--muted);
font-size: 12px;
line-height: 18px;
font-variant-numeric: tabular-nums;
}
&__help {
margin: 0;
}
&__mode {
display: flex;
button {
flex: 1;
}
}
&__zoom {
display: flex;
align-items: center;
gap: 8px;
// Between the zoom buttons the slider takes the room that is left. In the other sections, which
// are columns, the same rule would set its height and squash it to the track.
.view-options__slider {
flex: 1;
}
}
&__divider {
margin: 0;
border: 0;
border-top: 1px solid var(--border);
}
// Links sit flush with the panel text instead of carrying button padding.
&__reset {
align-self: flex-start;
--rc-button-padding: 0;
}
&__slider {
width: 100%;
height: 20px;
margin: 0;
background: transparent;
cursor: pointer;
appearance: none;
&::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
background: linear-gradient(to right, var(--primary) var(--fill), var(--muted) var(--fill));
}
&::-moz-range-track {
height: 4px;
border-radius: 2px;
background: linear-gradient(to right, var(--primary) var(--fill), var(--muted) var(--fill));
}
&::-webkit-slider-thumb {
width: 16px;
height: 16px;
margin-top: -6px;
border: 2px solid var(--nav-bg);
border-radius: 50%;
background: var(--primary);
appearance: none;
}
&::-moz-range-thumb {
width: 12px;
height: 12px;
border: 2px solid var(--nav-bg);
border-radius: 50%;
background: var(--primary);
}
&:focus-visible {
@include focus-outline;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
</style>
+44 -2
View File
@@ -1,5 +1,12 @@
import RFB from '@novnc/novnc/core/rfb';
export const MINIMUM_ZOOM = 50;
export const MAXIMUM_ZOOM = 200;
export const ZOOM_STEP = 10;
// The focus ring and the gap before it, drawn outside the canvas.
const FOCUS_RING_WIDTH = 4;
export function normalizeZoom(value) {
const percent = Number(value);
@@ -7,7 +14,7 @@ export function normalizeZoom(value) {
return 100;
}
return Math.min(200, Math.max(50, Math.round(percent / 10) * 10));
return Math.min(MAXIMUM_ZOOM, Math.max(MINIMUM_ZOOM, Math.round(percent / ZOOM_STEP) * ZOOM_STEP));
}
export default class ZoomableRFB extends RFB {
@@ -20,12 +27,47 @@ export default class ZoomableRFB extends RFB {
this._updateScale();
}
// The screen takes all the room it has, without a focus ring around it.
get edgeToEdge() {
return !!this._edgeToEdge;
}
set edgeToEdge(value) {
this._edgeToEdge = value;
this._updateScale();
}
_updateScale() {
if (this.scaleViewport) {
super._updateScale();
// Fit inside the focus ring the console draws around the canvas, instead of under it.
const size = this._screenSize();
const inset = this.edgeToEdge ? 0 : 2 * FOCUS_RING_WIDTH;
this._display.autoscale(size.w - inset, size.h - inset);
this._fixScrollbars();
} else {
this._display.scale = this.zoomPercent / 100;
this._fixScrollbars();
}
}
// noVNC only stops a click on the screen, which Firefox delivers there (others to the page, past
// noVNC's mouse capture). An open menu would never see the click outside it and would stay open.
_handleMouse(ev) {
if (ev.type !== 'click') {
super._handleMouse(ev);
}
}
// noVNC hides the mouse over the screen until the guest sends a pointer image, and a text console
// or a guest that draws its own pointer never does. Show the system pointer unless one arrives.
_refreshCursor() {
super._refreshCursor();
const hasGuestPointer = this._cursorImage.rgbaPixels.some((value, index) => index % 4 === 3 && value > 0);
if (!hasGuestPointer) {
this._canvas.style.cursor = 'default';
}
}
}
@@ -0,0 +1,19 @@
import { shallowMount } from '@vue/test-utils';
import { RcButton } from '@components/RcButton';
import NovncConnectionStatus from '../NovncConnectionStatus.vue';
jest.mock('../ZoomableRFB', () => ({}));
describe('component: NovncConnectionStatus', () => {
// A plain button takes the global 40px button height, taller than the 32px pill around it.
it('should offer Reconnect as an RcButton when the console is disconnected', () => {
const wrapper = shallowMount(NovncConnectionStatus, { props: { status: 'disconnected', label: 'Disconnected' } });
const reconnect = wrapper.findComponent(RcButton);
expect(reconnect.attributes('data-testid')).toBe('vnc-connection-reconnect');
reconnect.vm.$emit('click');
expect(wrapper.emitted('reconnect')).toHaveLength(1);
});
});
@@ -97,6 +97,36 @@ describe('component: NovncConsole', () => {
lastRFB().emit('disconnect');
expect(wrapper.vm.failure).toBe(CONSOLE_FAILURE.UNAVAILABLE);
expect(wrapper.vm.canTakeOver).toBe(false);
});
it('should retry, not report in use, when a VM that just started refuses the first connection', async() => {
const wrapper = mountConsole({ startedAt: Date.now() });
await jest.runOnlyPendingTimersAsync();
lastRFB().emit('disconnect');
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
expect(wrapper.vm.failure).toBe('');
});
it('should keep counting retries when Reconnect now skips the wait', async() => {
const wrapper = mountConsole();
await jest.runOnlyPendingTimersAsync();
lastRFB().emit('connect');
lastRFB().emit('disconnect');
expect(wrapper.vm.retryTimes).toBe(1);
wrapper.vm.retryNow();
expect(wrapper.vm.retryTimes).toBe(1);
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
// Still held by the other session: back to waiting, not the in-use failure.
lastRFB().emit('disconnect');
expect(wrapper.vm.status).toBe(CONSOLE_STATUS.RECONNECTING);
expect(wrapper.vm.retryTimes).toBe(2);
});
it('should only take over after the modal is confirmed', async() => {
@@ -152,6 +182,20 @@ describe('component: NovncConsole', () => {
expect(view.classes()).not.toContain('is-focused');
});
it('should drop the focus ring and its room when the screen goes edge to edge', async() => {
const wrapper = mountConsole();
const view = wrapper.find('.novnc-screen');
await jest.runOnlyPendingTimersAsync();
expect(view.classes()).not.toContain('is-edge-to-edge');
expect(lastRFB().edgeToEdge).toBe(false);
await wrapper.setProps({ settings: { edgeToEdge: true } });
expect(view.classes()).toContain('is-edge-to-edge');
expect(lastRFB().edgeToEdge).toBe(true);
});
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 });
@@ -0,0 +1,161 @@
import { reactive } from 'vue';
import { flushPromises, mount, shallowMount } from '@vue/test-utils';
import { RcButton } from '@components/RcButton';
import NovncConsoleCustomKeys from '../NovncConsoleCustomKeys.vue';
import { SHORTCUTS_PREFERENCE, SHORTCUT_NAMES_PREFERENCE } from '../shortcuts';
const key = (value, code) => ({ key: value, code });
const CTRL_F2 = [key('Control', 'ControlLeft'), key('F2', 'F2')];
const CTRL_SPACE = [key('Control', 'ControlLeft'), key(' ', 'Space')];
const ALT_T = [key('Alt', 'AltLeft'), key('t', 'KeyT')];
// `rendered` mounts the buttons and the recorder for real, in the document, so the focus can be checked.
const mountKeys = (saved = [], { rendered = false } = {}) => {
// Reactive like the store's, so the list follows what is saved.
const preference = reactive({
data: { [SHORTCUTS_PREFERENCE]: JSON.stringify(saved) },
save: jest.fn().mockResolvedValue(),
});
const wrapper = (rendered ? mount : shallowMount)(NovncConsoleCustomKeys, {
props: { show: true },
attachTo: rendered ? document.body : undefined,
global: {
mocks: { $store: { getters: { 'management/all': () => [preference] } } },
stubs: {
RcModal: { template: '<div><slot /></div>' }, Banner: { template: '<div><slot /></div>' }, LabeledInput: true
},
},
});
return { wrapper, preference };
};
const listed = (wrapper) => wrapper.findAll('[data-testid="vnc-custom-shortcut"]');
const removeShortcut = async(wrapper, index) => {
listed(wrapper)[index].findComponent(RcButton).vm.$emit('click');
await flushPromises();
};
const savedKeys = (preference) => JSON.parse(preference.data[SHORTCUTS_PREFERENCE]);
describe('component: NovncConsoleCustomKeys', () => {
it('should offer to remove every saved shortcut', () => {
const { wrapper } = mountKeys([CTRL_F2, ALT_T]);
expect(listed(wrapper).map((item) => item.findComponent(RcButton).exists())).toStrictEqual([true, true]);
});
it('should save the list without the shortcut that was removed', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2, ALT_T]);
await removeShortcut(wrapper, 0);
expect(preference.save).toHaveBeenCalledTimes(1);
expect(savedKeys(preference)).toStrictEqual([ALT_T]);
expect(listed(wrapper)).toHaveLength(1);
});
it('should remove a shortcut with the Space key in it', async() => {
const { wrapper, preference } = mountKeys([CTRL_SPACE, ALT_T]);
await removeShortcut(wrapper, 0);
expect(savedKeys(preference)).toStrictEqual([ALT_T]);
});
it('should keep the shortcut when removing it cannot be saved, and say so in the dialog', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2, ALT_T]);
preference.save.mockRejectedValue(new Error('forbidden'));
await removeShortcut(wrapper, 0);
expect(savedKeys(preference)).toStrictEqual([CTRL_F2, ALT_T]);
expect(listed(wrapper)).toHaveLength(2);
expect(wrapper.find('[data-testid="vnc-custom-keys-error"]').text()).toBe('%harvester.virtualMachine.detail.console.customKeys.saveError%: forbidden');
await wrapper.setProps({ show: false });
expect(wrapper.find('[data-testid="vnc-custom-keys-error"]').exists()).toBe(false);
});
it('should not start a second save while one is running', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2, ALT_T]);
wrapper.vm.remove(0);
wrapper.vm.remove(1);
await flushPromises();
expect(preference.save).toHaveBeenCalledTimes(1);
});
it('should keep the focus on a delete button after a removal, and hand it to the recorder once the list is empty', async() => {
const { wrapper } = mountKeys([CTRL_F2, ALT_T], { rendered: true });
const deleteButtons = () => wrapper.findAll('[data-testid="vnc-custom-shortcut"] button');
deleteButtons()[1].element.focus();
await deleteButtons()[1].trigger('click');
await flushPromises();
expect(document.activeElement).toBe(deleteButtons()[0].element);
await deleteButtons()[0].trigger('click');
await flushPromises();
expect(document.activeElement).toBe(wrapper.find('[data-testid="vnc-key-recorder"]').element);
wrapper.unmount();
});
it('should give the recorder the focus after a shortcut is added', async() => {
const { wrapper } = mountKeys([CTRL_F2], { rendered: true });
wrapper.vm.keys = ALT_T;
await wrapper.vm.$nextTick();
wrapper.find('[data-testid="vnc-add-shortcut"]').element.focus();
await wrapper.find('[data-testid="vnc-add-shortcut"]').trigger('click');
await flushPromises();
expect(document.activeElement).toBe(wrapper.find('[data-testid="vnc-key-recorder"]').element);
wrapper.unmount();
});
it('should empty the form as soon as a shortcut is added, so it does not say it is already saved while it saves', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2]);
preference.save.mockReturnValue(new Promise(() => {}));
wrapper.vm.keys = ALT_T;
wrapper.vm.add();
await wrapper.vm.$nextTick();
expect(listed(wrapper)).toHaveLength(2);
expect(wrapper.vm.keys).toStrictEqual([]);
expect(wrapper.find('.custom-keys__error').exists()).toBe(false);
});
it('should put the shortcut back in the form when it cannot be saved', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2]);
preference.save.mockRejectedValue(new Error('forbidden'));
wrapper.vm.keys = ALT_T;
wrapper.vm.name = 'Task manager';
await wrapper.vm.add();
expect(listed(wrapper)).toHaveLength(1);
expect(wrapper.vm.keys).toStrictEqual(ALT_T);
expect(wrapper.vm.name).toBe('Task manager');
});
it('should save a new shortcut straight away, with its name', async() => {
const { wrapper, preference } = mountKeys([CTRL_F2]);
wrapper.vm.keys = ALT_T;
wrapper.vm.name = ' Task manager ';
await wrapper.vm.add();
expect(savedKeys(preference)).toStrictEqual([CTRL_F2, ALT_T]);
expect(JSON.parse(preference.data[SHORTCUT_NAMES_PREFERENCE])).toStrictEqual({ 'AltLeft+KeyT': 'Task manager' });
expect(wrapper.vm.keys).toStrictEqual([]);
});
});
@@ -10,7 +10,12 @@ const consoleRef = {
focus: jest.fn(),
};
const mountWrapper = () => {
const CHECKBOX = { name: 'Checkbox' };
const mountWrapper = ({ preferences = [], storage = {} } = {}) => {
// The console settings are saved there, so one test's would load in the next.
window.localStorage.clear();
Object.entries(storage).forEach(([key, value]) => window.localStorage.setItem(key, JSON.stringify(value)));
consoleRef.ctrlAltDelete.mockClear();
consoleRef.focus.mockClear();
@@ -24,7 +29,7 @@ const mountWrapper = () => {
mocks: {
$store: {
getters: {
'management/all': () => [],
'management/all': () => preferences,
'harvester-common/getFeatureEnabled': () => true,
},
},
@@ -84,6 +89,106 @@ describe('component: NovncConsoleWrapper', () => {
expect(doSoftReboot).toHaveBeenCalledTimes(1);
});
it('should keep the shortcut menu open to use while keys cannot be sent, and say why', () => {
const wrapper = mountWrapper();
wrapper.vm.onStatusChanged({ status: 'reconnecting' });
expect(wrapper.vm.shortcutMenuProps.canSend).toBe(false);
expect(wrapper.vm.sendBlockedReason).toBe('%harvester.virtualMachine.detail.console.shortcutMenu.blockedDisconnected%');
// Without a user preference there is nothing to manage either, so only then is it disabled.
expect(wrapper.vm.shortcutMenuProps.disabled).toBe(true);
});
it('should keep the shortcut menu enabled while disconnected when there are shortcuts to manage', () => {
const wrapper = mountWrapper({ preferences: [{ data: {} }] });
wrapper.vm.onStatusChanged({ status: 'reconnecting' });
expect(wrapper.vm.shortcutMenuProps.canSend).toBe(false);
expect(wrapper.vm.shortcutMenuProps.disabled).toBe(false);
});
it('should disable the shortcut menu, managing shortcuts included, while view only is on', () => {
const wrapper = mountWrapper({ preferences: [{ data: {} }] });
expect(wrapper.vm.shortcutMenuProps.disabled).toBe(false);
wrapper.vm.setViewOnly(true);
expect(wrapper.vm.shortcutMenuProps.disabled).toBe(true);
});
it('should disable Ctrl+Alt+Del and Soft reboot while view only is on', async() => {
const wrapper = mountWrapper();
const doSoftReboot = jest.fn();
const button = (id) => wrapper.find(`[data-testid="${ id }"]`);
wrapper.vm.vmResource = { doSoftReboot, actions: { softreboot: 'url' } };
await wrapper.vm.$nextTick();
expect(button('vnc-soft-reboot').attributes('disabled')).toBe('false');
expect(button('vnc-ctrl-alt-del').attributes('disabled')).toBe('false');
wrapper.vm.setViewOnly(true);
await wrapper.vm.$nextTick();
expect(button('vnc-soft-reboot').attributes('disabled')).toBe('true');
expect(button('vnc-ctrl-alt-del').attributes('disabled')).toBe('true');
wrapper.vm.softReboot();
wrapper.vm.ctrlAltDelete();
expect(wrapper.vm.confirmAction).toBeNull();
expect(doSoftReboot).not.toHaveBeenCalled();
});
it('should say so under the console while view only is on', async() => {
const wrapper = mountWrapper();
const hint = () => wrapper.find('.console-area__hint').text();
expect(hint()).toBe('%harvester.virtualMachine.detail.console.hint%');
wrapper.vm.setViewOnly(true);
await wrapper.vm.$nextTick();
expect(hint()).toBe('%harvester.virtualMachine.detail.console.hintViewOnly%');
});
it('should not send keys while view only is on', () => {
const wrapper = mountWrapper();
wrapper.vm.setViewOnly(true);
expect(wrapper.vm.canSendKeys).toBe(false);
});
it('should switch view only with the checkbox, and not repeat it in the connection status', async() => {
const wrapper = mountWrapper();
const checkbox = () => wrapper.findComponent(CHECKBOX);
expect(checkbox().props('value')).toBe(false);
checkbox().vm.$emit('update:value', true);
await wrapper.vm.$nextTick();
expect(wrapper.vm.settings.viewOnly).toBe(true);
expect(checkbox().props('value')).toBe(true);
expect(wrapper.vm.statusLabel).toBe('%harvester.virtualMachine.detail.console.status.connected%');
});
it('should give the console the keyboard when view only is turned off, not when it is turned on', async() => {
const wrapper = mountWrapper();
wrapper.vm.setViewOnly(true);
await wrapper.vm.$nextTick();
expect(consoleRef.focus).not.toHaveBeenCalled();
wrapper.vm.setViewOnly(false);
await wrapper.vm.$nextTick();
expect(consoleRef.focus).toHaveBeenCalledTimes(1);
});
it('should not ask to send Ctrl+Alt+Del while keys cannot be sent', () => {
const wrapper = mountWrapper();
@@ -92,4 +197,128 @@ describe('component: NovncConsoleWrapper', () => {
expect(wrapper.vm.confirmAction).toBeNull();
});
<<<<<<< HEAD
=======
describe('view settings', () => {
it('should not take what the previous console saved on its first open as a choice', () => {
const wrapper = mountWrapper({
storage: {
'harvester-vnc-console-settings': {
viewMode: 'none', zoomPercent: 100, viewOnly: false, qualityLevel: 6, compressionLevel: 2
}
}
});
expect(wrapper.vm.settings.viewMode).toBe('scale');
});
it('should carry view only over from the previous console, which saved it on only when the user turned it on', () => {
const wrapper = mountWrapper({
storage: {
'harvester-vnc-console-settings': {
viewMode: 'none', zoomPercent: 150, viewOnly: true, qualityLevel: 9, compressionLevel: 0
}
}
});
expect(wrapper.vm.settings).toMatchObject({
viewOnly: true, viewMode: 'scale', zoomPercent: 100
});
});
it('should take view only from its own settings once it has saved them', () => {
const wrapper = mountWrapper({
storage: {
'harvester-vnc-console-settings': { viewOnly: true },
'harvester-vnc-console-view': { viewOnly: false },
}
});
expect(wrapper.vm.settings.viewOnly).toBe(false);
});
it('should save the settings only once the user changes one, and load them next time', async() => {
const wrapper = mountWrapper();
await wrapper.vm.$nextTick();
expect(window.localStorage).toHaveLength(0);
wrapper.vm.updateSettings({
...wrapper.vm.settings, viewMode: 'none', zoomPercent: 150
});
const reopened = mountWrapper({ storage: { 'harvester-vnc-console-view': JSON.parse(window.localStorage.getItem('harvester-vnc-console-view')) } });
expect(reopened.vm.settings).toMatchObject({ viewMode: 'none', zoomPercent: 150 });
});
it('should keep view only on when the View panel resets its settings', () => {
const wrapper = mountWrapper();
wrapper.vm.setViewOnly(true);
wrapper.vm.updateSettings({ ...wrapper.vm.settings, zoomPercent: 150 });
wrapper.vm.resetSettings();
expect(wrapper.vm.settings).toMatchObject({ viewOnly: true, zoomPercent: 100 });
expect(wrapper.vm.rfbSettings.viewOnly).toBe(true);
});
});
describe('in full screen', () => {
it('should fill the window whatever the saved view mode, and go back to it afterwards', () => {
const wrapper = mountWrapper();
wrapper.vm.updateSettings({ ...wrapper.vm.settings, viewMode: 'none' });
expect(wrapper.vm.rfbSettings).toMatchObject({ scaleViewport: false, edgeToEdge: false });
wrapper.vm.isFullscreen = true;
expect(wrapper.vm.rfbSettings).toMatchObject({ scaleViewport: true, edgeToEdge: true });
wrapper.vm.isFullscreen = false;
expect(wrapper.vm.rfbSettings).toMatchObject({ scaleViewport: false, edgeToEdge: false });
});
it('should leave the view only checkbox out of the bar, and keep view only as it was', async() => {
const wrapper = mountWrapper();
wrapper.vm.setViewOnly(true);
wrapper.vm.isFullscreen = true;
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(CHECKBOX).exists()).toBe(false);
expect(wrapper.vm.rfbSettings.viewOnly).toBe(true);
expect(wrapper.vm.shortcutMenuProps.disabled).toBe(true);
wrapper.vm.isFullscreen = false;
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(CHECKBOX).props('value')).toBe(true);
});
it('should leave Ctrl+Alt+Del out of the bar', async() => {
const wrapper = mountWrapper();
wrapper.vm.isFullscreen = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="vnc-fullscreen-bar"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="vnc-ctrl-alt-del"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="vnc-fullscreen-ctrl-alt-del"]').exists()).toBe(false);
});
it('should put the whole page in full screen, so that dialogs rendered outside the console show', async() => {
const wrapper = mountWrapper();
const requestFullscreen = jest.fn().mockResolvedValue();
document.documentElement.requestFullscreen = requestFullscreen;
await wrapper.vm.toggleFullscreen();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
delete document.documentElement.requestFullscreen;
});
});
>>>>>>> f3ca9e4 (feat: redesign the VNC console (#1233))
});
@@ -0,0 +1,91 @@
import { shallowMount } from '@vue/test-utils';
import NovncKeyRecorder from '../NovncKeyRecorder.vue';
const key = (k, code, extra = {}) => ({
key: k, code, keyCode: 0, location: 0, charCode: 0, repeat: false, preventDefault: jest.fn(), stopPropagation: jest.fn(), ...extra
});
describe('component: NovncKeyRecorder', () => {
it('should hold modifiers until another key ends the combination', () => {
const wrapper = shallowMount(NovncKeyRecorder);
wrapper.vm.start();
wrapper.vm.onKeydown(key('Control', 'ControlLeft'));
wrapper.vm.onKeydown(key('Shift', 'ShiftLeft'));
wrapper.vm.onKeydown(key('Control', 'ControlLeft', { repeat: true }));
wrapper.vm.onKeydown(key('Escape', 'Escape'));
expect(wrapper.emitted('update:value')[0][0].map((K) => K.code)).toStrictEqual(['ControlLeft', 'ShiftLeft', 'Escape']);
expect(wrapper.vm.recording).toBe(false);
});
it('should keep the browser from acting on keys while recording', () => {
const wrapper = shallowMount(NovncKeyRecorder);
const event = key('w', 'KeyW');
wrapper.vm.start();
wrapper.vm.onKeydown(event);
expect(event.preventDefault).toHaveBeenCalledWith();
});
it('should record a modifier pressed and released on its own, such as the Windows key', () => {
const wrapper = shallowMount(NovncKeyRecorder);
wrapper.vm.start();
wrapper.vm.onKeydown(key('Meta', 'MetaLeft'));
wrapper.vm.onKeyup(key('Meta', 'MetaLeft'));
expect(wrapper.emitted('update:value')[0][0].map((K) => K.code)).toStrictEqual(['MetaLeft']);
expect(wrapper.vm.recording).toBe(false);
});
it('should wait for the last modifier to be released before recording them', () => {
const wrapper = shallowMount(NovncKeyRecorder);
wrapper.vm.start();
wrapper.vm.onKeydown(key('Control', 'ControlLeft'));
wrapper.vm.onKeydown(key('Shift', 'ShiftLeft'));
wrapper.vm.onKeyup(key('Shift', 'ShiftLeft', { ctrlKey: true }));
expect(wrapper.emitted('update:value')).toBeUndefined();
wrapper.vm.onKeyup(key('Control', 'ControlLeft'));
expect(wrapper.emitted('update:value')[0][0].map((K) => K.code)).toStrictEqual(['ControlLeft', 'ShiftLeft']);
});
it('should drop a combination of modifiers only when it loses focus', () => {
const wrapper = shallowMount(NovncKeyRecorder);
wrapper.vm.start();
wrapper.vm.onKeydown(key('Alt', 'AltLeft'));
wrapper.vm.stop();
expect(wrapper.emitted('update:value')).toBeUndefined();
expect(wrapper.vm.state).toBe('empty');
});
it('should stop recording on Esc or Tab alone, without recording them', () => {
const wrapper = shallowMount(NovncKeyRecorder);
const tab = key('Tab', 'Tab');
wrapper.vm.start();
wrapper.vm.onKeydown(key('Escape', 'Escape'));
expect(wrapper.vm.recording).toBe(false);
wrapper.vm.start();
wrapper.vm.onKeydown(tab);
expect(wrapper.vm.recording).toBe(false);
expect(tab.preventDefault).not.toHaveBeenCalled();
expect(wrapper.emitted('update:value')).toBeUndefined();
});
it('should record a letter by its key, not the symbol a layout made of it', () => {
const wrapper = shallowMount(NovncKeyRecorder);
wrapper.vm.start();
wrapper.vm.onKeydown(key('Alt', 'AltLeft'));
wrapper.vm.onKeydown(key('©', 'KeyG'));
expect(wrapper.emitted('update:value')[0][0].map((K) => K.key)).toStrictEqual(['Alt', 'g']);
});
});
@@ -0,0 +1,158 @@
import { ref } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import NovncShortcutKeysMenu, { ShortcutMenuBody } from '../NovncShortcutKeysMenu.vue';
import {
keyLabel, normalizeShortcut, readShortcuts, shortcutLabels, writeShortcuts, SHORTCUTS_PREFERENCE, SHORTCUT_NAMES_PREFERENCE
} from '../shortcuts';
const mountMenu = (props = {}) => shallowMount(NovncShortcutKeysMenu, { props });
describe('component: NovncShortcutKeysMenu', () => {
it('should send a key on its own when no modifier is held', () => {
const wrapper = mountMenu();
wrapper.vm.sendKey('F5');
expect(wrapper.emitted('send-keys')).toStrictEqual([[['F5']]]);
});
it('should send held modifiers with the next key and then release them', () => {
const wrapper = mountMenu();
wrapper.vm.toggleModifier('AltLeft', true);
wrapper.vm.toggleModifier('ControlLeft', true);
expect(wrapper.vm.heldCombo).toBe('Ctrl+Alt');
wrapper.vm.sendKey('F2');
expect(wrapper.emitted('send-keys')).toStrictEqual([[['ControlLeft', 'AltLeft', 'F2']]]);
expect(wrapper.vm.held).toStrictEqual([]);
});
it('should offer the keys that go with what is held, like the old menu', async() => {
const wrapper = mountMenu();
const keys = () => wrapper.vm.keyOptions.map((K) => K.key);
expect(keys()).toStrictEqual(['PrintScreen']);
expect(wrapper.vm.showFunctionKeys).toBe(true);
wrapper.vm.toggleModifier('ControlLeft', true);
expect(keys()).toStrictEqual(['KeyN', 'KeyT', 'KeyW', 'KeyY']);
expect(wrapper.vm.showFunctionKeys).toBe(false);
wrapper.vm.toggleModifier('AltLeft', true);
expect(keys()).toStrictEqual(['Delete']);
expect(wrapper.vm.showFunctionKeys).toBe(true);
wrapper.vm.toggleModifier('ControlLeft', false);
expect(keys()).toStrictEqual(['PrintScreen']);
expect(wrapper.vm.showFunctionKeys).toBe(true);
});
it('should send Ctrl+W from the menu', () => {
const wrapper = mountMenu();
wrapper.vm.toggleModifier('ControlLeft', true);
wrapper.vm.sendKey('KeyW');
expect(wrapper.emitted('send-keys')).toStrictEqual([[['ControlLeft', 'KeyW']]]);
});
it('should hand Ctrl+Alt+Del to the confirmation instead of sending it', () => {
const wrapper = mountMenu();
wrapper.vm.toggleModifier('ControlLeft', true);
wrapper.vm.toggleModifier('AltLeft', true);
wrapper.vm.sendKey('Delete');
expect(wrapper.emitted('ctrl-alt-del')).toHaveLength(1);
expect(wrapper.emitted('send-keys')).toBeUndefined();
expect(wrapper.vm.held).toStrictEqual([]);
});
it('should let held modifiers go when the menu closes without a key', () => {
const wrapper = mountMenu();
wrapper.vm.toggleModifier('ControlLeft', true);
wrapper.vm.onOpen(false);
expect(wrapper.vm.held).toStrictEqual([]);
});
it('should name saved shortcuts by their keys when they have no name', () => {
const wrapper = mountMenu({
shortcuts: [
{ name: 'Open terminal', keys: [{ key: 'Control' }, { key: 'Alt' }, { key: 't' }] },
{ name: '', keys: [{ key: 'Escape' }] },
]
});
expect(wrapper.vm.items.map(({ name, label }) => [name, label])).toStrictEqual([
['Open terminal', 'Ctrl+Alt+T'],
['Esc', 'Esc'],
]);
});
it('should give the arrow keys the items shown after a modifier swapped them', async() => {
const dropdownItems = ref([]);
const keys = ref(['PrintScreen']);
const wrapper = mount({
components: { ShortcutMenuBody },
setup: () => ({ keys }),
template: '<div dropdown-menu-collection><shortcut-menu-body><div v-for="key in keys" :key="key" dropdown-menu-item>{{ key }}</div></shortcut-menu-body></div>',
}, { global: { provide: { dropdownContext: { dropdownItems } } } });
keys.value = ['KeyN', 'KeyT'];
await wrapper.vm.$nextTick();
await wrapper.find('[dropdown-menu-item]').trigger('keydown', { key: 'ArrowDown' });
expect(dropdownItems.value.map((item) => item.textContent)).toStrictEqual(['KeyN', 'KeyT']);
});
});
describe('shortcuts', () => {
it('should read both the old list format and the named format', () => {
const keys = [{ key: 'Control', code: 'ControlLeft' }];
expect(normalizeShortcut(keys)).toStrictEqual({ name: '', keys });
expect(normalizeShortcut({ name: 'Task manager', keys })).toStrictEqual({ name: 'Task manager', keys });
expect(normalizeShortcut({ name: 'Broken' })).toStrictEqual({ name: 'Broken', keys: [] });
});
it('should label keys the way the console menus show them', () => {
expect(keyLabel('Control')).toBe('Ctrl');
expect(keyLabel('a')).toBe('A');
expect(keyLabel('F2')).toBe('F2');
expect(shortcutLabels({ keys: [{ key: 'Shift' }, { key: ' ' }] })).toStrictEqual(['Shift', 'Space']);
expect(shortcutLabels({ keys: [{ key: 'Alt', code: 'AltLeft' }, { key: '©', code: 'KeyG' }] })).toStrictEqual(['Alt', 'G']);
// Real keys of other layouts stay as typed: AZERTY comma on KeyM, Windows AltGr (Ctrl+Alt) letters.
expect(shortcutLabels({ keys: [{ key: 'Control', code: 'ControlLeft' }, { key: ',', code: 'KeyM' }] })).toStrictEqual(['Ctrl', ',']);
expect(shortcutLabels({ keys: [{ key: 'Control', code: 'ControlLeft' }, { key: 'Alt', code: 'AltRight' }, { key: 'ą', code: 'KeyA' }] })).toStrictEqual(['Ctrl', 'Alt', 'Ą']);
expect(shortcutLabels({ keys: [{ key: 'Alt', code: 'AltLeft' }, { key: 'Dead', code: 'KeyE' }] })).toStrictEqual(['Alt', 'E']);
});
it('should keep the key list in the format older versions read and store names apart', async() => {
const keys = [{ key: 'Control', code: 'ControlLeft' }, { key: 't', code: 'KeyT' }];
const preference = { data: {}, save: jest.fn().mockResolvedValue() };
await writeShortcuts(preference, [{ name: 'Open terminal', keys }, { name: '', keys: [{ key: 'F2', code: 'F2' }] }]);
expect(JSON.parse(preference.data[SHORTCUTS_PREFERENCE])).toStrictEqual([keys, [{ key: 'F2', code: 'F2' }]]);
expect(JSON.parse(preference.data[SHORTCUT_NAMES_PREFERENCE])).toStrictEqual({ 'ControlLeft+KeyT': 'Open terminal' });
expect(readShortcuts(preference).map((shortcut) => shortcut.name)).toStrictEqual(['Open terminal', '']);
});
it('should put the preference back when the save fails', async() => {
const saved = JSON.stringify([[{ key: 'F2', code: 'F2' }]]);
const preference = { data: { [SHORTCUTS_PREFERENCE]: saved }, save: jest.fn().mockRejectedValue(new Error('403')) };
await expect(writeShortcuts(preference, [])).rejects.toThrow('403');
expect(preference.data).toStrictEqual({ [SHORTCUTS_PREFERENCE]: saved });
});
it('should read nothing from a preference it cannot parse', () => {
expect(readShortcuts({ data: { [SHORTCUTS_PREFERENCE]: '{not json' } })).toStrictEqual([]);
expect(readShortcuts(undefined)).toStrictEqual([]);
});
});
@@ -0,0 +1,36 @@
import { shallowMount } from '@vue/test-utils';
import NovncViewOptions, { VIEW_MODE } from '../NovncViewOptions.vue';
jest.mock('../ZoomableRFB', () => ({
MINIMUM_ZOOM: 50, MAXIMUM_ZOOM: 200, ZOOM_STEP: 10
}));
const settings = {
viewMode: VIEW_MODE.NONE, zoomPercent: 100, qualityLevel: 6, compressionLevel: 2
};
const mountOptions = (overrides = {}) => shallowMount(NovncViewOptions, { props: { settings: { ...settings, ...overrides } } });
describe('component: NovncViewOptions', () => {
it('should emit the whole settings object with the changed value', () => {
const wrapper = mountOptions();
wrapper.vm.update('qualityLevel', 9);
expect(wrapper.emitted('update')).toStrictEqual([[{ ...settings, qualityLevel: 9 }]]);
});
it('should step the zoom and stop at its limits', () => {
const wrapper = mountOptions({ zoomPercent: 200 });
wrapper.vm.stepZoom(1);
wrapper.vm.stepZoom(-1);
expect(wrapper.emitted('update').map(([value]) => value.zoomPercent)).toStrictEqual([200, 190]);
});
it('should only treat the manual mode as manual', () => {
expect(mountOptions().vm.isManual).toBe(true);
expect(mountOptions({ viewMode: VIEW_MODE.SCALE }).vm.isManual).toBe(false);
});
});
@@ -0,0 +1,109 @@
import ZoomableRFB from '../ZoomableRFB';
const GUEST_POINTER = 'url(guest-pointer) 0 0, default';
jest.mock('@novnc/novnc/core/rfb', () => ({
__esModule: true,
default: class {
constructor() {
this.scaleViewport = true;
this._canvas = { style: {} };
this._cursorImage = { rgbaPixels: new Uint8Array() };
this._display = { autoscale: jest.fn() };
}
_screenSize() {
return { w: 1000, h: 500 };
}
_fixScrollbars() {}
_handleMouse(ev) {
ev.stopPropagation();
}
// As noVNC does: nothing visible hides the mouse, an image is shown as the pointer.
_refreshCursor() {
this._canvas.style.cursor = this._cursorImage.rgbaPixels.length ? 'url(guest-pointer) 0 0, default' : 'none';
}
},
}), { virtual: true });
describe('zoomableRFB', () => {
describe('the pointer over the screen', () => {
it('should be the system one while the guest sends no pointer image', () => {
const rfb = new ZoomableRFB();
rfb._refreshCursor();
expect(rfb._canvas.style.cursor).toBe('default');
});
it('should be the system one when the image the guest sends is fully transparent', () => {
const rfb = new ZoomableRFB();
rfb._cursorImage = { rgbaPixels: new Uint8Array([255, 255, 255, 0, 0, 0, 0, 0]) };
rfb._refreshCursor();
expect(rfb._canvas.style.cursor).toBe('default');
});
it('should be the one the guest sends when it has visible pixels', () => {
const rfb = new ZoomableRFB();
rfb._cursorImage = { rgbaPixels: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 255]) };
rfb._refreshCursor();
expect(rfb._canvas.style.cursor).toBe(GUEST_POINTER);
});
it('should go back to the system one when the guest stops sending an image', () => {
const rfb = new ZoomableRFB();
rfb._cursorImage = { rgbaPixels: new Uint8Array([0, 0, 0, 255]) };
rfb._refreshCursor();
rfb._cursorImage = { rgbaPixels: [] };
rfb._refreshCursor();
expect(rfb._canvas.style.cursor).toBe('default');
});
});
describe('mouse events on the screen', () => {
it('should let a click through, so an open menu sees it as a click outside', () => {
const rfb = new ZoomableRFB();
const stopPropagation = jest.fn();
rfb._handleMouse({ type: 'click', stopPropagation });
expect(stopPropagation).not.toHaveBeenCalled();
});
it('should leave the others to noVNC', () => {
const rfb = new ZoomableRFB();
const stopPropagation = jest.fn();
rfb._handleMouse({ type: 'mousedown', stopPropagation });
expect(stopPropagation).toHaveBeenCalledTimes(1);
});
});
describe('the fitted screen', () => {
it('should keep room for the focus ring', () => {
const rfb = new ZoomableRFB();
rfb._updateScale();
expect(rfb._display.autoscale).toHaveBeenLastCalledWith(992, 492);
});
it('should take all the room when it is edge to edge', () => {
const rfb = new ZoomableRFB();
rfb.edgeToEdge = true;
expect(rfb._display.autoscale).toHaveBeenLastCalledWith(1000, 500);
});
});
});
+118
View File
@@ -0,0 +1,118 @@
// Saved custom shortcuts live in the Rancher user preference, which every Harvester UI the user opens
// shares. The keys stay a list of recorded keydown events per shortcut, the format older versions read;
// names go in a separate preference so those versions keep working.
export const SHORTCUTS_PREFERENCE = 'prefered-shortcut-keys';
export const SHORTCUT_NAMES_PREFERENCE = 'vnc-shortcut-names';
const KEY_LABELS = {
Control: 'Ctrl',
Escape: 'Esc',
Delete: 'Del',
' ': 'Space',
};
export const MODIFIER_KEYS = ['Control', 'Shift', 'Alt', 'Meta'];
export function keyLabel(key = '') {
if (KEY_LABELS[key]) {
return KEY_LABELS[key];
}
return key.length === 1 ? key.toUpperCase() : key;
}
// macOS Option turns a letter into a symbol (Option+G gives ©, Option+E a dead key). The key that was
// pressed is still in `code`, so in that case the letter is named after it. Only with Alt and no Ctrl:
// AZERTY's comma on KeyM, Dvorak's punctuation or Cyrillic letters are real keys, and Windows AltGr
// reports Ctrl+Alt with a real character.
export function baseKey(K, combination = []) {
const letter = /^Key([A-Z])$/.exec(K?.code || '');
if (!letter || /^[a-z]$/i.test(K.key || '')) {
return K;
}
const held = combination.map((M) => M.key);
const isOptionSymbol = held.includes('Alt') && !held.includes('Control');
return K.key === 'Dead' || isOptionSymbol ? { ...K, key: letter[1].toLowerCase() } : K;
}
export function normalizeShortcut(item) {
if (Array.isArray(item)) {
return { name: '', keys: item };
}
return { name: item?.name || '', keys: Array.isArray(item?.keys) ? item.keys : [] };
}
export function shortcutLabels(shortcut) {
const keys = shortcut?.keys || [];
return keys.map((K) => keyLabel(baseKey(K, keys).key));
}
// Two shortcuts are the same when they press the same keys, whatever they are called.
export function shortcutId(shortcut) {
return (shortcut?.keys || []).map((K) => K.code || K.key).join('+');
}
// Only what sending a shortcut needs, so a recorded KeyboardEvent can be stored.
export function recordKey(event, combination = []) {
const {
key, keyCode, code, location, charCode
} = event;
return baseKey({
key, keyCode, code, location, charCode
}, combination);
}
function parse(value, fallback) {
try {
return value ? JSON.parse(value) : fallback;
} catch (err) {
return fallback;
}
}
export function readShortcuts(preference) {
const list = parse(preference?.data?.[SHORTCUTS_PREFERENCE], []);
const names = parse(preference?.data?.[SHORTCUT_NAMES_PREFERENCE], {});
return (Array.isArray(list) ? list : [])
.map(normalizeShortcut)
.filter((shortcut) => shortcut.keys.length)
.map((shortcut) => ({ ...shortcut, name: shortcut.name || names?.[shortcutId(shortcut)] || '' }));
}
export function writeShortcuts(preference, shortcuts) {
const names = {};
shortcuts.forEach((shortcut) => {
if (shortcut.name) {
names[shortcutId(shortcut)] = shortcut.name;
}
});
const previous = {
[SHORTCUTS_PREFERENCE]: preference.data[SHORTCUTS_PREFERENCE],
[SHORTCUT_NAMES_PREFERENCE]: preference.data[SHORTCUT_NAMES_PREFERENCE],
};
preference.data[SHORTCUTS_PREFERENCE] = JSON.stringify(shortcuts.map((shortcut) => shortcut.keys));
preference.data[SHORTCUT_NAMES_PREFERENCE] = JSON.stringify(names);
// The lists read straight from the preference, so a failed save must not leave the change showing.
return preference.save().catch((err) => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) {
delete preference.data[key];
} else {
preference.data[key] = value;
}
});
throw err;
});
}
+75 -33
View File
@@ -1011,58 +1011,100 @@ harvester:
from: Generated from
down: No events in the past hour
console:
down: This virtual machine is down. Please start it to access its console.
shortcutKeys: Shortcut Keys
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
settings:
label: Settings
viewMode:
label: Display
scale: Scale the display to fit the window
none: Manual zoom
zoom: Zoom
notRunning:
title: This virtual machine is not running
detail: Start it to use its console.
startingTitle: This virtual machine is starting
startingDetail: The console connects once it is running.
shortcutKeys: Shortcut keys
softReboot: Soft reboot
view: View
shortcutMenu:
yourShortcuts: Your shortcuts
holdModifiers: Hold modifiers
holdHint: 'The next key you pick is sent as {combo}+key.'
keys: Keys
printScreen: Print Screen
functionKeys: Function keys
manage: Manage custom shortcuts…
blockedDisconnected: Keys can be sent once the console is connected.
hint: Clicking the console captures your keyboard. To send a shortcut, use the Shortcut keys menu.
hintViewOnly: '"View only" mode is on. In this mode, your mouse and keyboard are not sent to the VM.'
viewOnly: View only
quality: JPEG quality
compression: Compression level
connection:
menuLabel: Connection
description: Connected to {name}. If the screen stops updating, reconnecting usually fixes it.
customKeys:
title: Custom shortcuts
description: Save key combinations you send often. They appear at the top of the Shortcut keys menu.
new: New shortcut
combination: Key combination
reserved: Combinations your browser or OS reserves, like Ctrl+Alt+Del or Ctrl+W, can’t be recorded. Send them from the Shortcut keys menu instead, by holding Ctrl or Alt there.
duplicate: This key combination is already saved.
name: Name (optional)
clear: Clear
add: Add shortcut
saved: 'Saved shortcuts ({count})'
delete: 'Delete {name}'
done: Done
saveError: Unable to save the custom shortcuts
recorder:
empty: Click here, then press a key combination
recording: Press a key combination…
recordingStatus: Recording
again: Click to record again
viewOptions:
scaling: Scaling
automatic: Automatic
manual: Manual
automaticHelp: The screen scales to fit the window.
manualHelp: You set the zoom. Scroll to see parts of the screen that do not fit.
zoom: Zoom level
zoomIn: Zoom in
zoomOut: Zoom out
levelOf: '{value} of {max}'
qualityLevel:
label: JPEG quality
help: Higher quality looks sharper and uses more bandwidth.
compressionLevel:
label: Compression
help: Higher compression saves bandwidth but uses more CPU.
reset: Reset to defaults
fullscreen:
enter: Full Screen
exit: Exit Full Screen
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
title: 'Send Ctrl+Alt+Del to {name}?'
message: The key combination goes straight to the guest operating system. On many systems, including most Linux distributions, this restarts the VM immediately. Unsaved work in the VM will be lost.
button: Send Ctrl+Alt+Del
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
title: 'Soft reboot {name}?'
message: The guest operating system is asked to shut down and restart. Unsaved work in the VM will be lost.
button: Soft reboot
takeOver:
label: Take Over
label: Take over
title: Take over this console?
confirm: The other VNC session connected to this virtual machine will be disconnected.
overlay:
connecting: 'Connecting to {name}…'
attempt: 'Attempt {retryTimes} of {maximumRetryTimes}.'
reconnectNow: Reconnect now
failure:
inUse: This console is in use
unavailable: Unable to connect
dropped: Disconnected
dropped: Connection lost
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.
dropped: The console stopped receiving data from the virtual machine. The VM itself may still be running, or another VNC session may have opened this console.
status:
connecting: Connecting
connecting: Connecting…
connected: Connected
connectedViewOnly: Connected (view only)
disconnected: Disconnected
notRunning: Not running
failed: Connection failed
reconnecting: 'Reconnecting {retryTimes} of {maximumRetryTimes}'
credentialsRequired: This console requires credentials that the dashboard cannot provide.