From f94fd67001f20174675ba88b5ae3a765228547de Mon Sep 17 00:00:00 2001 From: Shotaro-Kawaguchi Date: Thu, 16 Jan 2025 16:00:37 +0200 Subject: [PATCH 01/10] consoles: Add VNC dialogs --- src/components/vm/consoles/vncAdd.jsx | 122 ++++++++++++++++++++++++ src/components/vm/consoles/vncBody.jsx | 63 ++++++++++++ src/components/vm/consoles/vncEdit.jsx | 127 +++++++++++++++++++++++++ src/libvirtApi/domain.js | 26 +++++ 4 files changed, 338 insertions(+) create mode 100644 src/components/vm/consoles/vncAdd.jsx create mode 100644 src/components/vm/consoles/vncBody.jsx create mode 100644 src/components/vm/consoles/vncEdit.jsx diff --git a/src/components/vm/consoles/vncAdd.jsx b/src/components/vm/consoles/vncAdd.jsx new file mode 100644 index 000000000..457974c92 --- /dev/null +++ b/src/components/vm/consoles/vncAdd.jsx @@ -0,0 +1,122 @@ +/* + * This file is part of Cockpit. + * + * Copyright 2024 Fsas Technologies 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 PropTypes from 'prop-types'; +import { Button, Form, Modal, ModalVariant } from "@patternfly/react-core"; +import { DialogsContext } from 'dialogs.jsx'; + +import { ModalError } from 'cockpit-components-inline-notification.jsx'; +import { VncRow } from './vncBody.jsx'; +import { domainAttachVnc, domainGet } from '../../../libvirtApi/domain.js'; + +const _ = cockpit.gettext; + +export class AddVNC extends React.Component { + static contextType = DialogsContext; + + constructor(props) { + super(props); + + this.state = { + dialogError: undefined, + vncAddress: "", + vncPort: "", + vncPassword: "", + addVncInProgress: false, + }; + this.add = this.add.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + this.dialogErrorSet = this.dialogErrorSet.bind(this); + } + + onValueChanged(key, value) { + const stateDelta = { [key]: value }; + + this.setState(stateDelta); + } + + dialogErrorSet(text, detail) { + this.setState({ dialogError: text, dialogErrorDetail: detail }); + } + + add() { + const Dialogs = this.context; + const { vm } = this.props; + + this.setState({ addVncInProgress: true }); + const vncParams = { + connectionName: vm.connectionName, + vmName: vm.name, + vncAddress: this.state.vncAddress || "", + vncPort: this.state.vncPort || "", + vncPassword: this.state.vncPassword || "", + }; + + domainAttachVnc(vncParams) + .then(() => { + domainGet({ connectionName: vm.connectionName, id: vm.id }); + Dialogs.close(); + }) + .catch(exc => this.dialogErrorSet(_("VNC device settings could not be saved"), exc.message)) + .finally(() => this.setState({ addVncInProgress: false })); + } + + render() { + const Dialogs = this.context; + const { idPrefix } = this.props; + + const defaultBody = ( +
e.preventDefault()} isHorizontal> + + + ); + + return ( + + + + + }> + {this.state.dialogError && } + {defaultBody} + + ); + } +} + +AddVNC.propTypes = { + idPrefix: PropTypes.string.isRequired, + vm: PropTypes.object.isRequired, +}; + +export default AddVNC; diff --git a/src/components/vm/consoles/vncBody.jsx b/src/components/vm/consoles/vncBody.jsx new file mode 100644 index 000000000..f039f35fd --- /dev/null +++ b/src/components/vm/consoles/vncBody.jsx @@ -0,0 +1,63 @@ +/* + * This file is part of Cockpit. + * + * Copyright 2024 Fsas Technologies 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 PropTypes from 'prop-types'; +import { FormGroup, Grid, GridItem, TextInput } from "@patternfly/react-core"; + +import cockpit from 'cockpit'; + +const _ = cockpit.gettext; + +export const VncRow = ({ idPrefix, onValueChanged, dialogValues }) => { + return ( + + + + onValueChanged('vncAddress', event.target.value)} /> + + + + + onValueChanged('vncPort', event.target.value)} /> + + + + + onValueChanged('vncPassword', event.target.value)} /> + + + + ); +}; + +VncRow.propTypes = { + idPrefix: PropTypes.string.isRequired, + onValueChanged: PropTypes.func.isRequired, + dialogValues: PropTypes.object.isRequired, +}; diff --git a/src/components/vm/consoles/vncEdit.jsx b/src/components/vm/consoles/vncEdit.jsx new file mode 100644 index 000000000..42f40d694 --- /dev/null +++ b/src/components/vm/consoles/vncEdit.jsx @@ -0,0 +1,127 @@ +/* + * This file is part of Cockpit. + * + * Copyright 2024 Fsas Technologies 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 PropTypes from 'prop-types'; +import { Button, Form, Modal, ModalVariant } from "@patternfly/react-core"; + +import { ModalError } from 'cockpit-components-inline-notification.jsx'; +import { DialogsContext } from 'dialogs.jsx'; +import { VncRow } from './vncBody.jsx'; +import { domainChangeVncSettings, domainGet } from '../../../libvirtApi/domain.js'; + +const _ = cockpit.gettext; + +export class EditVNCModal extends React.Component { + static contextType = DialogsContext; + + constructor(props) { + super(props); + + this.state = { + dialogError: undefined, + saveDisabled: false, + vmName: props.vmName, + vmId: props.vmId, + connectionName: props.connectionName, + vncAddress: props.consoleDetail.address || "", + vncPort: props.consoleDetail.port || "", + vncPassword: props.consoleDetail.password || "", + }; + + 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 }; + this.setState(stateDelta); + } + + dialogErrorSet(text, detail) { + this.setState({ dialogError: text, dialogErrorDetail: detail }); + } + + save() { + const Dialogs = this.context; + + const vncParams = { + connectionName: this.state.connectionName, + vmName: this.state.vmName, + vncAddress: this.state.vncAddress || "", + vncPort: this.state.vncPort || "", + vncPassword: this.state.vncPassword || "", + }; + + domainChangeVncSettings(vncParams) + .then(() => { + domainGet({ connectionName: this.state.connectionName, id: this.state.vmId }); + Dialogs.close(); + }) + .catch((exc) => { + this.dialogErrorSet(_("VNC settings could not be saved"), exc.message); + }); + } + + render() { + const Dialogs = this.context; + const { idPrefix } = this.props; + + const defaultBody = ( +
e.preventDefault()} isHorizontal> + + + ); + const showWarning = () => { + }; + + return ( + + + + + }> + <> + { showWarning() } + {this.state.dialogError && } + {defaultBody} + + + ); + } +} +EditVNCModal.propTypes = { + idPrefix: PropTypes.string.isRequired, + vmName: PropTypes.string.isRequired, + vmId: PropTypes.string.isRequired, + connectionName: PropTypes.string.isRequired, + consoleDetail: PropTypes.object.isRequired, +}; + +export default EditVNCModal; diff --git a/src/libvirtApi/domain.js b/src/libvirtApi/domain.js index eab234179..b7f8ea42e 100644 --- a/src/libvirtApi/domain.js +++ b/src/libvirtApi/domain.js @@ -1089,3 +1089,29 @@ 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 function domainAttachVnc({ connectionName, vmName, vncAddress, vncPort, vncPassword }) { + const args = ['virt-xml', '-c', `qemu:///${connectionName}`, vmName, '--add-device', '--graphics', `vnc,listen=${vncAddress},port=${vncPort},passwd=${vncPassword}`]; + const options = { err: "message" }; + + if (connectionName === "system") + options.superuser = "try"; + + return cockpit.spawn(args, options); +} + +export function domainChangeVncSettings({ + connectionName, + vmName, + vncAddress, + vncPort, + vncPassword, +}) { + const options = { err: "message" }; + if (connectionName === "system") + options.superuser = "try"; + + const args = ["virt-xml", "-c", `qemu:///${connectionName}`, vmName, "--edit", "--graphics", `vnc,listen=${vncAddress},port=${vncPort},passwd=${vncPassword}`]; + + return cockpit.spawn(args, options); +} From 21b06f45237e909aedc559ceabf61094d9d28c8b Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Wed, 29 Jan 2025 10:57:15 +0200 Subject: [PATCH 02/10] consoles: Improve VNC dialogs - The dialogs talk about "server" and "listening" to make that clearer. - We use the empty string instead of "-1" to signify automatic port assignment in the UI. - The port does validation of its value. - The password field takes the whole row and has can reveal its value. - The dialogs warn if a shutdown is needed. - The components take the whole "vm" object instead of separate name, id, and connectionName. --- src/components/vm/consoles/vncAdd.jsx | 24 +++++-- src/components/vm/consoles/vncBody.jsx | 94 ++++++++++++++++++-------- src/components/vm/consoles/vncEdit.jsx | 44 ++++++------ 3 files changed, 107 insertions(+), 55 deletions(-) diff --git a/src/components/vm/consoles/vncAdd.jsx b/src/components/vm/consoles/vncAdd.jsx index 457974c92..a5a4953ae 100644 --- a/src/components/vm/consoles/vncAdd.jsx +++ b/src/components/vm/consoles/vncAdd.jsx @@ -23,9 +23,11 @@ import { Button, Form, Modal, ModalVariant } from "@patternfly/react-core"; import { DialogsContext } from 'dialogs.jsx'; import { ModalError } from 'cockpit-components-inline-notification.jsx'; -import { VncRow } from './vncBody.jsx'; +import { VncRow, validateDialogValues } from './vncBody.jsx'; import { domainAttachVnc, domainGet } from '../../../libvirtApi/domain.js'; +import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx'; + const _ = cockpit.gettext; export class AddVNC extends React.Component { @@ -40,6 +42,7 @@ export class AddVNC extends React.Component { vncPort: "", vncPassword: "", addVncInProgress: false, + validationErrors: { }, }; this.add = this.add.bind(this); this.onValueChanged = this.onValueChanged.bind(this); @@ -60,6 +63,12 @@ export class AddVNC extends React.Component { const Dialogs = this.context; const { vm } = this.props; + const errors = validateDialogValues(this.state); + if (errors) { + this.setState({ validationErrors: errors }); + return; + } + this.setState({ addVncInProgress: true }); const vncParams = { connectionName: vm.connectionName, @@ -80,19 +89,21 @@ export class AddVNC extends React.Component { render() { const Dialogs = this.context; - const { idPrefix } = this.props; + const { idPrefix, vm } = this.props; const defaultBody = (
e.preventDefault()} isHorizontal> - + ); return ( + + + ); }; +export function validateDialogValues(values) { + const res = { }; + + if (values.vncPort == "") + ; // fine + else if (!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; +} + VncRow.propTypes = { idPrefix: PropTypes.string.isRequired, onValueChanged: PropTypes.func.isRequired, dialogValues: PropTypes.object.isRequired, + validationErrors: PropTypes.object.isRequired, }; diff --git a/src/components/vm/consoles/vncEdit.jsx b/src/components/vm/consoles/vncEdit.jsx index 42f40d694..f24eab227 100644 --- a/src/components/vm/consoles/vncEdit.jsx +++ b/src/components/vm/consoles/vncEdit.jsx @@ -23,8 +23,9 @@ import { Button, Form, Modal, ModalVariant } from "@patternfly/react-core"; import { ModalError } from 'cockpit-components-inline-notification.jsx'; import { DialogsContext } from 'dialogs.jsx'; -import { VncRow } from './vncBody.jsx'; +import { VncRow, validateDialogValues } from './vncBody.jsx'; import { domainChangeVncSettings, domainGet } from '../../../libvirtApi/domain.js'; +import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx'; const _ = cockpit.gettext; @@ -37,12 +38,10 @@ export class EditVNCModal extends React.Component { this.state = { dialogError: undefined, saveDisabled: false, - vmName: props.vmName, - vmId: props.vmId, - connectionName: props.connectionName, vncAddress: props.consoleDetail.address || "", - vncPort: props.consoleDetail.port || "", + vncPort: Number(props.consoleDetail.port) == -1 ? "" : props.consoleDetail.port || "", vncPassword: props.consoleDetail.password || "", + validationErrors: { }, }; this.save = this.save.bind(this); @@ -51,7 +50,7 @@ export class EditVNCModal extends React.Component { } onValueChanged(key, value) { - const stateDelta = { [key]: value }; + const stateDelta = { [key]: value, validationErrors: { [key]: null } }; this.setState(stateDelta); } @@ -61,10 +60,17 @@ export class EditVNCModal extends React.Component { save() { const Dialogs = this.context; + const { vm } = this.props; + + const errors = validateDialogValues(this.state); + if (errors) { + this.setState({ validationErrors: errors }); + return; + } const vncParams = { - connectionName: this.state.connectionName, - vmName: this.state.vmName, + connectionName: vm.connectionName, + vmName: vm.name, vncAddress: this.state.vncAddress || "", vncPort: this.state.vncPort || "", vncPassword: this.state.vncPassword || "", @@ -72,7 +78,7 @@ export class EditVNCModal extends React.Component { domainChangeVncSettings(vncParams) .then(() => { - domainGet({ connectionName: this.state.connectionName, id: this.state.vmId }); + domainGet({ connectionName: vm.connectionName, id: vm.id }); Dialogs.close(); }) .catch((exc) => { @@ -82,21 +88,21 @@ export class EditVNCModal extends React.Component { render() { const Dialogs = this.context; - const { idPrefix } = this.props; + const { idPrefix, vm } = this.props; const defaultBody = (
e.preventDefault()} isHorizontal> - + ); - const showWarning = () => { - }; return ( + + + ); + } + + let vnc_info; + let vnc_action; + + if (!vnc) { + vnc_info = _("not supported"); + vnc_action = ( + + ); + } else { + if (vnc.port == -1) + vnc_info = _("VNC, dynamic port"); + else + vnc_info = cockpit.format(_("VNC, port $0"), vnc.port); + + vnc_action = ( + + ); + } + + return ( + <> +

+ { + vm.state == "running" + ? _("Shut down and restart the virtual machine to access the graphical console.") + : _("Please start the virtual machine to access its console.") + } +

+
+
+ + + {_("Graphical console:")} {vnc_info} + + + {vnc_action} + + +
+ + ); +}; + class Vnc extends React.Component { constructor(props) { super(props); @@ -115,9 +195,18 @@ class Vnc extends React.Component { } render() { - const { consoleDetail, connectionName, vmName, vmId, onAddErrorNotification, isExpanded } = this.props; + const { consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded } = this.props; const { path, isActionOpen } = this.state; - if (!consoleDetail || !path) { + + if (!consoleDetail) { + return ( +
+ +
+ ); + } + + if (!path) { // postpone rendering until consoleDetail is known and channel ready return null; } @@ -129,11 +218,11 @@ class Vnc extends React.Component { id={cockpit.format("ctrl-alt-$0", keyName)} key={cockpit.format("ctrl-alt-$0", keyName)} onClick={() => { - return domainSendKey({ connectionName, id: vmId, keyCodes: [Enum.KEY_LEFTCTRL, Enum.KEY_LEFTALT, Enum[cockpit.format("KEY_$0", keyName.toUpperCase())]] }) + 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, vmName), + text: cockpit.format(_("Failed to send key Ctrl+Alt+$0 to VM $1"), keyName, vm.name), detail: ex.message, - resourceId: vmId, + resourceId: vm.id, })); }}> {cockpit.format(_("Ctrl+Alt+$0"), keyName)} @@ -147,10 +236,10 @@ class Vnc extends React.Component { ]; const additionalButtons = [ ( this.setState({ isActionOpen: !isActionOpen })}> {_("Send key")} diff --git a/src/components/vm/consoles/vncBody.jsx b/src/components/vm/consoles/vncBody.jsx index 202f6bd32..306a096fe 100644 --- a/src/components/vm/consoles/vncBody.jsx +++ b/src/components/vm/consoles/vncBody.jsx @@ -21,8 +21,10 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { FormGroup, FormHelperText, HelperText, HelperTextItem, - InputGroup, TextInput, Button, Checkbox, + Grid, GridItem, + InputGroup, TextInput, Button, Checkbox } from "@patternfly/react-core"; +import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons"; import cockpit from 'cockpit'; 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/test/check-machines-consoles b/test/check-machines-consoles index 3b8daf778..ab875113a 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 @@ -324,6 +325,59 @@ fullscreen=0 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) + + # "Console" card shows empty state + + b.wait_in_text(f"#vm-{name}-consoles .pf-v5-c-empty-state", "Graphical console not supported") + + # Shut down machine, card shows a button to add VNC + + self.performAction("subVmTest1", "forceOff") + b.wait_in_text("#vm-not-running-message", "Graphical console: not supported") + b.click("#vm-not-running-message button:contains(Add support)") + + b.wait_visible("#add-vnc-dialog") + b.set_input_text("#add-vnc-address", "0.0.0.0") + b.set_input_text("#add-vnc-port", "5000") + b.click("#add-vnc-add") + b.wait_visible("#add-vnc-dialog .pf-m-error:contains('Port must be 5900 or larger.')") + b.set_input_text("#add-vnc-port", "5901") + b.click("#add-vnc-add") + b.wait_not_present("#add-vnc-dialog") + + b.wait_in_text("#vm-not-running-message", "Graphical console: supported, port 5901") + + root = ET.fromstring(self.machine.execute(f"virsh dumpxml {name}")) + graphics = root.find('devices').findall('graphics') + self.assertEqual(len(graphics), 1) + self.assertEqual(graphics[0].get('port'), "5901") + self.assertEqual(graphics[0].get('listen'), "0.0.0.0") + + b.click("#vm-not-running-message button:contains(Edit)") + b.wait_visible("#edit-vnc-dialog") + b.set_input_text("#edit-vnc-address", "") + 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 {name}")) + graphics = root.find('devices').findall('graphics') + self.assertEqual(len(graphics), 1) + self.assertEqual(graphics[0].get('port'), "-1") + self.assertEqual(graphics[0].get('listen'), None) + if __name__ == '__main__': testlib.test_main() From 32319ca15daa343e60a83260428d49c7b41def26 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Thu, 30 Jan 2025 15:14:12 +0200 Subject: [PATCH 05/10] 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, 46 insertions(+), 15 deletions(-) diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx index 88b3d7360..45eb232b0 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -20,6 +20,9 @@ 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 { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card'; +import { ExpandIcon, HelpIcon } from '@patternfly/react-icons'; import SerialConsole from './serialConsole.jsx'; import Vnc, { VncState } from './vnc.jsx'; @@ -30,6 +33,7 @@ import { domainDesktopConsole, domainSerialConsoleCommand } from '../../../libvirtApi/domain.js'; +import { vmId } from "../../../helpers.js"; import './consoles.css'; @@ -148,9 +152,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 06/10] 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 d16f228f8fca20ab02470f8f0c5939efd7fec22b Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Wed, 29 Jan 2025 12:47:41 +0200 Subject: [PATCH 07/10] 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} /> }, { From 4885b1c92f2374d0c6835ae539bec810a3ee6d40 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Tue, 4 Feb 2025 13:29:05 +0200 Subject: [PATCH 08/10] WIP - Get mvollmer:lib-kebab-drills --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 90cb353fd..ba8b5a90f 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ COCKPIT_REPO_FILES = \ $(NULL) COCKPIT_REPO_URL = https://github.com/cockpit-project/cockpit.git -COCKPIT_REPO_COMMIT = 8ba240136c7bea2a29d7eb3b4f98fd9a387e5d99 # 332 + 19 commits +COCKPIT_REPO_COMMIT = 403e669037e7eea71fc3e40060b6271ddfc9cc7e # mvollmer:lib-kebab-drills $(COCKPIT_REPO_FILES): $(COCKPIT_REPO_STAMP) COCKPIT_REPO_TREE = '$(strip $(COCKPIT_REPO_COMMIT))^{tree}' From 86f5f21ea8b435fd1f5f8b1b229d593d7a229677 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Tue, 4 Feb 2025 09:24:29 +0200 Subject: [PATCH 09/10] consoles: Use a drilldown for sending keys This unclutters the menu and makes the other things easier to spot. --- src/components/vm/consoles/vnc.jsx | 45 ++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/components/vm/consoles/vnc.jsx b/src/components/vm/consoles/vnc.jsx index 1bd87ed2f..28cfb7f6a 100644 --- a/src/components/vm/consoles/vnc.jsx +++ b/src/components/vm/consoles/vnc.jsx @@ -23,6 +23,7 @@ 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 { MenuItem, DrilldownMenu } from "@patternfly/react-core/dist/esm/components/Menu"; 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"; @@ -315,10 +316,28 @@ class Vnc extends React.Component { ); }; const dropdownItems = [ - ...['Delete', 'Backspace'].map(key => renderDropdownItem(key)), - , - ...[...Array(12).keys()].map(key => renderDropdownItem(cockpit.format("F$0", key + 1))), - , + + { + [ + + {_("Send key")} + , + , + ...['Delete', 'Backspace'].map(key => renderDropdownItem(key)), + , + ...[...Array(12).keys()].map(key => renderDropdownItem(cockpit.format("F$0", key + 1))), + ] + } + + } + > + {_("Send key")} + , { (connected && consoleDetail) && } From 5c152a3bd7c1c938578edfc70a721a30e3f6e831 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Tue, 4 Feb 2025 14:36:24 +0200 Subject: [PATCH 10/10] consoles: Allow control over scaling and resizing behavior --- src/components/vm/consoles/vnc.jsx | 55 +++++++++++++++++++----------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/components/vm/consoles/vnc.jsx b/src/components/vm/consoles/vnc.jsx index 28cfb7f6a..146f34132 100644 --- a/src/components/vm/consoles/vnc.jsx +++ b/src/components/vm/consoles/vnc.jsx @@ -21,9 +21,9 @@ 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 { Dropdown, DropdownItem, DropdownList, DropdownGroup } from "@patternfly/react-core/dist/esm/components/Dropdown"; import { MenuToggle } from "@patternfly/react-core/dist/esm/components/MenuToggle"; -import { MenuItem, DrilldownMenu } from "@patternfly/react-core/dist/esm/components/Menu"; +import { DrilldownMenu } from "@patternfly/react-core/dist/esm/components/Menu"; 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"; @@ -31,6 +31,7 @@ import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/ 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 { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/components/ToggleGroup/index.js"; import { useDialogs, DialogsContext } from 'dialogs.jsx'; import { KebabDropdown } from 'cockpit-components-dropdown.jsx'; @@ -227,6 +228,7 @@ class Vnc extends React.Component { this.state = { path: undefined, connected: true, + size_mode: "local", }; this.connect = this.connect.bind(this); @@ -289,7 +291,7 @@ class Vnc extends React.Component { consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded, connectionAddress, spiceDetail, } = this.props; - const { path, connected } = this.state; + const { path, connected, size_mode } = this.state; function edit_vnc() { Dialogs.show( { [ - + {_("Send key")} - , + , , ...['Delete', 'Backspace'].map(key => renderDropdownItem(key)), , @@ -338,18 +340,33 @@ class Vnc extends React.Component { > {_("Send key")} , - - {_("Edit VNC server settings")} - , - this.setState({ connected: false })}> - {_("Disconnect")} - , + , + + this.setState({ size_mode: null })}> + {_("No scaling or resizing")} + + this.setState({ size_mode: "local" })}> + {_("Local scaling")} + + this.setState({ size_mode: "remote" })}> + {_("Remote resizing")} + + , + , + + + {_("Edit settings")} + + this.setState({ connected: false })}> + {_("Disconnect")} + + , ]; const detail = ( @@ -432,8 +449,8 @@ class Vnc extends React.Component { onInitFailed={this.onInitFailed} textConnecting={_("Connecting")} consoleContainerId={isExpanded ? "vnc-display-container-expanded" : "vnc-display-container-minimized"} - resizeSession - scaleViewport + scaleViewport={size_mode == "local"} + resizeSession={size_mode == "remote"} /> :