From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Cc: Alexandre Derumier <aderumier@groupe-cyllene.com>
Subject: SPAM: [RFC pve-manager 06/13] ui: add kyber console
Date: Tue, 25 Aug 2026 13:08:38 +0200 [thread overview]
Message-ID: <20260825110849.2967694-7-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825110849.2967694-1-alexandre.derumier@groupe-cyllene.com>
From: Alexandre Derumier <aderumier@groupe-cyllene.com>
Signed-off-by: Alexandre Derumier <aderumier@groupe-cyllene.com>
---
Makefile | 2 +-
PVE/HTTPServer.pm | 4 +-
PVE/Service/pveproxy.pm | 53 +++-
kyber-web/Makefile | 22 ++
kyber-web/index.html.tpl | 438 +++++++++++++++++++++++++++
www/manager6/Utils.js | 30 +-
www/manager6/button/ConsoleButton.js | 22 ++
www/manager6/qemu/AudioEdit.js | 3 +
www/manager6/qemu/Config.js | 9 +-
www/manager6/qemu/DisplayEdit.js | 4 +-
10 files changed, 581 insertions(+), 6 deletions(-)
create mode 100644 kyber-web/Makefile
create mode 100644 kyber-web/index.html.tpl
diff --git a/Makefile b/Makefile
index 6c8b82e..b7dfc7f 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ DSC=$(PACKAGE)_$(DEB_VERSION).dsc
DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb
DESTDIR=
-SUBDIRS = aplinfo PVE bin www services configs network-hooks test templates
+SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates
all: $(SUBDIRS)
set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done
diff --git a/PVE/HTTPServer.pm b/PVE/HTTPServer.pm
index 34403e8..c3fef48 100755
--- a/PVE/HTTPServer.pm
+++ b/PVE/HTTPServer.pm
@@ -120,11 +120,13 @@ sub auth_handler {
$isUpload = 1;
}
+ my $is_kyber_console = $rel_uri =~ m|^/nodes/[^/]+/qemu/\d+/kyber/|;
+
# Skip CSRF check for file upload (difficult to pass CSRF header with native html forms).
# Also skip the check with API tokens, as one of the design goals of API tokens was to
# provide stateless API access without requiring round-trips to get such CSRF tokens.
# CSRF-prevention also does not make much sense outside of the browser context.
- if ($method ne 'GET' && !($api_token || $isUpload)) {
+ if ($method ne 'GET' && !($api_token || $isUpload || $is_kyber_console)) {
my $euid = $>;
PVE::AccessControl::verify_csrf_prevention_token($username, $token) if $euid != 0;
}
diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index dfdd014..bcc1353 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -20,6 +20,10 @@ use PVE::Cluster;
use PVE::Daemon;
use PVE::DataCenterConfig;
use PVE::HTTPServer;
+use HTTP::Status qw(HTTP_BAD_REQUEST);
+use PVE::Exception;
+use PVE::INotify;
+use PVE::RPCEnvironment;
use PVE::SafeSyslog;
use PVE::pvecfg;
use PVE::Tools;
@@ -53,6 +57,7 @@ my $basedirs = {
fontlogos => '/usr/share/fonts-font-logos',
i18n => '/usr/share/pve-i18n',
manager => '/usr/share/pve-manager',
+ kyber => '/usr/share/pve-kyber-web',
novnc => '/usr/share/novnc-pve',
yew_mobile => '/usr/share/pve-yew-mobile-gui',
i18n_yew => '/usr/share/pve-yew-mobile-i18n',
@@ -60,6 +65,41 @@ my $basedirs = {
xtermjs => '/usr/share/pve-xtermjs',
};
+my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!;
+my $kyber_proxy_socket = '/run/pvekyberproxy.sock';
+
+my sub check_console_access {
+ my ($auth, $console_type, $node, $vmid) = @_;
+
+ PVE::Exception::raise(
+ "the $console_type console for VM $vmid must be opened on node '$node'\n",
+ code => HTTP_BAD_REQUEST,
+ ) if $node ne PVE::INotify::nodename();
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($auth->{userid}, "/vms/$vmid", ['VM.Console']);
+
+ return;
+}
+
+sub console_proxy {
+ my ($server, $reqstate, $auth, $method, $path) = @_;
+
+ if (my ($node, $vmid, $rest) = $path =~ $kyber_console_prefix) {
+ check_console_access($auth, 'Kyber', $node, $vmid);
+
+ my $target = $path;
+
+ if (my $query = $reqstate->{request}->url->query()) {
+ $target .= "?$query";
+ }
+
+ return { socket => $kyber_proxy_socket, path => $target, tls => 0 };
+ }
+
+ return undef;
+}
+
sub init {
my ($self) = @_;
@@ -77,6 +117,7 @@ sub init {
my $dirs = {};
+ add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/");
add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/");
add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/");
add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/");
@@ -134,6 +175,7 @@ sub init {
},
},
dirs => $dirs,
+ local_http_proxy_handler => \&console_proxy,
};
if (defined($proxyconf->{DHPARAMS})) {
@@ -247,6 +289,7 @@ sub get_index {
(is_phone($r->header('User-Agent')) && (!defined($args->{mobile}) || $args->{mobile}))
|| $args->{mobile};
+ my $kyber = defined($args->{console}) && $args->{kyber};
my $novnc = defined($args->{console}) && $args->{novnc};
my $xtermjs = defined($args->{console}) && $args->{xtermjs};
@@ -291,7 +334,9 @@ sub get_index {
# by default, load the normal index
my $dir = $basedirs->{manager};
- if ($novnc) {
+ if ($kyber) {
+ $dir = $basedirs->{kyber};
+ } elsif ($novnc) {
$dir = $basedirs->{novnc};
} elsif ($xtermjs) {
$dir = $basedirs->{xtermjs};
@@ -305,6 +350,12 @@ sub get_index {
$template->process("$dir/index.html.tpl", $vars, \$page) || die $template->error(), "\n";
my $headers = HTTP::Headers->new(Content_Type => "text/html; charset=utf-8");
+
+ if ($kyber) {
+ $headers->header('Cross-Origin-Opener-Policy' => 'same-origin');
+ $headers->header('Cross-Origin-Embedder-Policy' => 'require-corp');
+ }
+
my $resp = HTTP::Response->new(200, "OK", $headers, $page);
return $resp;
diff --git a/kyber-web/Makefile b/kyber-web/Makefile
new file mode 100644
index 0000000..28b07a0
--- /dev/null
+++ b/kyber-web/Makefile
@@ -0,0 +1,22 @@
+include ../defines.mk
+
+KYBERDIR = $(DESTDIR)/usr/share/pve-kyber-web
+
+all:
+
+.PHONY: install
+install: index.html.tpl
+ install -d $(KYBERDIR)
+ install -m 0644 index.html.tpl $(KYBERDIR)/index.html.tpl
+# The client itself - kyclient_wasm.js, kyclient_wasm_bg.wasm and the audio
+# worklet beside them - is built from kyber-web and shipped by its own package
+# rather than vendored here. It is AGPL-3.0-or-later, same as pve-manager, but
+# it is a separate upstream with its own release cadence and a wasm toolchain
+# that has no business in this build.
+#
+# The page drives that SDK directly and needs nothing else from it: no bundle,
+# no stylesheets, and none of the demo client's UI.
+
+.PHONY: clean distclean
+distclean: clean
+clean:
diff --git a/kyber-web/index.html.tpl b/kyber-web/index.html.tpl
new file mode 100644
index 0000000..31f5e76
--- /dev/null
+++ b/kyber-web/index.html.tpl
@@ -0,0 +1,438 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Kyber console</title>
+ <!--
+ Relative URLs resolve into the client's own directory rather than against
+ this page, which is served from '/'. The SDK loads a few assets by bare
+ name - the audio worklet among them - and without this they would be
+ looked for at the document root and 404 there.
+ -->
+ <base href="/kyber/">
+ <style>
+ html, body {
+ margin: 0;
+ height: 100%;
+ background: #000;
+ color: #ddd;
+ font: 13px/1.5 system-ui, sans-serif;
+ overflow: hidden;
+ }
+ #container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ }
+ #container canvas {
+ display: block;
+ outline: none;
+ }
+ #status {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 2em;
+ text-align: center;
+ background: #000;
+ white-space: pre-wrap;
+ }
+ #status[hidden] { display: none; }
+ #status.failed { color: #ff8080; }
+ /* The control bar is noVNC's, down to its images: a console should not
+ look like a different product depending on which one it is. Only the
+ rules this page needs are copied - loading noVNC's stylesheet whole
+ would bring its layout with it. */
+ #kyber_control_bar_anchor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ z-index: 10;
+ transition: 0.5s ease-in-out;
+ }
+ #kyber_control_bar {
+ position: relative;
+ left: -100%;
+ height: 100%;
+ padding: 5px;
+ background-color: #1c2331;
+ border-radius: 0 12px 12px 0;
+ transition: 0.5s ease-in-out;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ #kyber_control_bar.kyber_open { left: 0; }
+ #kyber_control_bar_handle {
+ position: absolute;
+ left: -15px;
+ top: 0;
+ transform: translateY(35px);
+ width: calc(100% + 30px);
+ height: 50px;
+ z-index: -1;
+ cursor: pointer;
+ border-radius: 6px;
+ background-color: #1c2331;
+ background-image: url("/novnc/app/images/handle_bg.svg");
+ background-repeat: no-repeat;
+ background-position: right;
+ box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+ }
+ #kyber_control_bar_handle:after {
+ content: "";
+ transition: transform 0.1s ease-in-out;
+ background: url("/novnc/app/images/handle.svg");
+ position: absolute;
+ top: 22px;
+ right: 5px;
+ width: 5px;
+ height: 6px;
+ }
+ #kyber_control_bar.kyber_open #kyber_control_bar_handle:after {
+ transform: translateX(1px) rotate(180deg);
+ }
+ .kyber_button {
+ min-width: 36px;
+ padding: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ background-color: transparent;
+ color: #fff;
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1.2;
+ }
+ .kyber_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+ .kyber_button img { width: 24px; height: 24px; display: block; margin: auto; }
+ #kyber_power_menu {
+ display: none;
+ position: absolute;
+ left: 100%;
+ margin-left: 8px;
+ background-color: #1c2331;
+ border-radius: 6px;
+ padding: 5px;
+ box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+ }
+ #kyber_power_menu.kyber_open { display: flex; flex-direction: column; gap: 4px; }
+ #kyber_power_menu .kyber_button { white-space: nowrap; text-align: left; }
+ #container:fullscreen,
+ :fullscreen #container {
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #000;
+ }
+</style>
+ </head>
+ <body>
+ <div id="kyber_control_bar_anchor">
+ <div id="kyber_control_bar">
+ <div id="kyber_control_bar_handle"></div>
+ <button class="kyber_button" id="kyber_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+ <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+ </button>
+ <button class="kyber_button" id="kyber_fullscreen" title="Fullscreen">
+ <img src="/novnc/app/images/fullscreen.svg" alt="">
+ </button>
+ <button class="kyber_button" id="kyber_power" title="Power">
+ <img src="/novnc/app/images/power.svg" alt="">
+ </button>
+ <div id="kyber_power_menu">
+ <button class="kyber_button" data-power="start">Start</button>
+ <button class="kyber_button" data-power="shutdown">Shutdown</button>
+ <button class="kyber_button" data-power="reboot">Reboot</button>
+ <button class="kyber_button" data-power="reset">Reset</button>
+ <button class="kyber_button" data-power="stop">Stop</button>
+ </div>
+ </div>
+ </div>
+ <div id="container"></div>
+ <div id="status">Connecting…</div>
+
+ <script type="module">
+ import init, { AuthCredentials, Client, StreamingConfigBuilder }
+ from '/kyber/kyclient_wasm.js';
+
+ const params = new URLSearchParams(window.location.search);
+ const node = params.get('node');
+ const vmid = params.get('vmid');
+ const vmname = params.get('vmname');
+
+ document.title = vmname ? `${vmname} (${vmid}) - Kyber console`
+ : `VM ${vmid} - Kyber console`;
+
+ const statusEl = document.getElementById('status');
+ const container = document.getElementById('container');
+
+ const setStatus = (text, failed) => {
+ statusEl.textContent = text;
+ statusEl.classList.toggle('failed', !!failed);
+ statusEl.hidden = false;
+ };
+
+ let client = null;
+ let streamingConfig = null;
+
+ function createCanvas() {
+ const canvas = document.createElement('canvas');
+ canvas.id = 'canvas';
+ canvas.width = 1024;
+ canvas.height = 768;
+ // Without this every keystroke goes to the document, not the guest.
+ canvas.tabIndex = 0;
+ container.appendChild(canvas);
+ canvas.focus();
+ return canvas;
+ }
+
+ function deleteCanvas() {
+ document.getElementById('canvas')?.remove();
+ }
+
+ // The canvas cannot be reused: its control was transferred to an
+ // offscreen canvas in the worker, one way.
+ async function restartStreaming() {
+ deleteCanvas();
+ createCanvas();
+ await client.start_streaming('canvas', streamingConfig.clone());
+ }
+
+ // A guest resize shows up as the host video process stopping; the stream
+ // must be rebuilt, since the decoder cannot change codec configuration
+ // mid-stream. Rate limited against a host stuck in a start/die loop.
+ // The stream's own geometry, which is what the canvas is sized from.
+ let videoSize = null;
+
+ // Native size in a window, scaled up to fill the screen in fullscreen -
+ // aspect ratio kept, so a 4:3 guest letterboxes rather than distorts.
+ function fitCanvas() {
+ const canvas = document.getElementById('canvas');
+ if (!canvas || !videoSize) {
+ return;
+ }
+ let { width, height } = videoSize;
+ if (document.fullscreenElement) {
+ const scale = Math.min(
+ window.innerWidth / width,
+ window.innerHeight / height,
+ );
+ width = Math.floor(width * scale);
+ height = Math.floor(height * scale);
+ }
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ }
+
+ const VIDEO_RESTART_MIN_INTERVAL_MS = 2000;
+ let lastVideoRestart = 0;
+
+ async function onClientEvent(name, event) {
+ switch (name) {
+ case 'streamer_pipeline_event':
+ // Not 'streaming_stopped': the SDK emits that only for a
+ // stop this client asked for. stop_streaming() comes back as
+ // one below, which rebuilds canvas and stream.
+ if (event.type === 'video' && event.stage === 'process'
+ && event.event === 'stopped' && client !== null) {
+ const now = Date.now();
+ if (now - lastVideoRestart < VIDEO_RESTART_MIN_INTERVAL_MS) {
+ console.warn('Host video stopped again too soon; not restarting');
+ break;
+ }
+ lastVideoRestart = now;
+ setStatus('Display changed, restarting…');
+ client.stop_streaming();
+ }
+ break;
+ case 'controlplane_connected':
+ statusEl.hidden = true;
+ await client.start_streaming('canvas', streamingConfig.clone());
+ break;
+ case 'canvas_resized':
+ videoSize = { width: event.width, height: event.height };
+ fitCanvas();
+ break;
+ case 'streaming_stopped':
+ // The session is still up, so pick the new stream up.
+ await restartStreaming();
+ statusEl.hidden = true;
+ break;
+ case 'reconnecting':
+ setStatus(`Reconnecting (${event.attempt}/${event.maxAttempts})…`);
+ break;
+ case 'reconnected':
+ statusEl.hidden = true;
+ if (event.shouldRestartStreaming) {
+ await restartStreaming();
+ }
+ break;
+ case 'reconnection_failed':
+ setStatus('Connection lost. Close this window and open the console again.', true);
+ deleteCanvas();
+ break;
+ case 'stopped':
+ setStatus(`Console stopped: ${event.reason()}`, true);
+ deleteCanvas();
+ break;
+ }
+ }
+
+ async function boot() {
+ if (!node || !vmid) {
+ throw new Error('missing node or vmid');
+ }
+
+ await init();
+
+ // Proxmox checks its ACL here and mints the token. Nothing is in the
+ // URL, so a copied link grants nothing.
+ const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+ const res = await fetch(`${base}/kyberproxy`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { CSRFPreventionToken: '[% token %]' },
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ throw new Error(`could not start the console (${res.status}) ${detail}`);
+ }
+ const { data } = await res.json();
+
+ // Control plane only: pveproxy forwards this prefix to
+ // pvekyberproxy and on to the VM's controller. The data plane goes
+ // straight to the UDP port start_mux names - pveproxy speaks no QUIC.
+ const basePath = `${base}/kyber`;
+
+ const builder = new StreamingConfigBuilder();
+ builder.protocol = 'kymux';
+ builder.codec = 'h264';
+ builder.bitrate = 20000000;
+ builder.display_index = 0;
+ builder.inputs = true;
+ builder.clipboard = true;
+ // Carried when the VM has an audio device on a 'dbus' audiodev.
+ // The controller asks the AV service, which says no when there is
+ // none, and the client simply gets no audio stream.
+ builder.audio = true;
+ // Needs nvenc; this encoder is libx264.
+ builder.intra_refresh = false;
+ streamingConfig = builder.build();
+
+ createCanvas();
+
+ client = await Client.new(
+ window.location.hostname,
+ window.location.port || '443',
+ AuthCredentials.jwt(data.ticket),
+ onClientEvent,
+ false, // metrics
+ 'none', // metrics mode
+ null, // live metrics callback
+ true, // reconnect on its own
+ basePath,
+ );
+
+ client.connect();
+ }
+
+ // The bar retracts like noVNC's: the handle is always reachable, the bar
+ // itself only when asked for.
+ const controlBar = document.getElementById('kyber_control_bar');
+ const powerMenu = document.getElementById('kyber_power_menu');
+ document.getElementById('kyber_control_bar_handle').addEventListener('click', () => {
+ controlBar.classList.toggle('kyber_open');
+ if (!controlBar.classList.contains('kyber_open')) {
+ powerMenu.classList.remove('kyber_open');
+ }
+ });
+
+ // Ctrl+Alt+Del cannot be captured by any web page - the OS takes it
+ // before a browser sees it - so it is injected as scancodes instead.
+ document.getElementById('kyber_ctrl_alt_del').addEventListener('click', () => {
+ try {
+ client?.send_ctrl_alt_del();
+ } catch (err) {
+ setStatus(`Could not send Ctrl-Alt-Del: ${err.message ?? err}`, true);
+ }
+ });
+
+ // Fullscreen takes the keyboard with it. Without the lock, combinations
+ // the browser and desktop claim - Ctrl+W, Alt+Tab, F11 - never reach the
+ // guest. It is granted only to a fullscreen document, and has to be
+ // released again or it outlives the console.
+ async function enterFullscreen() {
+ const el = document.documentElement;
+ await el.requestFullscreen();
+ try {
+ await navigator.keyboard?.lock?.();
+ } catch (err) {
+ console.warn('keyboard lock refused:', err);
+ }
+ document.getElementById('canvas')?.focus();
+ }
+
+ document.getElementById('kyber_fullscreen').addEventListener('click', async () => {
+ try {
+ if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ } else {
+ await enterFullscreen();
+ }
+ } catch (err) {
+ setStatus(`Fullscreen failed: ${err.message ?? err}`, true);
+ }
+ });
+
+ document.addEventListener('fullscreenchange', () => {
+ if (!document.fullscreenElement) {
+ navigator.keyboard?.unlock?.();
+ }
+ fitCanvas();
+ });
+ window.addEventListener('resize', fitCanvas);
+
+ document.getElementById('kyber_power').addEventListener('click', () => {
+ powerMenu.classList.toggle('kyber_open');
+ });
+
+ // Power goes through the API rather than the guest, so it works when the
+ // guest does not answer - which is the case the buttons exist for.
+ for (const button of powerMenu.querySelectorAll('[data-power]')) {
+ button.addEventListener('click', async () => {
+ const action = button.dataset.power;
+ powerMenu.classList.remove('kyber_open');
+ setStatus(`${button.textContent}…`);
+ try {
+ const res = await fetch(
+ `/api2/json/nodes/${node}/qemu/${vmid}/status/${action}`,
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { CSRFPreventionToken: '[% token %]' },
+ },
+ );
+ if (!res.ok) {
+ throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
+ }
+ statusEl.hidden = true;
+ } catch (err) {
+ setStatus(`${button.textContent} failed: ${err.message ?? err}`, true);
+ }
+ });
+ }
+
+ boot().catch((err) => {
+ console.error(err);
+ setStatus(`Kyber console failed: ${err.message ?? err}`, true);
+ });
+ </script>
+ </body>
+</html>
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index c86a00c..44ffdfe 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -656,6 +656,7 @@ Ext.define('PVE.Utils', {
serial3: gettext('Serial terminal') + ' 3',
virtio: 'VirtIO-GPU',
'virtio-gl': 'VirGL GPU',
+ kyber: 'Kyber',
none: Proxmox.Utils.noneText,
},
@@ -1452,6 +1453,8 @@ Ext.define('PVE.Utils', {
PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd);
} else if (viewer === 'xtermjs') {
Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
+ } else if (viewer === 'kyber') {
+ PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname);
} else if (viewer === 'vv') {
let url = '/nodes/' + nodename + '/spiceshell';
let params = {
@@ -1475,16 +1478,22 @@ Ext.define('PVE.Utils', {
},
defaultViewer: function (consoles, type) {
- var allowSpice, allowXtermjs;
+ var allowSpice, allowXtermjs, allowKyber;
if (consoles === true) {
allowSpice = true;
allowXtermjs = true;
+ allowKyber = true;
} else if (typeof consoles === 'object') {
allowSpice = consoles.spice;
allowXtermjs = !!consoles.xtermjs;
+ allowKyber = !!consoles.kyber;
}
let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
+ // A Kyber display serves no VNC, so nothing else can show it.
+ if (allowKyber) {
+ return 'kyber';
+ }
if (dv === 'vv' && !allowSpice) {
dv = allowXtermjs ? 'xtermjs' : 'html5';
} else if (dv === 'xtermjs' && !allowXtermjs) {
@@ -1515,6 +1524,24 @@ Ext.define('PVE.Utils', {
}
},
+ // The Kyber console streams the display over its own controller rather
+ // than through noVNC, so it gets a window of its own. Everything it
+ // needs is fetched by that page from kyberproxy; nothing is passed in
+ // the URL, so a copied link grants nothing on its own.
+ openKyberViewer: function (vmtype, vmid, nodename, vmname) {
+ let url = Ext.Object.toQueryString({
+ console: vmtype,
+ kyber: 1,
+ vmid: vmid,
+ vmname: vmname,
+ node: nodename,
+ });
+ let nw = window.open('?' + url, '_blank', 'innerWidth=1280,innerheight=800');
+ if (nw) {
+ nw.focus();
+ }
+ },
+
openSpiceViewer: function (url, params) {
var downloadWithName = function (uri, name) {
var link = Ext.DomHelper.append(document.body, {
@@ -1590,6 +1617,7 @@ Ext.define('PVE.Utils', {
let consoles = {
spice: !!conf.spice,
xtermjs: !!conf.serial,
+ kyber: !!conf.kyber,
};
PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
},
diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js
index d64f280..63377e7 100644
--- a/www/manager6/button/ConsoleButton.js
+++ b/www/manager6/button/ConsoleButton.js
@@ -12,6 +12,8 @@ Ext.define('PVE.button.ConsoleButton', {
enableSpice: true,
enableXtermjs: true,
+ // Off unless a VM says otherwise, so other guests show it greyed out.
+ enableKyber: false,
nodename: undefined,
@@ -33,6 +35,13 @@ Ext.define('PVE.button.ConsoleButton', {
me.down('#xtermjs').setDisabled(!enable);
},
+ setEnableKyber: function (enable) {
+ var me = this;
+
+ me.enableKyber = enable;
+ me.down('#kybermenu').setDisabled(!enable);
+ },
+
handler: function () {
// main, general, handler
let me = this;
@@ -40,6 +49,7 @@ Ext.define('PVE.button.ConsoleButton', {
{
spice: me.enableSpice,
xtermjs: me.enableXtermjs,
+ kyber: me.enableKyber,
},
me.consoleType,
me.vmid,
@@ -84,6 +94,18 @@ Ext.define('PVE.button.ConsoleButton', {
view.openConsole(button.type);
},
},
+ {
+ xtype: 'menuitem',
+ itemId: 'kybermenu',
+ text: 'Kyber',
+ type: 'kyber',
+ iconCls: 'fa fa-fw fa-desktop',
+ disabled: true,
+ handler: function (button) {
+ let view = this.up('button');
+ view.openConsole(button.type);
+ },
+ },
{
text: 'xterm.js',
itemId: 'xtermjs',
diff --git a/www/manager6/qemu/AudioEdit.js b/www/manager6/qemu/AudioEdit.js
index ba588ed..49f03e1 100644
--- a/www/manager6/qemu/AudioEdit.js
+++ b/www/manager6/qemu/AudioEdit.js
@@ -35,6 +35,9 @@ Ext.define('PVE.qemu.AudioInputPanel', {
fieldLabel: gettext('Backend Driver'),
comboItems: [
['spice', 'SPICE'],
+ // Puts the guest's audio on the same D-Bus as its display,
+ // which is where the Kyber and RDP consoles read it.
+ ['dbus', gettext('D-Bus (Kyber/RDP console)')],
['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`],
],
},
diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js
index 842d35d..bcf54d1 100644
--- a/www/manager6/qemu/Config.js
+++ b/www/manager6/qemu/Config.js
@@ -232,9 +232,10 @@ Ext.define('PVE.qemu.Config', {
disabled: !caps.vms['VM.Console'],
hidden: template,
consoleType: 'kvm',
- // disable spice/xterm for default action until status api call succeeded
+ // disable spice/xterm/kyber for default action until status api call succeeded
enableSpice: false,
enableXtermjs: false,
+ enableKyber: false,
consoleName: vm.name,
nodename: nodename,
vmid: vmid,
@@ -458,6 +459,7 @@ Ext.define('PVE.qemu.Config', {
var qmpstatus;
var spice = false;
var xtermjs = false;
+ var kyber = false;
var lock;
var rec;
@@ -475,6 +477,10 @@ Ext.define('PVE.qemu.Config', {
spice = !!s.data.get('spice');
xtermjs = !!s.data.get('serial');
+ // Reported by the status API only for a display set to
+ // 'kyber', which is also the only case with a controller
+ // behind it.
+ kyber = !!s.data.get('kyber');
}
rec = s.data.get('tags');
@@ -496,6 +502,7 @@ Ext.define('PVE.qemu.Config', {
consoleBtn.setEnableSpice(spice);
consoleBtn.setEnableXtermJS(xtermjs);
+ consoleBtn.setEnableKyber(kyber);
statusTxt.update({ lock: lock });
diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js
index 3f583ad..79e1ea2 100644
--- a/www/manager6/qemu/DisplayEdit.js
+++ b/www/manager6/qemu/DisplayEdit.js
@@ -26,7 +26,9 @@ Ext.define('PVE.qemu.DisplayInputPanel', {
return '4';
} else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') {
return '16';
- } else if (val.match(/^virtio/)) {
+ } else if (val.match(/^virtio/) || val === 'kyber') {
+ // kyber is a virtio-vga underneath, so it takes the same
+ // memory as one.
return '256';
} else if (get('matchNonGUIOption')) {
return 'N/A';
--
2.55.0
next prev parent reply other threads:[~2026-08-25 11:10 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-25 11:08 SPAM: [RFC pve-http-server/qemu-server/pve-manager/pve-{qemu-kyber,kyberproxy, kyber-web,qemu-rdp,rdpproxy,rdp-web} 00/13] add rdp && kyber consoles for qemu over D-Bus display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:08 ` Alexandre Derumier [this message]
2026-08-25 11:08 ` SPAM: [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:08 ` SPAM: [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260825110849.2967694-7-alexandre.derumier@groupe-cyllene.com \
--to=alexandre.derumier@groupe-cyllene.com \
--cc=aderumier@groupe-cyllene.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.