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 = (
+
+ );
+
+ 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 = (
+
+ );
+ 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);
+}