diff --git a/src/components/common/needsShutdown.jsx b/src/components/common/needsShutdown.jsx index ae9306b2c..6f1f47734 100644 --- a/src/components/common/needsShutdown.jsx +++ b/src/components/common/needsShutdown.jsx @@ -81,6 +81,10 @@ export function needsShutdownSpice(vm) { return vm.hasSpice !== vm.inactiveXML.hasSpice; } +export function needsShutdownVideo(vm, video) { + return false; +} + export function getDevicesRequiringShutdown(vm) { if (!vm.persistent) return []; diff --git a/src/components/vm/consoles/consoles.css b/src/components/vm/consoles/consoles.css index 53ac629af..d1d3d7a49 100644 --- a/src/components/vm/consoles/consoles.css +++ b/src/components/vm/consoles/consoles.css @@ -5,7 +5,7 @@ .pf-v5-c-console__vnc > div, .pf-v5-c-console__vnc > div > div { inline-size: 100%; - block-size: 100%; + block-size: 90%; } .pf-v5-c-console__actions { diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx index 578f2a12b..a29d03fd4 100644 --- a/src/components/vm/consoles/consoles.jsx +++ b/src/components/vm/consoles/consoles.jsx @@ -19,14 +19,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import cockpit from 'cockpit'; -import { AccessConsoles } from "@patternfly/react-console"; +import { vmId } from "../../../helpers.js"; +import { Button, Text, TextContent, TextVariants } from '@patternfly/react-core'; +import { DialogsContext } from 'dialogs.jsx'; +import { AddVNC } from './vncAdd.jsx'; +import { EditVNCModal } from './vncEdit.jsx'; import SerialConsole from './serialConsole.jsx'; import Vnc from './vnc.jsx'; import DesktopConsole from './desktopConsole.jsx'; import { + domainAttachVnc, domainCanConsole, domainDesktopConsole, + domainGet, domainSerialConsoleCommand } from '../../../libvirtApi/domain.js'; @@ -43,11 +49,18 @@ const VmNotRunning = () => { }; class Consoles extends React.Component { + static contextType = DialogsContext; + constructor (props) { super(props); this.state = { serial: props.vm.displays && props.vm.displays.filter(display => display.type == 'pty'), + selectedConsole: this.getDefaultConsole(props.vm), + addVncInProgress: false, + vncAddress: "", + vncPort: "", + vncPassword: "", }; this.getDefaultConsole = this.getDefaultConsole.bind(this); @@ -61,6 +74,10 @@ class Consoles extends React.Component { if (newSerial.length !== oldSerial.length || oldSerial.some((pty, index) => pty.alias !== newSerial[index].alias)) return { serial: newSerial }; + if (nextProps.selectedConsole !== prevState.selectedConsole) { + return { selectedConsole: nextProps.selectedConsole }; + } + return null; } @@ -91,14 +108,72 @@ class Consoles extends React.Component { domainDesktopConsole({ name: vm.name, id: vm.id, connectionName: vm.connectionName, consoleDetail: vm.displays.find(display => display.type == type) }); } + 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(_("Video device settings could not be saved"), exc.message)) + .finally(() => this.setState({ addVncInProgress: false })); + } + render () { + const Dialogs = this.context; const { vm, onAddErrorNotification, isExpanded } = this.props; - const { serial } = this.state; + const { serial, selectedConsole } = this.state; const spice = vm.displays && vm.displays.find(display => display.type == 'spice'); const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc'); + const id = vmId(vm.name); + + const openVncAdd = () => { + Dialogs.show(); + }; + + const openVncEdit = () => { + Dialogs.show(); + }; if (!domainCanConsole || !domainCanConsole(vm.state)) { - return (); + return ( + <> + + {selectedConsole === 'VncConsole' && !vnc && ( + + )} + {selectedConsole === 'VncConsole' && vnc && ( + + + {`VNC address=${vnc.address} port=${vnc.port === '-1' ? 'auto' : vnc.port} `} + + + + )} + + ); } const onDesktopConsole = () => { // prefer spice over vnc @@ -106,36 +181,43 @@ class Consoles extends React.Component { }; return ( - - {serial.map((pty, idx) => ())} - {vnc && - } - {(vnc || spice) && - } - + <> + {selectedConsole === 'SerialConsole' && serial.map((pty, idx) => ( + + ))} + {selectedConsole === 'VncConsole' && !vnc && ( + + )} + {selectedConsole === 'VncConsole' && vnc && ( + + )} + {selectedConsole === 'DesktopViewer' && (vnc || spice) && ( + + )} + ); } } Consoles.propTypes = { vm: PropTypes.object.isRequired, onAddErrorNotification: PropTypes.func.isRequired, + selectedConsole: PropTypes.string.isRequired, }; export default Consoles; diff --git a/src/components/vm/consoles/vnc.jsx b/src/components/vm/consoles/vnc.jsx index 684cd64ac..ccc75711c 100644 --- a/src/components/vm/consoles/vnc.jsx +++ b/src/components/vm/consoles/vnc.jsx @@ -17,14 +17,18 @@ * along with Cockpit; If not, see . */ import React from 'react'; +import PropTypes from 'prop-types'; import cockpit from 'cockpit'; +import { logDebug } from "../../../helpers.js"; 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, Text, TextContent, TextVariants } from '@patternfly/react-core'; +import { DialogsContext } from 'dialogs.jsx'; +import { EditVNCModal } from './vncEdit.jsx'; -import { logDebug } from '../../../helpers.js'; import { domainSendKey } from '../../../libvirtApi/domain.js'; const _ = cockpit.gettext; @@ -49,17 +53,25 @@ const Enum = { }; class Vnc extends React.Component { + static contextType = DialogsContext; + constructor(props) { super(props); + this.state = { path: undefined, isActionOpen: false, + vncAddress: props.consoleDetail.address || '', + vncPort: props.consoleDetail.port || '', + vncPassword: props.consoleDetail.password || '', }; this.connect = this.connect.bind(this); this.onDisconnected = this.onDisconnected.bind(this); this.onInitFailed = this.onInitFailed.bind(this); this.onExtraKeysDropdownToggle = this.onExtraKeysDropdownToggle.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + this.dialogErrorSet = this.dialogErrorSet.bind(this); } connect(props) { @@ -114,7 +126,17 @@ class Vnc extends React.Component { this.setState({ isActionOpen: false }); } + onValueChanged(key, value) { + const stateDelta = { [key]: value }; + this.setState(stateDelta); + } + + dialogErrorSet(text, detail) { + this.setState({ dialogError: text, dialogErrorDetail: detail }); + } + render() { + const Dialogs = this.context; const { consoleDetail, connectionName, vmName, vmId, onAddErrorNotification, isExpanded } = this.props; const { path, isActionOpen } = this.state; if (!consoleDetail || !path) { @@ -161,8 +183,17 @@ class Vnc extends React.Component { ]; + const openVncEdit = () => { + Dialogs.show(); + }; + return ( - + + /> + + + {`VNC address=${consoleDetail.address} port=${consoleDetail.port} `} + + + + ); } } // TODO: define propTypes +Vnc.propTypes = { + vmName: PropTypes.string.isRequired, + vmId: PropTypes.string.isRequired, + connectionName: PropTypes.string.isRequired, + consoleDetail: PropTypes.object.isRequired, + onAddErrorNotification: PropTypes.func.isRequired, + isExpanded: PropTypes.string.isRequired, +}; export default Vnc; 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/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx index 98fbfeb12..61fe6066a 100644 --- a/src/components/vm/vmDetailsPage.jsx +++ b/src/components/vm/vmDetailsPage.jsx @@ -29,6 +29,7 @@ import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@patternfly/r import { Page, PageGroup, PageBreadcrumb, PageSection, PageSectionVariants } from "@patternfly/react-core/dist/esm/components/Page"; import { Popover } from "@patternfly/react-core/dist/esm/components/Popover"; import { ExpandIcon, HelpIcon } from '@patternfly/react-icons'; +import { Flex, FlexItem, ToggleGroup, ToggleGroupItem } from '@patternfly/react-core'; import { WithDialogs } from 'dialogs.jsx'; import { vmId } from "../../helpers.js"; @@ -107,6 +108,23 @@ export const VmDetailsPage = ({ ); } + const [selectedConsole, setSelectedConsole] = React.useState(() => { + if (vm.displays) { + if (vm.displays.find(display => display.type == "pty")) { + return 'SerialConsole'; + } + if (vm.displays.find(display => display.type == "vnc")) { + return 'VncConsole'; + } + if (vm.displays.find(display => display.type == "spice")) { + return 'DesktopViewer'; + } + } + + // no console defined + return null; + }); + const cardContents = [ { id: `${vmId(vm.name)}-overview`, @@ -131,17 +149,50 @@ export const VmDetailsPage = ({ id: `${vmId(vm.name)}-consoles`, className: "consoles-card", title: _("Console"), - actions: vm.state != "shut off" - ? - : null, + actions: ( + + + + {vm.displays && vm.displays.filter(display => display.type === 'pty').length > 0 && ( + setSelectedConsole('SerialConsole')} + /> + )} + setSelectedConsole('VncConsole')} + /> + {(vm.displays && (vm.displays.find(display => display.type === 'vnc') || vm.displays.find(display => display.type === 'spice'))) && ( + setSelectedConsole('DesktopViewer')} + /> + )} + + + {vm.state !== "shut off" && ( + + + + )} + + ), body: , + onAddErrorNotification={onAddErrorNotification} + selectedConsole={selectedConsole} />, }] : []), { @@ -168,7 +219,7 @@ export const VmDetailsPage = ({ title: _("Host devices"), actions: , body: , - } + }, ]; if (vm.snapshots !== -1 && vm.snapshots !== undefined) { cardContents.push({ diff --git a/src/components/vm/vmDetailsPage.scss b/src/components/vm/vmDetailsPage.scss index 93a3686f1..4f565056e 100644 --- a/src/components/vm/vmDetailsPage.scss +++ b/src/components/vm/vmDetailsPage.scss @@ -44,7 +44,7 @@ } } - .networks-card, .disks-card, .snapshots-card, .hostdevs-card, .filesystems-card { + .networks-card, .disks-card, .snapshots-card, .hostdevs-card, .filesystems-card, .videodev-card { grid-column: 1 / -1; } diff --git a/src/libvirtApi/domain.js b/src/libvirtApi/domain.js index 5c9aa0dfd..3b98b367a 100644 --- a/src/libvirtApi/domain.js +++ b/src/libvirtApi/domain.js @@ -1069,3 +1069,39 @@ export async function domainAddTPM({ connectionName, vmName }) { return Promise.reject(ex); }); } + +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 domainDetachVideo({ connectionName, index, vmName, persistent }) { + const options = { err: "message" }; + const args = ['virt-xml', '-c', `qemu:///${connectionName}`, vmName, '--remove-device', '--graphics', `${index + 1}`]; + + 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); +}