From 77c4eb928a370b49f41982b09f4d85ea365bde68 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Wed, 29 Jan 2025 12:47:41 +0200 Subject: [PATCH] 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. - Can't change consoles in the expanded view, but the expanded console keeps the type that was active in the collapsed view. TODO: - Code cleanup, "VncConsole" is now really "GraphicsConsole", etc.. --- src/components/vm/consoles/consoles.css | 12 +- src/components/vm/consoles/consoles.jsx | 225 +++++++------- src/components/vm/consoles/serialConsole.jsx | 19 +- src/components/vm/consoles/vnc.jsx | 292 ++++++++++++++----- src/components/vm/vmDetailsPage.jsx | 15 +- 5 files changed, 371 insertions(+), 192 deletions(-) diff --git a/src/components/vm/consoles/consoles.css b/src/components/vm/consoles/consoles.css index 53ac629af..927c2dc06 100644 --- a/src/components/vm/consoles/consoles.css +++ b/src/components/vm/consoles/consoles.css @@ -18,13 +18,21 @@ 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; } + +.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 45eb232b0..438d33043 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -23,6 +23,7 @@ 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 { ExpandIcon, HelpIcon } from '@patternfly/react-icons'; +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup'; import SerialConsole from './serialConsole.jsx'; import Vnc, { VncState } from './vnc.jsx'; @@ -39,132 +40,118 @@ import './consoles.css'; const _ = cockpit.gettext; -class Consoles extends React.Component { - constructor (props) { - super(props); +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'); - 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'); - - 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); - - 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); - } - } +function console_launch(vm, consoleDetail) { + // fire download of the .vv file + domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address: connection_address() } }); +} + +export const Console = ({ vm, config, type, onAddErrorNotification, isExpanded }) => { + let con = null; - domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address } }); + if (!type) + type = console_default(vm); + + if (vm.state != "running") { + const vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc'); + const spice = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'spice'); + 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 || spice) && - } - +
+ {con} +
); } -} - -Consoles.propTypes = { - vm: PropTypes.object.isRequired, - onAddErrorNotification: PropTypes.func.isRequired, }; -export default Consoles; +export const ConsoleCard = ({ vm, config, type, setType, onAddErrorNotification }) => { + const serials = vm.displays && vm.displays.filter(display => display.type == 'pty'); -export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => { - let actions = null; - if (vm.state != "shut off") { - actions = ( + if (!type) + type = console_default(vm); + + const actions = []; + const tabs = []; + let body; + + if (vm.state == "running") { + actions.push( ); + + if (serials.length > 0) + tabs.push( setType("vnc")} />); + + serials.forEach((pty, idx) => { + const t = "serial" + idx; + tabs.push( setType(t)} />); + }) + + body = + } else { + const vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc'); + const spice = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'spice'); + body = ; } return ( @@ -184,9 +198,10 @@ export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => { isClickable> {_("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 2f27625b9..1bd87ed2f 100644 --- a/src/components/vm/consoles/vnc.jsx +++ b/src/components/vm/consoles/vnc.jsx @@ -20,19 +20,26 @@ 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 { Button } from "@patternfly/react-core/dist/esm/components/Button"; import { Divider } from "@patternfly/react-core/dist/esm/components/Divider"; 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 } from 'dialogs.jsx'; +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 { AddVNC } from './vncAdd.jsx'; import { EditVNCModal } from './vncEdit.jsx'; +import { ReplaceSpiceDialog } from '../vmReplaceSpiceDialog.jsx'; const _ = cockpit.gettext; // https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h @@ -55,7 +62,7 @@ const Enum = { KEY_DELETE: 111, }; -export const VncState = ({ vm, vnc }) => { +export const VncState = ({ vm, vnc, spice }) => { const Dialogs = useDialogs(); function add_vnc() { @@ -66,31 +73,59 @@ export const VncState = ({ vm, vnc }) => { Dialogs.show(); } + function replace_spice() { + Dialogs.show(); + } + if (vm.state == "running" && !vnc) { - return ( - - - {_("Graphical support not enabled.")} - - - - - - ); + if (!spice) { + return ( + + + {_("Graphical support not enabled.")} + + + + + + ); + } else { + return ( + + + {_("SPICE graphical console can not be shown here.")} + + + + + + ); + } } let vnc_info; let vnc_action; if (!vnc) { - vnc_info = _("not supported"); - vnc_action = ( - - ); + if (!spice) { + vnc_info = _("not supported"); + vnc_action = ( + + ); + } else { + vnc_info = _("SPICE, can not be shown here"); + vnc_action = ( + + ); + } } else { if (vnc.port == -1) vnc_info = _("VNC, dynamic port"); @@ -128,18 +163,74 @@ export const VncState = ({ vm, vnc }) => { ); }; +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.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) { @@ -184,34 +275,28 @@ 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, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded } = this.props; - const { path, isActionOpen } = this.state; + const Dialogs = this.context; + const { + consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded, connectionAddress, + spiceDetail, + } = this.props; + const { path, connected } = this.state; - if (!consoleDetail) { - return ( -
- -
- ); + function edit_vnc() { + Dialogs.show(); } - if (!path) { - // postpone rendering until consoleDetail is known and channel ready - return null; - } - const credentials = consoleDetail.password ? { password: consoleDetail.password } : undefined; - const encrypt = this.getEncrypt(); const renderDropdownItem = keyName => { return ( 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} - - + , + + {_("Edit VNC server settings")} + , + this.setState({ connected: false })}> + {_("Disconnect")} + , ]; + const detail = ( + + + + }> + + + + + { (connected && consoleDetail) && + + } + + + ); + + if (!consoleDetail) { + return ( + <> +
+ +
+ { spiceDetail &&
{detail}
} + + ); + } + + if (!path) { + // postpone rendering until consoleDetail is known and channel ready + return null; + } + + const credentials = consoleDetail.password ? { password: consoleDetail.password } : undefined; + const encrypt = this.getEncrypt(); + return ( - + <> + { connected + ? + :
+ + {_("Disconnected")} + + + + +
+ } +
{detail}
+ ); } } diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index ae11977e0..395a310eb 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, Console, console_name } 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 = (
@@ -98,14 +100,17 @@ export const VmDetailsPage = ({ {vm.name} - {_("Console")} + {console_name(vm, consoleType)} {vmActionsPageSection} - @@ -137,6 +142,8 @@ export const VmDetailsPage = ({ key={`${vmId(vm.name)}-consoles`} vm={vm} config={config} + type={consoleType} + setType={setConsoleType} onAddErrorNotification={onAddErrorNotification} /> }, {