From dbd2c9aab8f41bd691ead8d2d5132eb35290bf70 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Fri, 7 Feb 2025 11:39:49 +0200 Subject: [PATCH 1/4] consoles: Allow adding and editing VNC - The "Console" card is always present and let's people manage VNC server settings while the machine is off. - The "VNC console" tab is always present when the machine is running and let's people add VNC. - There is no way yet to change VNC server settings for a running machine since there is no good place for the button that would open the dialog. --- src/components/common/needsShutdown.jsx | 39 +++++ src/components/vm/consoles/consoles.css | 4 + src/components/vm/consoles/consoles.jsx | 108 ++++++++++-- src/components/vm/consoles/vncAddEdit.jsx | 192 ++++++++++++++++++++++ src/components/vm/vmDetailsPage.jsx | 34 ++-- src/libvirtApi/domain.js | 22 ++- test/check-machines-consoles | 121 +++++++++++++- test/reference | 2 +- 8 files changed, 482 insertions(+), 40 deletions(-) create mode 100644 src/components/vm/consoles/vncAddEdit.jsx diff --git a/src/components/common/needsShutdown.jsx b/src/components/common/needsShutdown.jsx index a9116cab0..8d593fe52 100644 --- a/src/components/common/needsShutdown.jsx +++ b/src/components/common/needsShutdown.jsx @@ -85,6 +85,41 @@ export function needsShutdownSpice(vm) { return vm.hasSpice !== vm.inactiveXML.hasSpice; } +export function needsShutdownVnc(vm) { + function find_vnc(v) { + return v.displays && v.displays.find(d => d.type == "vnc"); + } + + const active_vnc = find_vnc(vm); + const inactive_vnc = find_vnc(vm.inactiveXML); + + if (inactive_vnc) { + if (!active_vnc) + return true; + + // The active_vnc.port value is the actual port allocated at + // machine start, it is never -1. Thus, we can't just compare + // inactive_vnc.port with active_vnc.port here when + // inactive_vnc.port is -1. Also, when inactive_vnc.port _is_ + // -1, we can't tell whether active_vnc.port has been + // allocated based on some old fixed port in inactive_vnc.port + // (in which case we might want to shutdown and restart), or + // whether it was allocated dynamically (in which case we + // don't want to). But luckily that doesn't really matter and + // a shutdown would not have any useful effect anyway, so we + // don't have to worry that we are missing a notification for + // a pending shutdown. + // + if (inactive_vnc.port != -1 && active_vnc.port != inactive_vnc.port) + return true; + + if (active_vnc.password != inactive_vnc.password) + return true; + } + + return false; +} + export function getDevicesRequiringShutdown(vm) { if (!vm.persistent) return []; @@ -125,6 +160,10 @@ export function getDevicesRequiringShutdown(vm) { if (needsShutdownSpice(vm)) devices.push(_("SPICE")); + // VNC + if (needsShutdownVnc(vm)) + devices.push(_("VNC")); + // TPM if (needsShutdownTpm(vm)) devices.push(_("TPM")); diff --git a/src/components/vm/consoles/consoles.css b/src/components/vm/consoles/consoles.css index 53ac629af..ec4312605 100644 --- a/src/components/vm/consoles/consoles.css +++ b/src/components/vm/consoles/consoles.css @@ -28,3 +28,7 @@ #pf-v5-c-console__send-shortcut { display: none; } + +.consoles-card .pf-v5-c-empty-state__icon { + color: var(--pf-v5-global--custom-color--200); +} diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx index 1827c4bb1..e0c5320ba 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -20,10 +20,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import cockpit from 'cockpit'; import { AccessConsoles } from "@patternfly/react-console"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { + EmptyState, EmptyStateHeader, EmptyStateBody, EmptyStateFooter, EmptyStateActions, EmptyStateIcon +} from "@patternfly/react-core/dist/esm/components/EmptyState"; +import { PendingIcon } from "@patternfly/react-icons"; + +import { useDialogs } from 'dialogs.jsx'; import SerialConsole from './serialConsole.jsx'; import Vnc from './vnc.jsx'; import DesktopConsole from './desktopConsole.jsx'; +import { AddEditVNCModal } from './vncAddEdit.jsx'; + import { domainCanConsole, domainDesktopConsole, @@ -34,11 +43,70 @@ import './consoles.css'; const _ = cockpit.gettext; -const VmNotRunning = () => { +const ConsoleEmptyState = ({ vm }) => { + const Dialogs = useDialogs(); + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); + const inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc'); + + function add_vnc() { + Dialogs.show(); + } + + function edit_vnc() { + Dialogs.show(); + } + + let icon = null; + let top_message = null; + let bot_message = _("Graphical console support not enabled"); + + if (serials.length == 0 && !inactive_vnc) { + bot_message = _("Console support not enabled"); + } else { + if (vm.state != "running") { + if (serials.length > 0 && !inactive_vnc) { + top_message = _("Start the virtual machine to access its serial console"); + } else { + top_message = _("Start the virtual machine to access its console"); + } + } else if (inactive_vnc) { + icon = ; + if (serials.length > 0) { + top_message = _("Restart this virtual machine to access its graphical console"); + } else { + top_message = _("Restart this virtual machine to access its console"); + } + } + } + return ( -
- {_("Please start the virtual machine to access its console.")} -
+ + + + {top_message} + + { !inactive_vnc + ? <> + + {bot_message} + + + + + + + + : + + + + + } + ); }; @@ -81,8 +149,9 @@ class Consoles extends React.Component { return 'SerialConsole'; } - // no console defined - return null; + // no console defined, but the VncConsole is always there and + // will instruct people how to enable it for real. + return 'VncConsole'; } onDesktopConsoleDownload (type) { @@ -116,7 +185,11 @@ class Consoles extends React.Component { const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc'); if (!domainCanConsole || !domainCanConsole(vm.state)) { - return (); + return ( +
+ +
+ ); } const onDesktopConsole = () => { // prefer spice over vnc @@ -134,14 +207,19 @@ class Consoles extends React.Component { connectionName={vm.connectionName} vmName={vm.name} spawnArgs={domainSerialConsoleCommand({ vm, alias: pty.alias })} />))} - {vnc && - } + { vnc + ? + :
+ +
+ } {(vnc || spice) && . + */ + +import cockpit from 'cockpit'; + +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Form, Modal, ModalVariant, + FormGroup, FormHelperText, HelperText, HelperTextItem, + InputGroup, TextInput, Button, +} from "@patternfly/react-core"; +import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons"; + +import { ModalError } from 'cockpit-components-inline-notification.jsx'; +import { DialogsContext } from 'dialogs.jsx'; +import { domainChangeVncSettings, domainAttachVnc, domainGet } from '../../../libvirtApi/domain.js'; +import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx'; + +const _ = cockpit.gettext; + +const VncBody = ({ idPrefix, onValueChanged, dialogValues, validationErrors }) => { + const [showPassword, setShowPassword] = useState(false); + + return ( + <> + + onValueChanged('vncPort', event.target.value)} /> + + + { validationErrors?.vncPort + ? {validationErrors?.vncPort} + : + {_("Leave empty to automatically assign a free port when the machine starts")} + + } + + + + + + onValueChanged('vncPassword', event.target.value)} /> + + + + + ); +}; + +function validateDialogValues(values) { + const res = { }; + + if (values.vncPort != "" && (!values.vncPort.match("^[0-9]+$") || Number(values.vncPort) < 5900)) + res.vncPort = _("Port must be 5900 or larger."); + + return Object.keys(res).length > 0 ? res : null; +} + +VncBody.propTypes = { + idPrefix: PropTypes.string.isRequired, + onValueChanged: PropTypes.func.isRequired, + dialogValues: PropTypes.object.isRequired, + validationErrors: PropTypes.object, +}; + +export class AddEditVNCModal extends React.Component { + static contextType = DialogsContext; + + constructor(props) { + super(props); + + this.state = { + dialogError: undefined, + vncPort: Number(props.consoleDetail?.port) == -1 ? "" : props.consoleDetail?.port || "", + vncPassword: props.consoleDetail?.password || "", + validationErrors: null, + }; + + this.save = this.save.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + this.dialogErrorSet = this.dialogErrorSet.bind(this); + } + + onValueChanged(key, value) { + const stateDelta = { [key]: value, validationErrors: null }; + this.setState(stateDelta); + } + + dialogErrorSet(text, detail) { + this.setState({ dialogError: text, dialogErrorDetail: detail }); + } + + save() { + const Dialogs = this.context; + const { vm } = this.props; + + const errors = validateDialogValues(this.state); + if (errors) { + this.setState({ validationErrors: errors }); + return; + } + + const vncParams = { + listen: this.props.consoleDetail?.address || "", + port: this.state.vncPort || "", + password: this.state.vncPassword || "", + }; + + (this.props.consoleDetail ? domainChangeVncSettings(vm, vncParams) : domainAttachVnc(vm, vncParams)) + .then(() => { + domainGet({ connectionName: vm.connectionName, id: vm.id }); + Dialogs.close(); + }) + .catch((exc) => { + this.dialogErrorSet(_("VNC settings could not be saved"), exc.message); + }); + } + + render() { + const Dialogs = this.context; + const { idPrefix, vm } = this.props; + + const defaultBody = ( +
e.preventDefault()} isHorizontal> + + + ); + + return ( + + + + + }> + <> + { vm.state === 'running' && !this.state.dialogError && } + {this.state.dialogError && } + {defaultBody} + + + ); + } +} + +AddEditVNCModal.propTypes = { + idPrefix: PropTypes.string.isRequired, + vm: PropTypes.object.isRequired, + consoleDetail: PropTypes.object, +}; diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index 98c38a335..a8e36465a 100644 --- a/src/components/vm/vmDetailsPage.jsx +++ b/src/components/vm/vmDetailsPage.jsx @@ -132,24 +132,22 @@ export const VmDetailsPage = ({ title: _("Usage"), body: , }, - ...(vm.displays.length - ? [{ - id: `${vmId(vm.name)}-consoles`, - className: "consoles-card", - title: _("Console"), - actions: vm.state != "shut off" - ? - : null, - body: , - }] - : []), + { + id: `${vmId(vm.name)}-consoles`, + className: "consoles-card", + title: _("Console"), + actions: vm.state != "shut off" + ? + : null, + body: , + }, { id: `${vmId(vm.name)}-disks`, className: "disks-card", diff --git a/src/libvirtApi/domain.js b/src/libvirtApi/domain.js index eab234179..ac8b13f80 100644 --- a/src/libvirtApi/domain.js +++ b/src/libvirtApi/domain.js @@ -893,7 +893,7 @@ function shlex_quote(str) { return "'" + str.replaceAll("'", "'\"'\"'") + "'"; } -async function domainSetXML(vm, option, values) { +async function domainModifyXML(vm, action, option, type, values) { const opts = { err: "message" }; if (vm.connectionName === 'system') opts.superuser = 'try'; @@ -903,12 +903,16 @@ async function domainSetXML(vm, option, values) { // we need to do the equivalent of shlex.quote here. const args = []; + if (type) + args.push(shlex_quote(type)); for (const key in values) args.push(shlex_quote(key + '=' + values[key])); - await cockpit.spawn([ - 'virt-xml', '-c', `qemu:///${vm.connectionName}`, '--' + option, args.join(','), vm.uuid, '--edit' - ], opts); + const cmd = [ + 'virt-xml', '-c', `qemu:///${vm.connectionName}`, '--' + option, args.join(','), vm.uuid, '--' + action, + ]; + + await cockpit.spawn(cmd, opts); } export async function domainSetDescription(vm, description) { @@ -917,7 +921,7 @@ export async function domainSetDescription(vm, description) { // protocol error. So let's limit it to a reasonable length here. if (description.length > 32000) description = description.slice(0, 32000); - await domainSetXML(vm, "metadata", { description }); + await domainModifyXML(vm, "edit", "metadata", null, { description }); } export function domainSetCpuMode({ @@ -1089,3 +1093,11 @@ export async function domainAddTPM({ connectionName, vmName }) { const args = ["virt-xml", "-c", `qemu:///${connectionName}`, "--add-device", "--tpm", "default", vmName]; return cockpit.spawn(args, { err: "message", superuser: connectionName === "system" ? "try" : null }); } + +export async function domainAttachVnc(vm, values) { + await domainModifyXML(vm, "add-device", "graphics", "vnc", values); +} + +export async function domainChangeVncSettings(vm, values) { + await domainModifyXML(vm, "edit", "graphics", "vnc", values); +} diff --git a/test/check-machines-consoles b/test/check-machines-consoles index 4d489b1a6..0cb2d8e00 100755 --- a/test/check-machines-consoles +++ b/test/check-machines-consoles @@ -19,6 +19,7 @@ import os import time +import xml.etree.ElementTree as ET import machineslib import testlib @@ -246,7 +247,7 @@ fullscreen=0 # Test message is present if VM is not running self.performAction(name, "forceOff", checkExpectedState=False) - b.wait_in_text("#vm-not-running-message", "start the virtual machine") + b.wait_in_text("#vm-not-running-message", "Start the virtual machine") # Test deleting VM from console page will not trigger any error self.performAction(name, "delete") @@ -330,6 +331,124 @@ fullscreen=0 self.waitViewerDownload("vnc", my_ip) + def testAddEditVNC(self, withSerial=True): + b = self.browser + + # Create a machine without any consoles + + name = "subVmTest1" + self.createVm(name, ptyconsole=withSerial) + + self.login_and_go("/machines") + self.waitPageInit() + self.waitVmRow(name) + self.goToVmPage(name) + + def assert_state(text): + b.wait_in_text(f"#vm-{name}-consoles .pf-v5-c-empty-state", text) + + def assert_not_state(text): + b.wait_not_in_text(f"#vm-{name}-consoles .pf-v5-c-empty-state", text) + + if withSerial: + b.click("#pf-v5-c-console__type-selector") + b.wait_visible("#pf-v5-c-console__type-selector + .pf-v5-c-select__menu") + b.click("#VncConsole button") + b.wait_not_present("#pf-v5-c-console__type-selector + .pf-v5-c-select__menu") + + # "Console" card shows empty state + + if withSerial: + assert_state("Graphical console support not enabled") + b.assert_pixels(f"#vm-{name}-consoles", "no-vnc") + else: + assert_state("Console support not enabled") + + b.click(f"#vm-{name}-consoles .pf-v5-c-empty-state button:contains(Add VNC)") + + b.wait_visible("#add-vnc-dialog") + b.set_input_text("#add-vnc-port", "5000") + b.click("#add-vnc-save") + b.wait_visible("#add-vnc-dialog .pf-m-error:contains('Port must be 5900 or larger.')") + b.set_input_text("#add-vnc-port", "Hamburg") + b.wait_not_present("#add-vnc-dialog .pf-m-error") + b.click("#add-vnc-save") + b.wait_visible("#add-vnc-dialog .pf-m-error:contains('Port must be 5900 or larger.')") + if withSerial: + b.assert_pixels("#add-vnc-dialog", "add") + b.set_input_text("#add-vnc-port", "100000000000") # for testing failed libvirt calls + b.set_input_text("#add-vnc-password", "foobar") + b.wait_attr("#add-vnc-password", "type", "password") + b.click("#add-vnc-dialog .pf-v5-c-input-group button") + b.wait_attr("#add-vnc-password", "type", "text") + b.click("#add-vnc-save") + b.wait_in_text("#add-vnc-dialog", "VNC settings could not be saved") + b.wait_in_text("#add-vnc-dialog", "cannot parse vnc port 100000000000") + b.set_input_text("#add-vnc-port", "5901") + b.click("#add-vnc-save") + b.wait_not_present("#add-vnc-dialog") + + if withSerial: + assert_state("Restart this virtual machine to access its graphical console") + b.wait_visible(f"#vm-{name}-needs-shutdown") + b.assert_pixels(f"#vm-{name}-consoles", "needs-shutdown") + else: + assert_state("Restart this virtual machine to access its console") + + root = ET.fromstring(self.machine.execute(f"virsh dumpxml --inactive --security-info {name}")) + graphics = root.find('devices').findall('graphics') + self.assertEqual(len(graphics), 1) + self.assertEqual(graphics[0].get('port'), "5901") + self.assertEqual(graphics[0].get('passwd'), "foobar") + + b.click(f"#vm-{name}-consoles .pf-v5-c-empty-state button:contains(VNC settings)") + b.wait_visible("#edit-vnc-dialog") + b.wait_val("#edit-vnc-port", "5901") + b.wait_val("#edit-vnc-password", "foobar") + if withSerial: + b.assert_pixels("#edit-vnc-dialog", "edit") + b.set_input_text("#edit-vnc-port", "") + b.click("#edit-vnc-save") + b.wait_not_present("#edit-vnc-dialog") + + root = ET.fromstring(self.machine.execute(f"virsh dumpxml --inactive --security-info {name}")) + graphics = root.find('devices').findall('graphics') + self.assertEqual(len(graphics), 1) + self.assertEqual(graphics[0].get('port'), "-1") + self.assertEqual(graphics[0].get('passwd'), "foobar") + + # Shut down machine + + self.performAction("subVmTest1", "forceOff") + assert_state("Start the virtual machine to access its console") + if withSerial: + b.assert_pixels(f"#vm-{name}-consoles", "shutoff") + + # Remove VNC from the outside and do the whole dance again + + self.machine.execute(f"virt-xml --remove-device --graphics vnc {name}") + + if withSerial: + assert_state("Start the virtual machine to access its serial console") + assert_state("Graphical console support not enabled") + b.assert_pixels(f"#vm-{name}-consoles", "shutoff-no-vnc") + else: + assert_state("Console support not enabled") + + b.click("#vm-not-running-message button:contains(Add VNC)") + b.wait_visible("#add-vnc-dialog") + b.click("#add-vnc-save") + b.wait_not_present("#add-vnc-dialog") + + if withSerial: + assert_not_state("Graphical console support not enabled") + else: + assert_not_state("Console support not enabled") + assert_state("Start the virtual machine to access its console") + + def testAddEditVNCNoSerial(self): + self.testAddEditVNC(withSerial=False) + if __name__ == '__main__': testlib.test_main() diff --git a/test/reference b/test/reference index fef884db2..431b8aac0 160000 --- a/test/reference +++ b/test/reference @@ -1 +1 @@ -Subproject commit fef884db2f402038be5b2f37e0df9d45a7aca4c2 +Subproject commit 431b8aac005bfc991590e5b2f78aa0f7592dcfc3 From 6d06b42148765b4b7f6409fc45685a0c09c35035 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Mon, 10 Feb 2025 14:57:06 +0200 Subject: [PATCH 2/4] consoles: Collect all console things in a dedicated ConsoleCard Things will get more much more complicated. --- src/components/vm/consoles/consoles.jsx | 38 ++++++++++++++++++++++++- src/components/vm/vmDetailsPage.jsx | 23 ++++++--------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx index e0c5320ba..d0956d6af 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -21,10 +21,11 @@ import PropTypes from 'prop-types'; import cockpit from 'cockpit'; import { AccessConsoles } from "@patternfly/react-console"; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card'; import { EmptyState, EmptyStateHeader, EmptyStateBody, EmptyStateFooter, EmptyStateActions, EmptyStateIcon } from "@patternfly/react-core/dist/esm/components/EmptyState"; -import { PendingIcon } from "@patternfly/react-icons"; +import { PendingIcon, ExpandIcon, HelpIcon } from "@patternfly/react-icons"; import { useDialogs } from 'dialogs.jsx'; @@ -38,6 +39,7 @@ import { domainDesktopConsole, domainSerialConsoleCommand } from '../../../libvirtApi/domain.js'; +import { vmId } from "../../../helpers.js"; import './consoles.css'; @@ -229,9 +231,43 @@ class Consoles extends React.Component { ); } } + Consoles.propTypes = { vm: PropTypes.object.isRequired, onAddErrorNotification: PropTypes.func.isRequired, }; export default Consoles; + +export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => { + let actions = null; + if (vm.state != "shut off") { + actions = ( + + ); + } + + return ( + + + {_("Console")} + + + + + + + ); +}; diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index a8e36465a..ae11977e0 100644 --- a/src/components/vm/vmDetailsPage.jsx +++ b/src/components/vm/vmDetailsPage.jsx @@ -37,7 +37,7 @@ import { VmFilesystemsCard, VmFilesystemActions } from './filesystems/vmFilesyst import { VmDisksCardLibvirt, VmDisksActions } from './disks/vmDisksCard.jsx'; import { VmNetworkTab, VmNetworkActions } from './nics/vmNicsCard.jsx'; import { VmHostDevCard, VmHostDevActions } from './hostdevs/hostDevCard.jsx'; -import Consoles from './consoles/consoles.jsx'; +import Consoles, { ConsoleCard } from './consoles/consoles.jsx'; import VmOverviewCard from './overview/vmOverviewCard.jsx'; import VmUsageTab from './vmUsageCard.jsx'; import { VmSnapshotsCard, VmSnapshotsActions } from './snapshots/vmSnapshotsCard.jsx'; @@ -133,20 +133,11 @@ export const VmDetailsPage = ({ body: , }, { - id: `${vmId(vm.name)}-consoles`, - className: "consoles-card", - title: _("Console"), - actions: vm.state != "shut off" - ? - : null, - body: , + card: }, { id: `${vmId(vm.name)}-disks`, @@ -224,6 +215,8 @@ export const VmDetailsPage = ({ } const cards = cardContents.map(card => { + if (card.card) + return card.card; return ( Date: Thu, 30 Jan 2025 13:14:52 +0200 Subject: [PATCH 3/4] consoles: Mention that replacing spice is good for enabling inline viewer We want to invoke that dialog also from the "Console" card, where it is used to enable the inline graphical console viewer. --- src/components/vm/vmReplaceSpiceDialog.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/vm/vmReplaceSpiceDialog.jsx b/src/components/vm/vmReplaceSpiceDialog.jsx index 836b1a659..c5a3d9822 100644 --- a/src/components/vm/vmReplaceSpiceDialog.jsx +++ b/src/components/vm/vmReplaceSpiceDialog.jsx @@ -165,6 +165,9 @@ export const ReplaceSpiceDialog = ({ vm, vms }) => { {_("This is intended for a host which does not support SPICE due to upgrades or live migration.")} + + {_("It can also be used to enable the inline graphical console in the browser, which does not support SPICE.")} + { vmSelect } From f65bf5f5c3538f7d8417dd58a56b3898baa864ad Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Mon, 10 Feb 2025 15:33:58 +0200 Subject: [PATCH 4/4] consoles: Redesign and reimplement - A ToggleGroup in the Card header is used to switch consoles - The DesktopViewer is gone, but there is a footer with a "Launch viewer" button and a "How to connect" popup. - Actions for the Graphics console are collected into a kebab menu. - The expanded console has less UI around it, and it keeps the type that was active in the collapsed view. - ... --- src/components/vm/consoles/consoles.css | 12 +- src/components/vm/consoles/consoles.jsx | 334 ++++++++++--------- src/components/vm/consoles/serialConsole.jsx | 19 +- src/components/vm/consoles/vnc.jsx | 235 ++++++++++--- src/components/vm/vmDetailsPage.jsx | 34 +- 5 files changed, 394 insertions(+), 240 deletions(-) diff --git a/src/components/vm/consoles/consoles.css b/src/components/vm/consoles/consoles.css index ec4312605..3a92d458a 100644 --- a/src/components/vm/consoles/consoles.css +++ b/src/components/vm/consoles/consoles.css @@ -18,17 +18,25 @@ grid-template-rows: min-content 1fr; } +.vm-console-footer { + grid-area: 3 / 1 / 4 / 3; +} + .consoles-page-expanded .actions-pagesection .pf-v5-c-page__main-body { padding-block-end: 0; } -/* Hide send key button - there is not way to do that from the JS +/* Hide standard VNC actions - there is not way to do that from the JS * https://github.com/patternfly/patternfly-react/issues/3689 */ -#pf-v5-c-console__send-shortcut { +.pf-v5-c-console__actions-vnc { display: none; } .consoles-card .pf-v5-c-empty-state__icon { color: var(--pf-v5-global--custom-color--200); } + +.ct-remote-viewer-popover { + max-inline-size: 60ch; +} diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx index d0956d6af..a686595d4 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -25,7 +25,8 @@ import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@patternfly/r import { EmptyState, EmptyStateHeader, EmptyStateBody, EmptyStateFooter, EmptyStateActions, EmptyStateIcon } from "@patternfly/react-core/dist/esm/components/EmptyState"; -import { PendingIcon, ExpandIcon, HelpIcon } from "@patternfly/react-icons"; +import { PendingIcon, ExpandIcon, CompressIcon, HelpIcon } from "@patternfly/react-icons"; +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup'; import { useDialogs } from 'dialogs.jsx'; @@ -45,11 +46,20 @@ import './consoles.css'; const _ = cockpit.gettext; -const ConsoleEmptyState = ({ vm }) => { +export const ConsoleEmptyState = ({ vm, type }) => { const Dialogs = useDialogs(); - const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); const inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc'); + if (type != "vnc") { + return ( + + + {_("Start the virtual machine to access this console")} + + + ); + } + function add_vnc() { Dialogs.show(); } @@ -58,192 +68,167 @@ const ConsoleEmptyState = ({ vm }) => { Dialogs.show(); } - let icon = null; - let top_message = null; - let bot_message = _("Graphical console support not enabled"); - - if (serials.length == 0 && !inactive_vnc) { - bot_message = _("Console support not enabled"); - } else { - if (vm.state != "running") { - if (serials.length > 0 && !inactive_vnc) { - top_message = _("Start the virtual machine to access its serial console"); - } else { - top_message = _("Start the virtual machine to access its console"); - } - } else if (inactive_vnc) { - icon = ; - if (serials.length > 0) { - top_message = _("Restart this virtual machine to access its graphical console"); - } else { - top_message = _("Restart this virtual machine to access its console"); - } - } - } - - return ( - - - - {top_message} - - { !inactive_vnc - ? <> + if (inactive_vnc) { + if (vm.state == "running") { + // Running and VNC defined? Changes are pending. + return ( + + } /> - {bot_message} + {_("Restart this virtual machine to access its graphical console")} - - - : + + ); + } else { + // Not running and VNC defined? Just start it. + return ( + + + {_("Start the virtual machine to access this console")} + + + ); + } + } else { + // No VNC defined? Add it. + return ( + + + {_("Graphical support not enabled")} + + - - } - - ); -}; - -class Consoles extends React.Component { - constructor (props) { - super(props); - - this.state = { - serial: props.vm.displays && props.vm.displays.filter(display => display.type == 'pty'), - }; - - this.getDefaultConsole = this.getDefaultConsole.bind(this); - this.onDesktopConsoleDownload = this.onDesktopConsoleDownload.bind(this); + + ); } +}; - static getDerivedStateFromProps(nextProps, prevState) { - const oldSerial = prevState.serial; - const newSerial = nextProps.vm.displays && nextProps.vm.displays.filter(display => display.type == 'pty'); +export function console_default(vm) { + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); + const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc'); - if (newSerial.length !== oldSerial.length || oldSerial.some((pty, index) => pty.alias !== newSerial[index].alias)) - return { serial: newSerial }; + if (vnc || serials.length == 0) + return "vnc"; + else + return "serial0"; +} - return null; +export function console_name(vm, type) { + if (!type) + type = console_default(vm); + + if (type.startsWith("serial")) { + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); + if (serials.length == 1) + return _("Serial console"); + const idx = Number(type.substr(6)); + return cockpit.format(_("Serial console ($0)"), serials[idx]?.alias || idx); + } else if (type == "vnc") { + return _("Graphical console"); + } else { + return _("Console"); } +} - getDefaultConsole () { - const { vm } = this.props; - - if (vm.displays) { - if (vm.displays.find(display => display.type == "vnc")) { - return 'VncConsole'; - } - if (vm.displays.find(display => display.type == "spice")) { - return 'DesktopViewer'; - } +function connection_address() { + let address; + if (cockpit.transport.host == "localhost") { + const app = cockpit.transport.application(); + if (app.startsWith("cockpit+=")) { + address = app.substr(9); + } else { + address = window.location.hostname; } - - const serialConsoleCommand = domainSerialConsoleCommand({ vm }); - if (serialConsoleCommand) { - return 'SerialConsole'; + } else { + address = cockpit.transport.host; + const pos = address.indexOf("@"); + if (pos >= 0) { + address = address.substr(pos + 1); } - - // no console defined, but the VncConsole is always there and - // will instruct people how to enable it for real. - return 'VncConsole'; } + return address; +} - onDesktopConsoleDownload (type) { - const { vm } = this.props; - // fire download of the .vv file - const consoleDetail = vm.displays.find(display => display.type == type); +function console_launch(vm, consoleDetail) { + // fire download of the .vv file + domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address: connection_address() } }); +} - let address; - if (cockpit.transport.host == "localhost") { - const app = cockpit.transport.application(); - if (app.startsWith("cockpit+=")) { - address = app.substr(9); - } else { - address = window.location.hostname; - } - } else { - address = cockpit.transport.host; - const pos = address.indexOf("@"); - if (pos >= 0) { - address = address.substr(pos + 1); - } - } +export const Console = ({ vm, config, type, onAddErrorNotification, isExpanded }) => { + let con = null; + + if (!type) + type = console_default(vm); - domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address } }); + if (vm.state != "running") { + return ( +
+ +
+ ); } - render () { - const { vm, onAddErrorNotification, isExpanded } = this.props; - const { serial } = this.state; - const spice = vm.displays && vm.displays.find(display => display.type == 'spice'); + if (type.startsWith("serial")) { + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); + const idx = Number(type.substr(6)); + if (serials.length > idx) + con = ; + } else if (type == "vnc") { const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc'); + const inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc'); + const spice = vm.displays && vm.displays.find(display => display.type == 'spice'); - if (!domainCanConsole || !domainCanConsole(vm.state)) { - return ( -
- -
- ); - } - - const onDesktopConsole = () => { // prefer spice over vnc - this.onDesktopConsoleDownload(spice ? 'spice' : 'vnc'); - }; + con = console_launch(vm, vnc || spice)} + connectionAddress={connection_address()} + isExpanded={isExpanded} />; + } + if (con) { return ( - - {serial.map((pty, idx) => ())} - { vnc - ? - :
- -
- } - {(vnc || spice) && - } -
+
+ {con} +
); } -} - -Consoles.propTypes = { - vm: PropTypes.object.isRequired, - onAddErrorNotification: PropTypes.func.isRequired, }; -export default Consoles; +export const ConsoleCard = ({ vm, config, type, setType, onAddErrorNotification, isExpanded }) => { + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); + + if (!type) + type = console_default(vm); -export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => { - let actions = null; - if (vm.state != "shut off") { - actions = ( + const actions = []; + const tabs = []; + let body; + + if (!isExpanded) { + actions.push( ); + } else { + actions.push( + + ); } + if (serials.length > 0) + tabs.push( setType("vnc")} />); + + serials.forEach((pty, idx) => { + const t = "serial" + idx; + tabs.push( setType(t)} />); + }) + + body = + return ( { isSelectable isClickable> - {_("Console")} + {isExpanded ? vm.name : _("Console")} + {tabs} - + {body} diff --git a/src/components/vm/consoles/serialConsole.jsx b/src/components/vm/consoles/serialConsole.jsx index 6846193c3..e99789271 100644 --- a/src/components/vm/consoles/serialConsole.jsx +++ b/src/components/vm/consoles/serialConsole.jsx @@ -21,6 +21,7 @@ import PropTypes from 'prop-types'; import cockpit from 'cockpit'; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; import { Terminal } from "cockpit-components-terminal.jsx"; const _ = cockpit.gettext; @@ -89,15 +90,21 @@ class SerialConsoleCockpit extends React.Component { return ( <> -
- {this.state.channel - ? - : - } -
{t}
+
+ + + + + {this.state.channel + ? + : + } + + +
); } diff --git a/src/components/vm/consoles/vnc.jsx b/src/components/vm/consoles/vnc.jsx index 34a5c0cb5..be7d73b28 100644 --- a/src/components/vm/consoles/vnc.jsx +++ b/src/components/vm/consoles/vnc.jsx @@ -20,12 +20,27 @@ import React from 'react'; import cockpit from 'cockpit'; import { VncConsole } from '@patternfly/react-console'; +import { Popover } from "@patternfly/react-core/dist/esm/components/Popover"; import { Dropdown, DropdownItem, DropdownList } from "@patternfly/react-core/dist/esm/components/Dropdown"; import { MenuToggle } from "@patternfly/react-core/dist/esm/components/MenuToggle"; import { Divider } from "@patternfly/react-core/dist/esm/components/Divider"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; + +import { EmptyState, EmptyStateBody, EmptyStateFooter } from "@patternfly/react-core/dist/esm/components/EmptyState"; +import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; +import { DescriptionList, DescriptionListTerm, DescriptionListGroup, DescriptionListDescription } from "@patternfly/react-core/dist/esm/components/DescriptionList"; +import { Text, TextContent, TextVariants, TextList, TextListItem, TextListItemVariants, TextListVariants } from "@patternfly/react-core/dist/esm/components/Text"; +import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js"; + +import { useDialogs, DialogsContext } from 'dialogs.jsx'; +import { KebabDropdown } from 'cockpit-components-dropdown.jsx'; +import { fmt_to_fragments } from 'utils.jsx'; import { logDebug } from '../../../helpers.js'; import { domainSendKey } from '../../../libvirtApi/domain.js'; +import { AddEditVNCModal } from './vncAddEdit'; +import { ReplaceSpiceDialog } from '../vmReplaceSpiceDialog.jsx'; +import { ConsoleEmptyState } from './consoles'; const _ = cockpit.gettext; // https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h @@ -48,12 +63,69 @@ const Enum = { KEY_DELETE: 111, }; +const VncConnectInfo = ({ vm, connection_address, vnc, spice }) => { + function TLI(term, description) { + // What is this? Java? + return ( + <> + {term} + {description} + + ); + } + + return ( + <> + + + {fmt_to_fragments(_('Clicking "Launch viewer" will download a $0 file and launch the Remote Viewer application on your system.'), .vv)} + + + {_('Remote Viewer is available for most operating systems. To install it, search for "Remote Viewer" in GNOME Software, KDE Discover, or run the following:')} + + + {TLI(_("RHEL, CentOS"), sudo yum install virt-viewer)} + {TLI(_("Fedora"), sudo dnf install virt-viewer)} + {TLI(_("Ubuntu, Debian"), sudo apt-get install virt-viewer)} + {TLI(_("Windows"), + fmt_to_fragments( + _("Download the MSI from $0"), + + {"virt-manager.org"} + ))} + + { (vnc || spice) && + <> + + {_('Other remote viewer applications can connect to the following address:')} + + + + {vnc + ? cockpit.format("vnc://$0:$1", connection_address, vnc.port) + : cockpit.format("spice://$0:$1", connection_address, spice.port) + } + + + + } + + + ); +}; + class Vnc extends React.Component { + static contextType = DialogsContext; + constructor(props) { super(props); this.state = { path: undefined, - isActionOpen: false, + connected: true, }; this.credentials = null; @@ -61,7 +133,6 @@ class Vnc extends React.Component { this.connect = this.connect.bind(this); this.onDisconnected = this.onDisconnected.bind(this); this.onInitFailed = this.onInitFailed.bind(this); - this.onExtraKeysDropdownToggle = this.onExtraKeysDropdownToggle.bind(this); } connect(props) { @@ -106,31 +177,25 @@ class Vnc extends React.Component { onDisconnected(detail) { // server disconnected console.info('Connection lost: ', detail); + this.setState({ connected: false }); } onInitFailed(detail) { console.error('VncConsole failed to init: ', detail, this); } - onExtraKeysDropdownToggle() { - this.setState({ isActionOpen: false }); - } - render() { - const { consoleDetail, connectionName, vmName, vmId, onAddErrorNotification, isExpanded } = this.props; - const { path, isActionOpen } = this.state; - if (!consoleDetail || !path) { - // postpone rendering until consoleDetail is known and channel ready - return null; - } + const Dialogs = this.context; + const { + consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded, connectionAddress, + spiceDetail, + } = this.props; + const { path, connected } = this.state; - // We must pass the very same object to VncConsole.credentials - // on every render. Otherwise VncConsole thinks credentials - // have changed and will reconnect. - if (!this.credentials || this.credentials.password != consoleDetail.password) - this.credentials = { password: consoleDetail.password }; + function edit_vnc() { + Dialogs.show(); + } - const encrypt = this.getEncrypt(); const renderDropdownItem = keyName => { return ( renderDropdownItem(key)), , ...[...Array(12).keys()].map(key => renderDropdownItem(cockpit.format("F$0", key + 1))), + , + + {_("Edit VNC server settings")} + , + this.setState({ connected: false })}> + {_("Disconnect")} + , ]; - const additionalButtons = [ - ( - this.setState({ isActionOpen: !isActionOpen })}> - {_("Send key")} - - )} - isOpen={isActionOpen} - > - - {dropdownItems} - - - ]; + + const detail = ( + + + + }> + + + + + { (connected && consoleDetail) && + + } + + + ); + + if (!consoleDetail) { + return ( + <> +
+ +
+ { spiceDetail &&
{detail}
} + + ); + } + + if (!path) { + // postpone rendering until consoleDetail is known and channel ready + return null; + } + + // We must pass the very same object to VncConsole.credentials + // on every render. Otherwise VncConsole thinks credentials + // have changed and will reconnect. + if (!this.credentials || this.credentials.password != consoleDetail.password) + this.credentials = { password: consoleDetail.password }; + + const encrypt = this.getEncrypt(); return ( - + <> + { connected + ? + :
+ + {_("Disconnected")} + + + + +
+ } +
{detail}
+ ); } } -// TODO: define propTypes - export default Vnc; diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index ae11977e0..5d99e5e13 100644 --- a/src/components/vm/vmDetailsPage.jsx +++ b/src/components/vm/vmDetailsPage.jsx @@ -17,7 +17,7 @@ * along with Cockpit; If not, see . */ import PropTypes from 'prop-types'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import cockpit from 'cockpit'; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb"; @@ -37,7 +37,7 @@ import { VmFilesystemsCard, VmFilesystemActions } from './filesystems/vmFilesyst import { VmDisksCardLibvirt, VmDisksActions } from './disks/vmDisksCard.jsx'; import { VmNetworkTab, VmNetworkActions } from './nics/vmNicsCard.jsx'; import { VmHostDevCard, VmHostDevActions } from './hostdevs/hostDevCard.jsx'; -import Consoles, { ConsoleCard } from './consoles/consoles.jsx'; +import { ConsoleCard } from './consoles/consoles.jsx'; import VmOverviewCard from './overview/vmOverviewCard.jsx'; import VmUsageTab from './vmUsageCard.jsx'; import { VmSnapshotsCard, VmSnapshotsActions } from './snapshots/vmSnapshotsCard.jsx'; @@ -64,6 +64,8 @@ export const VmDetailsPage = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const [consoleType, setConsoleType] = useState(null); + const vmActionsPageSection = (
@@ -89,25 +91,13 @@ export const VmDetailsPage = ({ - - - - {_("Virtual machines")} - - - {vm.name} - - - {_("Console")} - - - - {vmActionsPageSection} - - - + ); @@ -137,6 +127,8 @@ export const VmDetailsPage = ({ key={`${vmId(vm.name)}-consoles`} vm={vm} config={config} + type={consoleType} + setType={setConsoleType} onAddErrorNotification={onAddErrorNotification} /> }, {