From 8c98db2dc832faad328fbc2a3f725d9bee73df64 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Fri, 11 Apr 2025 12:23:56 +0300 Subject: [PATCH] consoles: Redesign and reimplement - A ToggleGroup in the Card header is used to switch consoles - The console switcher is also there for shut-off machines. This gives us a place for type-specific actions that also make sense for a shut-off machine, like editing VNC server settings. - The DesktopViewer is gone, but there is a footer with a "Launch viewer" button and a "How to connect" popover. - Actions for the Graphics and Serial consoles are collected into kebab menus. - The expanded console has less UI around it, and it keeps the type that was active in the collapsed view. Instead of the breadcrumb it has a "Collapse" button. - When there is no actual console for a given type, there is now a EmptyState component where you can enable it. - It is possible to change VNC server settings via the "How to connect" popup. - The SPICE console invites you to replace it with VNC. - The size of the expanded console is now always controlled by the browser window and never overflows in height. - The VncConsole has been imported from @patternfly/react-console and stripped down. --- node_modules | 2 +- package.json | 1 - src/components/common/needsShutdown.jsx | 51 ++- src/components/vm/consoles/VncConsole.jsx | 153 +++++++ src/components/vm/consoles/common.jsx | 127 ++++++ src/components/vm/consoles/consoles.css | 85 ++-- src/components/vm/consoles/consoles.jsx | 316 ++++++++----- src/components/vm/consoles/desktopConsole.jsx | 68 --- src/components/vm/consoles/serial.jsx | 213 +++++++++ src/components/vm/consoles/spice.jsx | 94 ++++ src/components/vm/consoles/state.jsx | 95 ++++ src/components/vm/consoles/vnc.jsx | 428 +++++++++++++++--- src/components/vm/vmDetailsPage.jsx | 60 +-- src/components/vm/vmReplaceSpiceDialog.jsx | 39 +- src/libvirtApi/domain.js | 26 +- test/check-machines-consoles | 325 ++++++++++--- test/check-machines-create | 22 +- test/check-machines-multi-host-consoles | 2 +- 18 files changed, 1679 insertions(+), 428 deletions(-) create mode 100644 src/components/vm/consoles/VncConsole.jsx create mode 100644 src/components/vm/consoles/common.jsx delete mode 100644 src/components/vm/consoles/desktopConsole.jsx create mode 100644 src/components/vm/consoles/serial.jsx create mode 100644 src/components/vm/consoles/spice.jsx create mode 100644 src/components/vm/consoles/state.jsx diff --git a/node_modules b/node_modules index 6af447b65..9b63a35e4 160000 --- a/node_modules +++ b/node_modules @@ -1 +1 @@ -Subproject commit 6af447b6582e9e7721ab714a56222c799f6e3329 +Subproject commit 9b63a35e406cbd6731b8acbee9bf95342e8ff628 diff --git a/package.json b/package.json index b2a3f37c1..15dba6234 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ }, "dependencies": { "@patternfly/patternfly": "6.1.0", - "@patternfly/react-console": "6.0.0", "@patternfly/react-core": "6.1.0", "@patternfly/react-icons": "6.1.0", "@patternfly/react-styles": "6.1.0", diff --git a/src/components/common/needsShutdown.jsx b/src/components/common/needsShutdown.jsx index a016e1cea..10bd625bf 100644 --- a/src/components/common/needsShutdown.jsx +++ b/src/components/common/needsShutdown.jsx @@ -83,6 +83,47 @@ 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 needsShutdownSerialConsole(vm) { + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); + const inactive_serials = vm.inactiveXML.displays && vm.inactiveXML.displays.filter(display => display.type == 'pty'); + return serials.length != inactive_serials.length; +} + export function getDevicesRequiringShutdown(vm) { if (!vm.persistent) return []; @@ -123,6 +164,14 @@ export function getDevicesRequiringShutdown(vm) { if (needsShutdownSpice(vm)) devices.push(_("SPICE")); + // VNC + if (needsShutdownVnc(vm)) + devices.push(_("VNC")); + + // Serial console + if (needsShutdownSerialConsole(vm)) + devices.push(_("Text console")); + // TPM if (needsShutdownTpm(vm)) devices.push(_("TPM")); @@ -172,7 +221,7 @@ export const VmNeedsShutdown = ({ vm }) => { position="bottom" hasAutoWidth bodyContent={body}> - diff --git a/src/components/vm/consoles/VncConsole.jsx b/src/components/vm/consoles/VncConsole.jsx new file mode 100644 index 000000000..e840ef526 --- /dev/null +++ b/src/components/vm/consoles/VncConsole.jsx @@ -0,0 +1,153 @@ +/* + +MIT License + +Copyright (c) 2025 Red Hat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + */ + +import React from 'react'; + +import { initLogging } from '@novnc/novnc/lib/util/logging'; +import RFB_module from '@novnc/novnc/lib/rfb'; +const RFB = RFB_module.default; + +export const VncConsole = ({ + children, + host, + port = '80', + path = '', + encrypt = false, + resizeSession = true, + clipViewport = false, + dragViewport = false, + scaleViewport = false, + viewOnly = false, + shared = false, + credentials, + repeaterID = '', + vncLogging = 'warn', + consoleContainerId, + onDisconnected = () => {}, + onInitFailed, + onSecurityFailure, +}) => { + const rfb = React.useRef(); + + const novncElem = React.useRef(null); + + const onConnected = () => { + }; + + const _onDisconnected = React.useCallback( + (e) => { + onDisconnected(e); + }, + [onDisconnected] + ); + + const _onSecurityFailure = React.useCallback( + (e) => { + onSecurityFailure(e); + }, + [onSecurityFailure] + ); + + const addEventListeners = React.useCallback(() => { + if (rfb.current) { + rfb.current?.addEventListener('connect', onConnected); + rfb.current?.addEventListener('disconnect', _onDisconnected); + rfb.current?.addEventListener('securityfailure', _onSecurityFailure); + } + }, [rfb, _onDisconnected, _onSecurityFailure]); + + const removeEventListeners = React.useCallback(() => { + if (rfb.current) { + rfb.current.removeEventListener('connect', onConnected); + rfb.current.removeEventListener('disconnect', _onDisconnected); + rfb.current.removeEventListener('securityfailure', _onSecurityFailure); + } + }, [rfb, _onDisconnected, _onSecurityFailure]); + + const connect = React.useCallback(() => { + const protocol = encrypt ? 'wss' : 'ws'; + const url = `${protocol}://${host}:${port}/${path}`; + + const options = { + repeaterID, + shared, + credentials + }; + rfb.current = new RFB(novncElem.current, url, options); + addEventListeners(); + rfb.current.viewOnly = viewOnly; + rfb.current.clipViewport = clipViewport; + rfb.current.dragViewport = dragViewport; + rfb.current.scaleViewport = scaleViewport; + rfb.current.resizeSession = resizeSession; + }, [ + addEventListeners, + host, + path, + port, + resizeSession, + clipViewport, + dragViewport, + scaleViewport, + viewOnly, + encrypt, + rfb, + repeaterID, + shared, + credentials + ]); + + React.useEffect(() => { + initLogging(vncLogging); + try { + connect(); + } catch (e) { + onInitFailed && onInitFailed(e); + rfb.current = undefined; + } + + return () => { + disconnect(); + removeEventListeners(); + rfb.current = undefined; + }; + }, [connect, onInitFailed, removeEventListeners, vncLogging]); + + const disconnect = () => { + if (!rfb.current) { + return; + } + rfb.current.disconnect(); + }; + + return ( +
+
+
+ ); +}; + +VncConsole.displayName = 'VncConsole'; diff --git a/src/components/vm/consoles/common.jsx b/src/components/vm/consoles/common.jsx new file mode 100644 index 000000000..b6e71b3be --- /dev/null +++ b/src/components/vm/consoles/common.jsx @@ -0,0 +1,127 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2025 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import React from 'react'; +import cockpit from 'cockpit'; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content"; +import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js"; +import { Popover } from "@patternfly/react-core/dist/esm/components/Popover"; +import { HelpIcon } from "@patternfly/react-icons"; +import { fmt_to_fragments } from 'utils.jsx'; +import { domainDesktopConsole } from '../../../libvirtApi/domain.js'; + +const _ = cockpit.gettext; + +export 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; + } + } else { + address = cockpit.transport.host; + const pos = address.indexOf("@"); + if (pos >= 0) { + address = address.substr(pos + 1); + } + } + return address; +} + +export function console_launch(vm, consoleDetail) { + // fire download of the .vv file + domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address: connection_address() } }); +} + +const RemoteConnectionInfo = ({ hide, url, onEdit }) => { + function CDD(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:")} + + + {CDD(_("RHEL, CentOS"), sudo yum install virt-viewer)} + {CDD(_("Fedora"), sudo dnf install virt-viewer)} + {CDD(_("Ubuntu, Debian"), sudo apt-get install virt-viewer)} + {CDD(_("Windows"), + fmt_to_fragments( + _("Download the MSI from $0"), + + virt-manager.org + ))} + + { url && + <> + + {_("Other remote viewer applications can connect to the following address:")} + + + {url} + + + + } + { onEdit && + + } + + ); +}; + +export const RemoteConnectionPopover = ({ url, onEdit }) => { + return ( + + + } + > + + ); + } else { + actions.push( + + ); } - onDesktopConsoleDownload (type) { - const { vm } = this.props; - // fire download of the .vv file - const consoleDetail = vm.displays.find(display => display.type == type); + tabs.push( state.setType("graphical")} />); - let address; - if (cockpit.transport.host == "localhost") { - const app = cockpit.transport.application(); - if (app.startsWith("cockpit+=")) { - address = app.substr(9); + if (type == "graphical") { + if (vm.state != "running") { + if (!inactive_vnc && !inactive_spice) { + body = ; + } else if (inactive_vnc) { + body = ( + + ); } else { - address = window.location.hostname; + body = ; } } else { - address = cockpit.transport.host; - const pos = address.indexOf("@"); - if (pos >= 0) { - address = address.substr(pos + 1); + if (vnc) { + body = ( + + ); + body_actions = ( + + ); + } else if (inactive_vnc) { + body = ( + + ); + } else if (spice) { + body = ; + } else { + body = ; } } - - domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address } }); } - render () { - const { vm, onAddErrorNotification, isExpanded } = this.props; - const { serial } = this.state; - const spice = vm.displays && vm.displays.find(display => display.type == 'spice'); - const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc'); + if (serials.length > 0) { + serials.forEach((pty, idx) => { + const t = "text" + idx; + tabs.push( state.setType(t)} />); - if (!domainCanConsole || !domainCanConsole(vm.state)) { - return (); - } + if (type == t) { + if (vm.state != "running") { + body = ; + } else { + const serial_state = state.serialStates.get(pty.alias || idx); + body = ( + + ); + body_actions = ( + + ); + } + } + }); + } else { + tabs.push( state.setType("text0")} />); - const onDesktopConsole = () => { // prefer spice over vnc - this.onDesktopConsoleDownload(spice ? 'spice' : 'vnc'); - }; - - return ( - - {serial.map((pty, idx) => ())} - {vnc && - } - {(vnc || spice) && - } - - ); + if (type == "text0") { + if (inactive_serials.length > 0) { + body = ; + } else { + body = ; + } + } } -} -Consoles.propTypes = { - vm: PropTypes.object.isRequired, - onAddErrorNotification: PropTypes.func.isRequired, -}; -export default Consoles; + return ( + + + + + {isExpanded ? vm.name : _("Console")} + + + {tabs} + + + {body_actions} + + + + +
+ {body} +
+
+
+ ); +}; diff --git a/src/components/vm/consoles/desktopConsole.jsx b/src/components/vm/consoles/desktopConsole.jsx deleted file mode 100644 index 454551953..000000000 --- a/src/components/vm/consoles/desktopConsole.jsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 Red Hat, Inc. - * - * Cockpit is free software; you can redistribute it and/or modify it - * under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation; either version 2.1 of the License, or - * (at your option) any later version. - * - * Cockpit is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Cockpit; If not, see . - */ -import React from "react"; -import { DesktopViewer } from '@patternfly/react-console'; - -import cockpit from "cockpit"; - -const _ = cockpit.gettext; - -function fmt_to_fragments(fmt) { - const args = Array.prototype.slice.call(arguments, 1); - - function replace(part) { - if (part[0] == "$") { - return args[parseInt(part.slice(1))]; - } else - return part; - } - - return React.createElement.apply(null, [React.Fragment, { }].concat(fmt.split(/(\$[0-9]+)/g).map(replace))); -} - -const DesktopConsoleDownload = ({ vnc, spice, onDesktopConsole }) => { - return ( - -

- {fmt_to_fragments(_("Clicking \"Launch remote viewer\" will download a .vv file and launch $0."), Remote Viewer)} -

-

- {fmt_to_fragments(_("$0 is available for most operating systems. To install it, search for it in GNOME Software or run the following:"), Remote Viewer)} -

- } - /> - ); -}; - -export default DesktopConsoleDownload; diff --git a/src/components/vm/consoles/serial.jsx b/src/components/vm/consoles/serial.jsx new file mode 100644 index 000000000..0c21444fb --- /dev/null +++ b/src/components/vm/consoles/serial.jsx @@ -0,0 +1,213 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2017 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import cockpit from 'cockpit'; +import { StateObject } from './state'; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { + EmptyState, EmptyStateBody, EmptyStateFooter, EmptyStateActions +} from "@patternfly/react-core/dist/esm/components/EmptyState"; +import { Terminal } from "cockpit-components-terminal.jsx"; +import { PendingIcon } from "@patternfly/react-icons"; +import { KebabDropdown } from 'cockpit-components-dropdown.jsx'; +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown"; + +import { domainAttachSerialConsole } from '../../../libvirtApi/domain.js'; + +const _ = cockpit.gettext; + +export class SerialState extends StateObject { + constructor () { + super(); + this.connected = true; + } + + setConnected(val) { + this.connected = val; + this.update(); + } +} + +export const SerialActiveActions = ({ state }) => { + const dropdownItems = [ + state.setConnected(false)} + isDisabled={!state.connected} + > + {_("Disconnect")} + , + ]; + + return ( + + ); +}; + +export class SerialActive extends React.Component { + constructor (props) { + super(props); + + this.state = { + channel: undefined, + }; + } + + componentDidMount() { + this.updateChannel(this.props.spawnArgs); + } + + componentDidUpdate(prevProps) { + const oldSpawnArgs = prevProps.spawnArgs; + const newSpawnArgs = this.props.spawnArgs; + + const channel_needs_update = () => { + if (newSpawnArgs.length !== oldSpawnArgs.length || + oldSpawnArgs.some((arg, index) => arg !== newSpawnArgs[index])) + return true; + if (this.props.state.connected && !this.state.channel) + return true; + if (!this.props.state.connected && this.state.channel) + return true; + return false; + }; + + if (channel_needs_update()) + this.updateChannel(this.props.spawnArgs); + } + + updateChannel() { + if (this.state.channel) + this.state.channel.close(); + + if (this.props.state.connected) { + const opts = { + payload: "stream", + spawn: this.props.spawnArgs, + pty: true, + }; + if (this.props.connectionName == "system") + opts.superuser = "try"; + const channel = cockpit.channel(opts); + this.setState({ channel }); + } else { + this.setState({ channel: null }); + } + } + + render () { + const { state } = this.props; + + const pid = this.props.vmName + "-terminal"; + let t; + if (!state.connected) { + t = ( + + {_("Disconnected")} + + + + + ); + } else if (!this.state.channel) { + t = {_("Loading...")}; + } else { + t = ( + + ); + } + return ( +
+ {t} +
+ ); + } +} + +SerialActive.propTypes = { + connectionName: PropTypes.string.isRequired, + vmName: PropTypes.string.isRequired, + spawnArgs: PropTypes.array.isRequired, +}; + +export const SerialInactive = ({ vm }) => { + return ( + + + {_("Start the virtual machine to access the console")} + + + ); +}; + +export const SerialMissing = ({ vm, onAddErrorNotification }) => { + const [inProgress, setInProgress] = useState(false); + + function add_serial() { + setInProgress(true); + domainAttachSerialConsole(vm) + .catch(ex => onAddErrorNotification({ + text: cockpit.format(_("Failed to add text console to VM $0"), vm.name), + detail: ex.message, + resourceId: vm.id, + })) + .finally(() => setInProgress(false)); + } + + return ( + + + {_("Text console support not enabled")} + + + + + + + + ); +}; + +export const SerialPending = ({ vm }) => { + return ( + + + {_("Restart this virtual machine to access its text console")} + + + ); +}; diff --git a/src/components/vm/consoles/spice.jsx b/src/components/vm/consoles/spice.jsx new file mode 100644 index 000000000..776183a92 --- /dev/null +++ b/src/components/vm/consoles/spice.jsx @@ -0,0 +1,94 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2025 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import React from 'react'; +import cockpit from 'cockpit'; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { EmptyState, EmptyStateBody, EmptyStateFooter, EmptyStateActions } from "@patternfly/react-core/dist/esm/components/EmptyState"; +import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; + +import { useDialogs } from 'dialogs'; + +import { ReplaceSpiceDialog } from '../vmReplaceSpiceDialog.jsx'; +import { RemoteConnectionPopover, connection_address, console_launch } from './common'; + +const _ = cockpit.gettext; + +const SpiceFooter = ({ vm, spice }) => { + return ( +
+ + + + + + + +
+ ); +}; + +const Spice = ({ vm, isActive, isExpanded, spice }) => { + const Dialogs = useDialogs(); + + function replace_spice() { + Dialogs.show(); + } + + return ( + <> + + + {_("This machine has a SPICE graphical console that can not be shown here.")} + { !isActive && + _(" Start the virtual machine to be able to launch a remote viewer for this console") + } + + + + + + + + { !isExpanded && } + + ); +}; + +export const SpiceActive = ({ vm, isExpanded, spice }) => { + return ; +}; + +export const SpiceInactive = ({ vm, isExpanded }) => { + return ; +}; diff --git a/src/components/vm/consoles/state.jsx b/src/components/vm/consoles/state.jsx new file mode 100644 index 000000000..21e20ef32 --- /dev/null +++ b/src/components/vm/consoles/state.jsx @@ -0,0 +1,95 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2025 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +/* This is an experiment re React state management patterns. + + Hooks are cool and all, but they don't allow dynamic patterns. + Each function component needs to have a static list of hook + invocations at the top. Also, if you want to get everything done + with hooks, you will end up with too many useEffects that trigger + each other in complicated ways. + + Thus, if things get only slightly complicates I'll immediately make + a proper class that emits signals. Inside that class, we can just + write normal code! How refreshing! Such a class needs to be + "hooked up" via useObject and useEvent. + + But it's a bit too much boilerplate, so let's make a common + foundation for this pattern. + + Use it like this: + + class MyState extends StateObject { + constructor() { + super(); + this.whatever = 12; + } + + setWhatever(val) { + this.whatever = val; + this.update(); + } + } + + const MyComp = () => { + const state = useStateObject(() => new MyState()); + + return {state.whatever}; + } + + This call to "this.update" in "setWhatever" will cause a re-render + of MyComp. + + There are some more features: + + - When a state object should be disposed off, it's "close" method + is called. This method should close all channels etc. + + - If you want to combine state objects into a larger one, the + larger one can call "follow" on its sub-objects. This will + invoke a given callback and the larger object can update itself + based on the sub-object. (This is really just a thin wrapper + around "EventEmitter.on"). + + This is all really not a lot of code, but when using these helpers, + it keeps the focus on the actual state and away from the mechanics + of how to trigger a render. +*/ + +import { EventEmitter } from 'cockpit/event'; +import { useObject, useOn } from 'hooks'; + +export class StateObject extends EventEmitter { + update() { + this.emit("render"); + } + + follow(obj, callback) { + return obj.on("render", callback || (() => this.update())); + } + + close() { + } +} + +export function useStateObject(constructor, deps, comps) { + const state = useObject(constructor, obj => obj.close(), deps || [], comps); + useOn(state, "render"); + return state; +} diff --git a/src/components/vm/consoles/vnc.jsx b/src/components/vm/consoles/vnc.jsx index 5cc7f3f31..ae7814528 100644 --- a/src/components/vm/consoles/vnc.jsx +++ b/src/components/vm/consoles/vnc.jsx @@ -16,16 +16,37 @@ * You should have received a copy of the GNU Lesser General Public License * along with Cockpit; If not, see . */ -import React from 'react'; + +import React, { useState } from 'react'; import cockpit from 'cockpit'; +import { StateObject } from './state'; -import { VncConsole } from '@patternfly/react-console'; -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 { Form, FormGroup, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form"; +import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText"; +import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput"; +import { InputGroup } from "@patternfly/react-core/dist/esm/components/InputGroup"; +import { EyeIcon, EyeSlashIcon, PendingIcon } from "@patternfly/react-icons"; + +import { + EmptyState, EmptyStateBody, EmptyStateFooter, EmptyStateActions +} from "@patternfly/react-core/dist/esm/components/EmptyState"; +import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; + +import { KebabDropdown } from 'cockpit-components-dropdown.jsx'; +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown"; + +import { Modal, ModalVariant } from '@patternfly/react-core/dist/esm/deprecated/components/Modal'; +import { ModalError } from 'cockpit-components-inline-notification.jsx'; +import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx'; +import { useDialogs } from 'dialogs'; import { logDebug } from '../../../helpers.js'; -import { domainSendKey } from '../../../libvirtApi/domain.js'; +import { RemoteConnectionPopover, connection_address, console_launch } from './common'; +import { domainSendKey, domainAttachVnc, domainChangeVncSettings, domainGet } from '../../../libvirtApi/domain.js'; + +import { VncConsole } from './VncConsole'; const _ = cockpit.gettext; // https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h @@ -48,12 +69,221 @@ const Enum = { KEY_DELETE: 111, }; -class Vnc extends React.Component { +export class VncState extends StateObject { + constructor() { + super(); + this.connected = true; + } + + setConnected(val) { + this.connected = val; + this.update(); + } +} + +const VncEditModal = ({ vm, inactive_vnc }) => { + const config_port = (inactive_vnc.port == -1) ? "" : (inactive_vnc.port || ""); + const config_password = inactive_vnc.password || ""; + + const Dialogs = useDialogs(); + const [port, setPort] = useState(config_port); + const [password, setPassword] = useState(config_password); + const [showPassword, setShowPassword] = useState(false); + const [portError, setPortError] = useState(null); + const [passwordError, setPasswordError] = useState(null); + const [applyError, setApplyError] = useState(null); + const [applyErrorDetail, setApplyErrorDetail] = useState(null); + + async function apply() { + let field_errors = 0; + if (port != "" && (!port.match("^[0-9]+$") || Number(port) < 5900)) { + setPortError(_("Port must be 5900 or larger.")); + field_errors += 1; + } + + if (password.length > 8) { + setPasswordError(_("Password must be 8 characters or less.")); + field_errors += 1; + } + + if (field_errors > 0) + return; + + setPortError(null); + setPasswordError(null); + + const vncParams = { + listen: inactive_vnc.address || "", + port, + password, + }; + + try { + await domainChangeVncSettings(vm, vncParams); + domainGet({ connectionName: vm.connectionName, id: vm.id }); + Dialogs.close(); + } catch (ex) { + setApplyError(_("VNC settings could not be saved")); + setApplyErrorDetail(ex.message); + } + } + + return ( + + + + + } + > + { vm.state === 'running' && !applyError && + + } + { applyError && + + } +
e.preventDefault()} isHorizontal> + + { setPortError(null); setPort(val) }} /> + + + { portError + ? {portError} + : + {_("Leave empty to automatically assign a free port when the machine starts")} + + } + + + + + + { setPasswordError(null); setPassword(val) }} /> + + + { passwordError && + + + {passwordError} + + + } + +
+
+ ); +}; + +export const VncActiveActions = ({ state, vm, vnc, onAddErrorNotification }) => { + const renderDropdownItem = keyName => { + return ( + { + return domainSendKey({ + connectionName: vm.connectionName, + id: vm.id, + keyCodes: [ + Enum.KEY_LEFTCTRL, + Enum.KEY_LEFTALT, + Enum[cockpit.format("KEY_$0", keyName.toUpperCase())] + ] + }) + .catch(ex => onAddErrorNotification({ + text: cockpit.format(_("Failed to send key Ctrl+Alt+$0 to VM $1"), keyName, vm.name), + detail: ex.message, + resourceId: vm.id, + })); + }}> + {cockpit.format(_("Ctrl+Alt+$0"), keyName)} + + ); + }; + + const dropdownItems = [ + ...['Delete', 'Backspace'].map(key => renderDropdownItem(key)), + , + ...[...Array(12).keys()].map(key => renderDropdownItem(cockpit.format("F$0", key + 1))), + , + state.setConnected(false)} + isDisabled={!state.connected} + > + {_("Disconnect")} + , + ]; + + return ( + + ); +}; + +const VncFooter = ({ vm, vnc, inactive_vnc, onAddErrorNotification }) => { + const Dialogs = useDialogs(); + + return ( +
+ + + + Dialogs.show()} + /> + + + +
+ ); +}; + +export class VncActive extends React.Component { constructor(props) { super(props); this.state = { path: undefined, - isActionOpen: false, }; this.credentials = null; @@ -62,7 +292,6 @@ class Vnc extends React.Component { this.onDisconnected = this.onDisconnected.bind(this); this.onInitFailed = this.onInitFailed.bind(this); this.onSecurityFailure = this.onSecurityFailure.bind(this); - this.onExtraKeysDropdownToggle = this.onExtraKeysDropdownToggle.bind(this); } connect(props) { @@ -107,6 +336,7 @@ class Vnc extends React.Component { onDisconnected(detail) { // server disconnected console.info('Connection lost: ', detail); + this.props.state.setConnected(false); } onInitFailed(detail) { @@ -117,14 +347,14 @@ class Vnc extends React.Component { console.info('Security failure:', event?.detail?.reason || "unknown reason"); } - onExtraKeysDropdownToggle() { - this.setState({ isActionOpen: false }); - } - render() { - const { consoleDetail, connectionName, vmName, vmId, onAddErrorNotification, isExpanded } = this.props; - const { path, isActionOpen } = this.state; - if (!consoleDetail || !path) { + const { + consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded, + state, + } = this.props; + const { path } = this.state; + + if (!path) { // postpone rendering until consoleDetail is known and channel ready return null; } @@ -136,70 +366,118 @@ class Vnc extends React.Component { this.credentials = { password: consoleDetail.password }; const encrypt = this.getEncrypt(); - const renderDropdownItem = keyName => { - return ( - { - return domainSendKey({ connectionName, id: vmId, keyCodes: [Enum.KEY_LEFTCTRL, Enum.KEY_LEFTALT, Enum[cockpit.format("KEY_$0", keyName.toUpperCase())]] }) - .catch(ex => onAddErrorNotification({ - text: cockpit.format(_("Failed to send key Ctrl+Alt+$0 to VM $1"), keyName, vmName), - detail: ex.message, - resourceId: vmId, - })); - }}> - {cockpit.format(_("Ctrl+Alt+$0"), keyName)} - - ); - }; - const dropdownItems = [ - ...['Delete', 'Backspace'].map(key => renderDropdownItem(key)), - , - ...[...Array(12).keys()].map(key => renderDropdownItem(cockpit.format("F$0", key + 1))), - ]; - const additionalButtons = [ - ( - this.setState({ isActionOpen: !isActionOpen })}> - {_("Send key")} - - )} - isOpen={isActionOpen} - > - - {dropdownItems} - - - ]; - return ( - ); + + return ( + <> + { state.connected + ? + :
+ + {_("Disconnected")} + + + + +
+ } + {footer} + + ); } } -// TODO: define propTypes +export const VncInactive = ({ vm, inactive_vnc, isExpanded, onAddErrorNotification }) => { + return ( + <> + + + {_("Start the virtual machine to access the console")} + + + { !isExpanded && + + } + + ); +}; + +export const VncMissing = ({ vm, onAddErrorNotification }) => { + const [inProgress, setInProgress] = useState(false); + + function add_vnc() { + setInProgress(true); + domainAttachVnc(vm, { }) + .catch(ex => onAddErrorNotification({ + text: cockpit.format(_("Failed to add VNC to VM $0"), vm.name), + detail: ex.message, + resourceId: vm.id, + })) + .finally(() => setInProgress(false)); + } + + return ( + + + {_("Graphical console support not enabled")} + + + + + + + + ); +}; -export default Vnc; +export const VncPending = ({ vm, inactive_vnc, isExpanded, onAddErrorNotification }) => { + return ( + <> + + + {_("Restart this virtual machine to access its graphical console")} + + + { !isExpanded && + + } + + ); +}; diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index 683d6d6e4..bb711f1ba 100644 --- a/src/components/vm/vmDetailsPage.jsx +++ b/src/components/vm/vmDetailsPage.jsx @@ -19,24 +19,22 @@ import PropTypes from 'prop-types'; import React, { useEffect } from 'react'; import cockpit from 'cockpit'; +import { useStateObject } from './consoles/state'; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb"; import { CodeBlock, CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock"; import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery"; -import { Button } from "@patternfly/react-core/dist/esm/components/Button"; import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List"; import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card'; -import { Page, PageGroup, PageBreadcrumb, PageSection, } from "@patternfly/react-core/dist/esm/components/Page"; -import { ExpandIcon } from '@patternfly/react-icons'; +import { Page, PageGroup, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page"; import { WithDialogs } from 'dialogs.jsx'; import { vmId } from "../../helpers.js"; - import { VmFilesystemsCard, VmFilesystemActions } from './filesystems/vmFilesystemsCard.jsx'; 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 { ConsoleState, 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 +62,9 @@ export const VmDetailsPage = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // We want to reset the ConsoleState when a machine starts or shuts down. + const consoleState = useStateObject(() => new ConsoleState(), [vm.state]); + const vmActionsPageSection = (
@@ -89,22 +90,11 @@ export const VmDetailsPage = ({ - - - - {_("Virtual machines")} - - - {vm.name} - - - {_("Console")} - - - - {vmActionsPageSection} - - + @@ -132,24 +122,14 @@ 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: , - }] - : []), + { + card: + }, { id: `${vmId(vm.name)}-disks`, className: "disks-card", @@ -223,6 +203,8 @@ export const VmDetailsPage = ({ } const cards = cardContents.map(card => { + if (card.card) + return card.card; return ( { }> { vm.state === 'running' && !error && } {error && } - - - {isMultiple - ? _("Replace SPICE on selected VMs.") - : _("Replace SPICE on the virtual machine.") } - - {_("Convert SPICE graphics console to VNC")} - {_("Convert QXL video card to VGA")} - {_("Remove SPICE audio and host devices")} - - } /> - - - {_("This is intended for a host which does not support SPICE due to upgrades or live migration.")} - + + {isMultiple + ? _("Replace SPICE on selected VMs.") + : _("Replace SPICE on the virtual machine.") } + + {_("Convert SPICE graphics console to VNC")} + {_("Convert QXL video card to VGA")} + {_("Remove SPICE audio and host devices")} + + } /> + + + {_("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 } diff --git a/src/libvirtApi/domain.js b/src/libvirtApi/domain.js index a23c42ca8..0cbb69fc3 100644 --- a/src/libvirtApi/domain.js +++ b/src/libvirtApi/domain.js @@ -892,7 +892,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'; @@ -902,12 +902,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) { @@ -916,7 +920,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({ @@ -1088,3 +1092,15 @@ 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); +} + +export async function domainAttachSerialConsole(vm) { + await domainModifyXML(vm, "add-device", "console", "pty", { }); +} diff --git a/test/check-machines-consoles b/test/check-machines-consoles index dea3b9966..5a55171f9 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 @@ -41,7 +42,7 @@ class TestMachinesConsoles(machineslib.VirtualMachinesCase): def waitViewerDownload(self, kind, host, port=5900): self.browser.allow_download() - self.browser.click(".pf-v6-c-console__remote-viewer-launch-vv") # "Launch Remote Viewer" button + self.browser.click('.vm-console-footer button:contains("Launch viewer")') content = f"""[virt-viewer] type={kind} host={host} @@ -64,22 +65,16 @@ fullscreen=0 b.wait_in_text("#vm-subVmTest1-system-state", "Running") # running or paused self.goToVmPage("subVmTest1") - # since VNC is not defined for this VM, the view for "Desktop Viewer" is rendered by default - b.wait_in_text(".pf-v6-c-console__manual-connection dl > div:first-child dd", "127.0.0.1") - b.wait_in_text(".pf-v6-c-console__manual-connection dl > div:nth-child(2) dd", "5900") + # VNC is not defined for this VM, so we get the empty SPICE state + b.wait_in_text(".consoles-card", "This machine has a SPICE graphical console") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_in_text(".ct-remote-viewer-popover", f"spice://{b.address}:5900") + b.assert_pixels(".ct-remote-viewer-popover", "popover") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_not_present(".ct-remote-viewer-popover") self.waitViewerDownload("spice", b.address) - # Go to the expanded console view - b.click("button:contains(Expand)") - - # Check "More information" - b.click('.pf-v6-c-console__remote-viewer .pf-v6-c-expandable-section__toggle button') - b.wait_in_text('.pf-v6-c-expandable-section__content', - 'Clicking "Launch remote viewer" will download') - - b.assert_pixels("#vm-subVmTest1-consoles-page", "vm-details-console-external", skip_layouts=["rtl"]) - def testInlineConsole(self, urlroot=""): b = self.browser @@ -96,13 +91,13 @@ fullscreen=0 self.goToVmPage("subVmTest1") # since VNC is defined for this VM, the view for "In-Browser Viewer" is rendered by default - b.wait_visible('[class*=consoleVnc-] canvas') + b.wait_visible(".vm-console-vnc canvas") # make sure the log file is full - then empty it and reboot the VM - the log file should fill up again self.waitGuestBooted(args['logfile']) self.machine.execute(f"echo '' > {args['logfile']}") - b.click("#subVmTest1-system-vnc-sendkey") + b.click("#vnc-actions") b.click("#ctrl-alt-Delete") self.waitLogFile(args['logfile'], "reboot: Restarting system") @@ -123,8 +118,7 @@ fullscreen=0 self.goToVmPage(name) b.wait_in_text(f"#vm-{name}-system-state", "Running") - b.click("#pf-v6-c-console__type-selector") - b.click("#SerialConsole") + b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Text)") # In case the OS already finished booting, press Enter into the console to re-trigger the login prompt # Sometimes, pressing Enter one time doesn't take effect, so loop to press Enter to make sure @@ -138,28 +132,23 @@ fullscreen=0 # Make sure the content of console is expected testlib.wait(lambda: "Welcome to Alpine Linux" in b.text(f"#{name}-terminal .xterm-accessibility-tree")) - b.click(f"#{name}-serialconsole-disconnect") - b.wait_text(f"#{name}-terminal", "Disconnected from serial console. Click the connect button.") + b.click(".consoles-card .pf-v6-c-card__header-main button.pf-m-plain") + b.click('.pf-v6-c-menu button:contains("Disconnect")') + b.wait_in_text(".consoles-card", "Disconnected") - b.click(f"#{name}-serialconsole-connect") + b.click('.consoles-card button:contains("Connect")') b.wait_in_text(f"#{name}-terminal .xterm-accessibility-tree > div:nth-child(1)", f"Connected to domain '{name}'") - b.click("button:contains(Expand)") - b.assert_pixels("#vm-vmWithSerialConsole-consoles-page", "vm-details-console-serial", - ignore=['[class*=consoleVnc-]'], skip_layouts=["rtl"]) - # Add a second serial console m.execute(""" virsh destroy vmWithSerialConsole; virt-xml --add-device vmWithSerialConsole --console pty,target_type=virtio; virsh start vmWithSerialConsole""") - b.click("#pf-v6-c-console__type-selector") - b.click(".pf-v6-c-menu li:contains('Serial console (serial0)') button") - b.wait(lambda: m.execute("ps aux | grep 'virsh -c qemu:///system console vmWithSerialConsole serial0'")) - b.click("#pf-v6-c-console__type-selector") - b.click(".pf-v6-c-menu li:contains('Serial console (console1)') button") - b.wait(lambda: m.execute("ps aux | grep 'virsh -c qemu:///system console vmWithSerialConsole console1'")) + b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Text (serial0)")') + b.wait(lambda: m.execute("ps aux | grep 'virsh -c [q]emu:///system console vmWithSerialConsole serial0'")) + b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Text (console1)")') + b.wait(lambda: m.execute("ps aux | grep 'virsh -c [q]emu:///system console vmWithSerialConsole console1'")) # Add multiple serial consoles # Remove all console firstly @@ -174,10 +163,19 @@ fullscreen=0 """) for i in range(0, 6): - b.click("#pf-v6-c-console__type-selector") - b.click(f'.pf-v6-c-menu li:contains(\'Serial console ({"serial" if i == 0 else "console"}{i})\') button') + tag = "serial" if i == 0 else "console" + b.click(f'.consoles-card .pf-v6-c-toggle-group button:contains("Text ({tag}{i})")') b.wait(lambda: m.execute( - f'ps aux | grep \'virsh -c qemu:///system console vmWithSerialConsole {"serial" if i == 0 else "console"}{i}\'')) # noqa: B023, E501 + f'ps aux | grep \'virsh -c [q]emu:///system console vmWithSerialConsole {tag}{i}\'')) # noqa: B023 + + def console_channels(): + return m.execute("(ps aux | grep 'virsh -c [q]emu:///system console vmWithSerialConsole') || true") + + # Verify that there is still at least one channel open + b.wait(lambda: console_channels() != "") + # Navigating away from the details page should close all channels + self.goToMainPage() + b.wait(lambda: console_channels() == "") # disconnecting the serial console closes the pty channel self.allow_journal_messages("connection unexpectedly closed by peer", @@ -201,36 +199,22 @@ fullscreen=0 b.wait_in_text(f"#vm-{name}-system-state", "Running") # test switching console from serial to graphical - b.wait_visible(f"#vm-{name}-consoles") - b.wait_visible("[class*=consoleVnc-] canvas") + b.wait_visible(".consoles-card") + b.wait_visible(".vm-console-vnc canvas") - b.click("#pf-v6-c-console__type-selector") - b.click("#SerialConsole") - - b.wait_not_present("[class*=consoleVnc-] canvas") + b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Text)") + b.wait_not_present(".vm-console-vnc canvas") b.wait_visible(f"#{name}-terminal") # Go back to Vnc console - b.click("#pf-v6-c-console__type-selector") - b.click("#VncConsole") - b.wait_visible("[class*=consoleVnc-] canvas") - - # Go to the expanded console view - b.click("button:contains(Expand)") + b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Graphical)") + b.wait_not_present(f"#{name}-terminal") + b.wait_visible(".vm-console-vnc canvas") # 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") - - # Test deleting VM from console page will not trigger any error - self.performAction(name, "delete") - b.wait_visible(f"#vm-{name}-delete-modal-dialog") - b.click(f"#vm-{name}-delete-modal-dialog button:contains(Delete)") - self.waitPageInit() - self.waitVmRow(name, present=False) - - b.wait_not_present("#navbar-oops") + b.wait_in_text(".consoles-card", "Start the virtual machine") self.allow_journal_messages("connection unexpectedly closed by peer") self.allow_browser_errors("Disconnection timed out.", @@ -267,11 +251,12 @@ fullscreen=0 self.goToVmPage(name) b.wait_in_text(f"#vm-{name}-system-state", "Running") - b.click("#pf-v6-c-console__type-selector") - b.click("#DesktopViewer") - - b.wait_in_text(".pf-v6-c-console__manual-connection dl > div:first-child dd", "127.0.0.1") - b.wait_in_text(".pf-v6-c-console__manual-connection dl > div:nth-child(2) dd", "5900") + b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Graphical)") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_in_text(".ct-remote-viewer-popover", f"vnc://{my_ip}:5900") + b.assert_pixels(".ct-remote-viewer-popover", "popover") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_not_present(".ct-remote-viewer-popover") self.waitViewerDownload("vnc", my_ip) @@ -294,14 +279,222 @@ fullscreen=0 self.goToVmPage(name) b.wait_in_text(f"#vm-{name}-system-state", "Running") - b.click("#pf-v6-c-console__type-selector") - b.click("#DesktopViewer") - - b.wait_in_text(".pf-v6-c-console__manual-connection dl > div:first-child dd", "127.0.0.1") - b.wait_in_text(".pf-v6-c-console__manual-connection dl > div:nth-child(2) dd", "5900") + b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Graphical)") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_in_text(".ct-remote-viewer-popover", f"vnc://{my_ip}:5900") + b.assert_pixels(".ct-remote-viewer-popover", "popover") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_not_present(".ct-remote-viewer-popover") self.waitViewerDownload("vnc", my_ip) + def testAddEditVNC(self): + b = self.browser + + # Create a machine without any consoles + + name = "subVmTest1" + self.createVm(name) + + 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-v6-c-empty-state", text) + + def assert_not_state(text): + b.wait_not_in_text(f"#vm-{name}-consoles .pf-v6-c-empty-state", text) + + # "Console" card shows empty state + + assert_state("Graphical console support not enabled") + b.assert_pixels(".consoles-card", "no-vnc") + + b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add VNC)") + + assert_state("Restart this virtual machine to access its graphical console") + b.wait_visible(f"#vm-{name}-needs-shutdown") + b.assert_pixels(".consoles-card", "needs-shutdown") + + 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'), None) + + b.click(".vm-console-footer button.pf-m-plain") + b.click(".ct-remote-viewer-popover button:contains('Set port and password')") + b.wait_visible("#vnc-edit-dialog") + b.set_input_text("#vnc-edit-port", "5000") + b.click("#vnc-edit-save") + b.wait_visible("#vnc-edit-dialog .pf-m-error:contains('Port must be 5900 or larger.')") + b.set_input_text("#vnc-edit-port", "Hamburg") + b.wait_not_present("#vnc-edit-dialog .pf-m-error") + b.click("#vnc-edit-save") + b.wait_visible("#vnc-edit-dialog .pf-m-error:contains('Port must be 5900 or larger.')") + b.assert_pixels("#vnc-edit-dialog", "add") + b.set_input_text("#vnc-edit-port", "100000000000") # for testing failed libvirt calls + b.set_input_text("#vnc-edit-password", "foobarfoobar") + b.wait_attr("#vnc-edit-password", "type", "password") + b.click("#vnc-edit-dialog .pf-v6-c-input-group button") + b.wait_attr("#vnc-edit-password", "type", "text") + b.click("#vnc-edit-save") + b.wait_visible("#vnc-edit-dialog .pf-m-error:contains('Password must be 8 characters or less.')") + b.set_input_text("#vnc-edit-password", "foobar") + b.click("#vnc-edit-save") + b.wait_in_text("#vnc-edit-dialog", "VNC settings could not be saved") + b.wait_in_text("#vnc-edit-dialog", "cannot parse vnc port 100000000000") + b.set_input_text("#vnc-edit-port", "5901") + b.click("#vnc-edit-save") + b.wait_not_present("#vnc-edit-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'), "5901") + self.assertEqual(graphics[0].get('passwd'), "foobar") + + # Shut down machine + + self.performAction("subVmTest1", "forceOff") + assert_state("Start the virtual machine to access the console") + b.assert_pixels(".consoles-card", "shutoff") + + # Remove VNC from the outside and add it back while the machine is off + + self.machine.execute(f"virt-xml --remove-device --graphics vnc {name}") + + assert_state("Graphical console support not enabled") + + b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add VNC)") + assert_not_state("Graphical console support not enabled") + assert_state("Start the virtual machine to access the console") + + def testAddSerial(self): + b = self.browser + m = self.machine + + # Create a machine without any serial consoles + + name = "subVmTest1" + self.createVm(name, running=False, ptyconsole=True) + m.execute(f"virt-xml --remove-device {name} --serial all") + + 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-v6-c-empty-state", text) + + # Switch to Text console + + b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Text")') + + # "Console" card shows empty state + + assert_state("Text console support not enabled") + b.assert_pixels(".consoles-card", "no-serial") + + b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add text console)") + + assert_state("Start the virtual machine to access the console") + + self.performAction(name, "run") + testlib.wait(lambda: "Welcome to Alpine Linux" in b.text(f"#{name}-terminal .xterm-accessibility-tree")) + + # Shutdown, remove, start, and add it while VM is running + self.performAction(name, "forceOff") + assert_state("Start the virtual machine to access the console") + m.execute(f"virt-xml --remove-device {name} --serial all") + self.performAction(name, "run") + b.wait_in_text(f"#vm-{name}-system-state", "Running") + + b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Text")') + assert_state("Text console support not enabled") + b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add text console)") + + assert_state("Restart this virtual machine to access its text console") + b.wait_visible(f"#vm-{name}-needs-shutdown") + b.assert_pixels(".consoles-card", "needs-shutdown") + + self.performAction("subVmTest1", "forceOff") + assert_state("Start the virtual machine to access the console") + self.performAction(name, "run") + testlib.wait(lambda: "Welcome to Alpine Linux" in b.text(f"#{name}-terminal .xterm-accessibility-tree")) + + def testExpandedConsole(self): + b = self.browser + + # Create a machine without any serial consoles + + name = "subVmTest1" + self.createVm(name, graphics="vnc", ptyconsole=True) + + self.login_and_go("/machines") + self.waitPageInit() + self.waitVmRow(name) + self.goToVmPage(name) + + b.click(".consoles-card button:contains(Expand)") + b.wait_visible(".consoles-page-expanded") + b.assert_pixels(".consoles-card", "expanded") + + # Disconnect VNC, switch to Text + + b.click("#vnc-actions") + b.click("#vnc-disconnect") + b.wait_in_text(".consoles-card", "Disconnected") + b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Text")') + b.wait_visible(".consoles-card .vm-terminal") + + # Compress, Text should still be selected and VNC should stay + # disconnected + + b.click(".consoles-card button:contains(Compress)") + b.wait_visible("#vm-details") + b.wait_visible(".consoles-card .vm-terminal") + b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Graphical")') + b.wait_in_text(".consoles-card", "Disconnected") + + # Connect VNC + b.click(".consoles-card button:contains(Connect)") + b.wait_visible(".vm-console-vnc canvas") + + def testSpice(self): + b = self.browser + + # Create a machine with a spice console, and no vnc. + + name = "subVmTest1" + self.createVm(name, graphics="spice") + + 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-v6-c-empty-state", text) + + assert_state("This machine has a SPICE graphical console that can not be shown here.") + + b.click(".consoles-card .pf-v6-c-empty-state button:contains(Replace with VNC)") + b.wait_text(".pf-v6-c-modal-box__title-text", f"Replace SPICE devices in VM {name}") + b.click("#replace-spice-dialog-confirm") + b.wait_not_present(".pf-v6-c-modal-box") + + assert_state("Restart this virtual machine to access its graphical consol") + + self.performAction(name, "forceOff") + assert_state("Start the virtual machine to access the console") + + self.performAction(name, "run") + b.wait_visible(".vm-console-vnc canvas") + if __name__ == '__main__': testlib.test_main() diff --git a/test/check-machines-create b/test/check-machines-create index e97ad29a7..b89da8718 100755 --- a/test/check-machines-create +++ b/test/check-machines-create @@ -2517,18 +2517,20 @@ vnc_password= "{vnc_passwd}" # if VNC is available, the inline viewer is the default if expect_vnc: with b.wait_timeout(60): - b.wait_visible("[class*=consoleVnc-] canvas") + b.wait_visible(".vm-console-vnc canvas") - b.click("#pf-v6-c-console__type-selector") - b.click("#DesktopViewer") - if expect_vnc: - b.wait_in_text(".pf-v6-c-console__manual-connection", "VNC") - else: - self.assertNotIn("VNC", b.text(".pf-v6-c-console__manual-connection")) - if expect_spice: - b.wait_in_text(".pf-v6-c-console__manual-connection", "SPICE") + if not expect_vnc and not expect_spice: + b.wait_not_present(".vm-console-footer button.pf-m-plain") else: - self.assertNotIn("SPICE", b.text(".pf-v6-c-console__manual-connection")) + b.click(".vm-console-footer button.pf-m-plain") + if expect_vnc: + b.wait_in_text(".ct-remote-viewer-popover", "vnc://") + else: + b.wait_not_in_text(".ct-remote-viewer-popover", "vnc://") + if expect_spice: + b.wait_in_text(".ct-remote-viewer-popover", "spice://") + b.click(".vm-console-footer button.pf-m-plain") + b.wait_not_present(".ct-remote-viewer-popover") def doReplaceSpiceSingle(vmName, expect_running=False, cancel=False): self.performAction(vmName, "replace-spice") diff --git a/test/check-machines-multi-host-consoles b/test/check-machines-multi-host-consoles index 4b461640d..5504ba70c 100755 --- a/test/check-machines-multi-host-consoles +++ b/test/check-machines-multi-host-consoles @@ -89,7 +89,7 @@ class TestMultiMachineVNC(VirtualMachinesCase): self.goToVmPage("subVmTest1") # Wait till we have a VNC connection - b.wait_visible("[class*=consoleVnc-] canvas") + b.wait_visible(".vm-console-vnc canvas") # Both machines should have the equal amount of VNC connections m1_open_connections = m.execute("ss -tupn | grep 127.0.0.1:5900 | grep qemu").splitlines()