From 1a727304754e5da5d8dea67ab1f8fc4117523e28 Mon Sep 17 00:00:00 2001 From: Shotaro-Kawaguchi Date: Thu, 16 Jan 2025 16:00:37 +0200 Subject: [PATCH 1/4] 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 61d10d016d110041812e8bd910432301f3683d3b Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Fri, 10 Jan 2025 16:37:18 +0200 Subject: [PATCH 2/4] consoles: Show VNC details also when VM is off So that people can edit them or add VNC if it is missing. --- src/components/vm/consoles/consoles.jsx | 66 ++++++++++++++++++++++--- src/components/vm/consoles/vnc.jsx | 14 +++++- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx index 578f2a12b..286e72633 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -20,10 +20,16 @@ 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 { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; +import { useDialogs } from 'dialogs.jsx'; import SerialConsole from './serialConsole.jsx'; import Vnc from './vnc.jsx'; import DesktopConsole from './desktopConsole.jsx'; +import { AddVNC } from './vncAdd.jsx'; +import { EditVNCModal } from './vncEdit.jsx'; + import { domainCanConsole, domainDesktopConsole, @@ -34,10 +40,59 @@ import './consoles.css'; const _ = cockpit.gettext; -const VmNotRunning = () => { +const VmNotRunning = ({ vm, vnc }) => { + const Dialogs = useDialogs(); + + function add_vnc() { + Dialogs.show(); + } + + function edit_vnc() { + Dialogs.show(); + } + + let vnc_info; + let vnc_action; + + if (!vnc) { + vnc_info = _("not supported"); + vnc_action = ( + + ); + } else { + if (vnc.port == -1) + vnc_info = _("supported"); + else + vnc_info = cockpit.format(_("supported, port $0"), vnc.port); + + vnc_action = ( + + ); + } + return (
- {_("Please start the virtual machine to access its console.")} +

{_("Please start the virtual machine to access its console.")}

+
+ + + {_("Graphical console:")} {vnc_info} + + + {vnc_action} + +
); }; @@ -98,7 +153,7 @@ class Consoles extends React.Component { const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc'); if (!domainCanConsole || !domainCanConsole(vm.state)) { - return (); + return (); } const onDesktopConsole = () => { // prefer spice over vnc @@ -109,21 +164,20 @@ class Consoles extends React.Component { {serial.map((pty, idx) => ())} - {vnc && } + isExpanded={isExpanded} /> {(vnc || spice) && + + + {_("Graphical console not supported. Shut down the virtual machine to add support.")} + + + + ); + } + if (!path) { // postpone rendering until consoleDetail is known and channel ready return null; } From f7ba00389f560800a26992271beee31331273a44 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Fri, 17 Jan 2025 13:32:35 +0200 Subject: [PATCH 3/4] consoles: Improve VNC dialogs - The dialogs talk about "server" and "listening" to make that clearer. - There are now placeholder texts to explain the defaults - We use the empty string instead "-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. --- src/components/vm/consoles/vncAdd.jsx | 19 +++-- src/components/vm/consoles/vncBody.jsx | 99 ++++++++++++++++++-------- src/components/vm/consoles/vncEdit.jsx | 23 ++++-- 3 files changed, 100 insertions(+), 41 deletions(-) diff --git a/src/components/vm/consoles/vncAdd.jsx b/src/components/vm/consoles/vncAdd.jsx index 457974c92..dc015c660 100644 --- a/src/components/vm/consoles/vncAdd.jsx +++ b/src/components/vm/consoles/vncAdd.jsx @@ -23,7 +23,7 @@ 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'; const _ = cockpit.gettext; @@ -40,6 +40,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 +61,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, @@ -84,15 +91,17 @@ export class AddVNC extends React.Component { const defaultBody = (
e.preventDefault()} isHorizontal> - + ); return ( + + + ); }; +export function validateDialogValues(values) { + const res = { }; + + console.log("port", JSON.stringify(values.vncPort), values.vncPort.match("^[0-9]+$")); + + if (values.vncPort == "") + ; // fine + else if (!values.vncPort.match("^[0-9]+$")) + res.vncPort = _("Port must be a positive number.") + else if (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..f98d3ea46 100644 --- a/src/components/vm/consoles/vncEdit.jsx +++ b/src/components/vm/consoles/vncEdit.jsx @@ -23,7 +23,7 @@ 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'; const _ = cockpit.gettext; @@ -41,8 +41,9 @@ export class EditVNCModal extends React.Component { 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 +52,7 @@ export class EditVNCModal extends React.Component { } onValueChanged(key, value) { - const stateDelta = { [key]: value }; + const stateDelta = { [key]: value, validationErrors: { [key]: null } }; this.setState(stateDelta); } @@ -62,6 +63,12 @@ export class EditVNCModal extends React.Component { save() { const Dialogs = this.context; + const errors = validateDialogValues(this.state); + if (errors) { + this.setState({ validationErrors: errors }); + return; + } + const vncParams = { connectionName: this.state.connectionName, vmName: this.state.vmName, @@ -86,9 +93,11 @@ export class EditVNCModal extends React.Component { const defaultBody = (
e.preventDefault()} isHorizontal> - + ); const showWarning = () => { @@ -96,7 +105,7 @@ export class EditVNCModal extends React.Component { return (