diff --git a/src/components/common/needsShutdown.jsx b/src/components/common/needsShutdown.jsx
index a9116cab0..0ea7eda57 100644
--- a/src/components/common/needsShutdown.jsx
+++ b/src/components/common/needsShutdown.jsx
@@ -85,6 +85,26 @@ export function needsShutdownSpice(vm) {
return vm.hasSpice !== vm.inactiveXML.hasSpice;
}
+export function needsShutdownVnc(vm) {
+ function find_vnc(v) {
+ return v.displays && v.displays.find(d => d.type == "vnc");
+ }
+
+ const active_vnc = find_vnc(vm);
+ const inactive_vnc = find_vnc(vm.inactiveXML);
+
+ if (inactive_vnc) {
+ if (!active_vnc)
+ return true;
+ if (inactive_vnc.port != -1 && active_vnc.port != inactive_vnc.port)
+ return true;
+ if (active_vnc.password != inactive_vnc.password)
+ return true;
+ }
+
+ return false;
+}
+
export function getDevicesRequiringShutdown(vm) {
if (!vm.persistent)
return [];
@@ -125,6 +145,10 @@ export function getDevicesRequiringShutdown(vm) {
if (needsShutdownSpice(vm))
devices.push(_("SPICE"));
+ // VNC
+ if (needsShutdownVnc(vm))
+ devices.push(_("VNC"));
+
// TPM
if (needsShutdownTpm(vm))
devices.push(_("TPM"));
diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx
index 1827c4bb1..c09c7e8b4 100644
--- a/src/components/vm/consoles/consoles.jsx
+++ b/src/components/vm/consoles/consoles.jsx
@@ -22,8 +22,9 @@ import cockpit from 'cockpit';
import { AccessConsoles } from "@patternfly/react-console";
import SerialConsole from './serialConsole.jsx';
-import Vnc from './vnc.jsx';
+import Vnc, { VncState } from './vnc.jsx';
import DesktopConsole from './desktopConsole.jsx';
+
import {
domainCanConsole,
domainDesktopConsole,
@@ -34,14 +35,6 @@ import './consoles.css';
const _ = cockpit.gettext;
-const VmNotRunning = () => {
- return (
-
- {_("Please start the virtual machine to access its console.")}
-
- );
-};
-
class Consoles extends React.Component {
constructor (props) {
super(props);
@@ -81,8 +74,9 @@ class Consoles extends React.Component {
return 'SerialConsole';
}
- // no console defined
- return null;
+ // no console defined, but the VncConsole is always there and
+ // will instruct people how to enable it for real.
+ return 'VncConsole';
}
onDesktopConsoleDownload (type) {
@@ -114,9 +108,14 @@ class Consoles extends React.Component {
const { serial } = 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 inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc');
if (!domainCanConsole || !domainCanConsole(vm.state)) {
- return ( );
+ return (
+
+
+
+ );
}
const onDesktopConsole = () => { // prefer spice over vnc
@@ -134,14 +133,12 @@ class Consoles extends React.Component {
connectionName={vm.connectionName}
vmName={vm.name}
spawnArgs={domainSerialConsoleCommand({ vm, alias: pty.alias })} />))}
- {vnc &&
}
+ isExpanded={isExpanded} />
{(vnc || spice) &&
{
+ const Dialogs = useDialogs();
+
+ function add_vnc() {
+ Dialogs.show( );
+ }
+
+ function edit_vnc() {
+ Dialogs.show( );
+ }
+
+ if (vm.state == "running" && !vnc) {
+ return (
+
+
+ {_("Graphical support not enabled.")}
+
+
+
+ {_("Add VNC")}
+
+
+
+ );
+ }
+
+ let vnc_info;
+ let vnc_action;
+
+ if (!vnc) {
+ vnc_info = _("not supported");
+ vnc_action = (
+
+ {_("Add VNC")}
+
+ );
+ } else {
+ if (vnc.port == -1)
+ vnc_info = _("VNC, dynamic port");
+ else
+ vnc_info = cockpit.format(_("VNC, port $0"), vnc.port);
+
+ vnc_action = (
+
+ {_("Edit")}
+
+ );
+ }
+
+ 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/vncAdd.jsx b/src/components/vm/consoles/vncAdd.jsx
new file mode 100644
index 000000000..93bf18f94
--- /dev/null
+++ b/src/components/vm/consoles/vncAdd.jsx
@@ -0,0 +1,133 @@
+/*
+ * 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, 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 {
+ static contextType = DialogsContext;
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ dialogError: undefined,
+ vncCustomPort: false,
+ vncPort: "",
+ vncPassword: "",
+ addVncInProgress: false,
+ validationErrors: { },
+ };
+ 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, validationErrors: { } };
+ this.setState(stateDelta);
+ }
+
+ dialogErrorSet(text, detail) {
+ this.setState({ dialogError: text, dialogErrorDetail: detail });
+ }
+
+ add() {
+ 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,
+ vmName: vm.name,
+ vncAddress: "",
+ vncPort: this.state.vncCustomPort ? 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, vm } = this.props;
+
+ const defaultBody = (
+
+ );
+
+ return (
+
+
+ {_("Add")}
+
+
+ {_("Cancel")}
+
+ >
+ }>
+ { vm.state === 'running' && !this.state.dialogError && }
+ {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..1c9494189
--- /dev/null
+++ b/src/components/vm/consoles/vncBody.jsx
@@ -0,0 +1,93 @@
+/*
+ * 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, { useState } from 'react';
+import PropTypes from 'prop-types';
+import {
+ FormGroup, FormHelperText, HelperText, HelperTextItem,
+ InputGroup, TextInput, Button, Checkbox
+} from "@patternfly/react-core";
+import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
+
+import cockpit from 'cockpit';
+
+const _ = cockpit.gettext;
+
+export const VncRow = ({ idPrefix, onValueChanged, dialogValues, validationErrors }) => {
+ const [showPassword, setShowPassword] = useState(false);
+
+ return (
+ <>
+
+ onValueChanged('vncCustomPort', checked)} />
+ { dialogValues.vncCustomPort &&
+ onValueChanged('vncPort', event.target.value)} />
+ }
+ { dialogValues.vncCustomPort && validationErrors.vncPort &&
+
+
+ {validationErrors.vncPort}
+
+
+ }
+
+
+
+ onValueChanged('vncPassword', event.target.value)} />
+ setShowPassword(!showPassword)}>
+ { showPassword ? : }
+
+
+
+ >
+ );
+};
+
+export function validateDialogValues(values) {
+ const res = { };
+
+ if (values.vncCustomPort && (!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
new file mode 100644
index 000000000..7ea701be0
--- /dev/null
+++ b/src/components/vm/consoles/vncEdit.jsx
@@ -0,0 +1,131 @@
+/*
+ * 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, validateDialogValues } from './vncBody.jsx';
+import { domainChangeVncSettings, domainGet } from '../../../libvirtApi/domain.js';
+import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx';
+
+const _ = cockpit.gettext;
+
+export class EditVNCModal extends React.Component {
+ static contextType = DialogsContext;
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ dialogError: undefined,
+ saveDisabled: false,
+ vncCustomPort: Number(props.consoleDetail.port) != -1,
+ vncPort: Number(props.consoleDetail.port) == -1 ? "" : props.consoleDetail.port || "",
+ vncPassword: props.consoleDetail.password || "",
+ validationErrors: { },
+ };
+
+ 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, validationErrors: { } };
+ this.setState(stateDelta);
+ }
+
+ dialogErrorSet(text, detail) {
+ this.setState({ dialogError: text, dialogErrorDetail: detail });
+ }
+
+ save() {
+ const Dialogs = this.context;
+ const { vm } = this.props;
+
+ const errors = validateDialogValues(this.state);
+ if (errors) {
+ this.setState({ validationErrors: errors });
+ return;
+ }
+
+ const vncParams = {
+ connectionName: vm.connectionName,
+ vmName: vm.name,
+ vncAddress: this.props.consoleDetail.address || "",
+ vncPort: this.state.vncCustomPort ? this.state.vncPort : "",
+ vncPassword: this.state.vncPassword || "",
+ };
+
+ domainChangeVncSettings(vncParams)
+ .then(() => {
+ domainGet({ connectionName: vm.connectionName, id: vm.id });
+ Dialogs.close();
+ })
+ .catch((exc) => {
+ this.dialogErrorSet(_("VNC settings could not be saved"), exc.message);
+ });
+ }
+
+ render() {
+ const Dialogs = this.context;
+ const { idPrefix, vm } = this.props;
+
+ const defaultBody = (
+
+ );
+
+ return (
+
+
+ {_("Save")}
+
+
+ {_("Cancel")}
+
+ >
+ }>
+ <>
+ { vm.state === 'running' && !this.state.dialogError && }
+ {this.state.dialogError && }
+ {defaultBody}
+ >
+
+ );
+ }
+}
+EditVNCModal.propTypes = {
+ idPrefix: PropTypes.string.isRequired,
+ vm: PropTypes.object.isRequired,
+ consoleDetail: PropTypes.object.isRequired,
+};
+
+export default EditVNCModal;
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"
- ? {
- const urlOptions = { name: vm.name, connection: vm.connectionName };
- return cockpit.location.go(["vm", "console"], { ...cockpit.location.options, ...urlOptions });
- }}
- icon={ }
- iconPosition="right">{_("Expand")}
- : null,
- body: ,
- }]
- : []),
+ {
+ id: `${vmId(vm.name)}-consoles`,
+ className: "consoles-card",
+ title: _("Console"),
+ actions: vm.state != "shut off"
+ ? {
+ const urlOptions = { name: vm.name, connection: vm.connectionName };
+ return cockpit.location.go(["vm", "console"], { ...cockpit.location.options, ...urlOptions });
+ }}
+ icon={ }
+ iconPosition="right">{_("Expand")}
+ : null,
+ body: ,
+ },
{
id: `${vmId(vm.name)}-disks`,
className: "disks-card",
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);
+}
diff --git a/test/check-machines-consoles b/test/check-machines-consoles
index 3b8daf778..17b848515 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,104 @@ 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 support not enabled.")
+ b.assert_pixels(f"#vm-{name}-consoles", "no-vnc")
+
+ b.click(f"#vm-{name}-consoles .pf-v5-c-empty-state button:contains(Add VNC)")
+
+ b.wait_visible("#add-vnc-dialog")
+ b.set_checked("#add-vnc-autoport", val=True)
+ 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.assert_pixels("#add-vnc-dialog", "add")
+ b.set_input_text("#add-vnc-port", "100000000000") # for testing failed libvirt calls
+ b.set_input_text("#add-vnc-password", "foobar")
+ b.wait_attr("#add-vnc-password", "type", "password")
+ b.click("#add-vnc-dialog .pf-v5-c-input-group button")
+ b.wait_attr("#add-vnc-password", "type", "text")
+ b.click("#add-vnc-add")
+ b.wait_in_text("#add-vnc-dialog", "VNC device settings could not be saved")
+ b.wait_in_text("#add-vnc-dialog", "cannot parse vnc port 100000000000")
+ b.set_input_text("#add-vnc-port", "5901")
+ b.click("#add-vnc-add")
+ b.wait_not_present("#add-vnc-dialog")
+
+ b.wait_in_text(f"#vm-{name}-consoles .pf-v5-c-console", "Shut down and restart")
+ b.wait_in_text(f"#vm-{name}-consoles .pf-v5-c-console", "Graphical console: VNC, port 5901")
+ b.wait_visible(f"#vm-{name}-needs-shutdown")
+ b.assert_pixels(f"#vm-{name}-consoles", "needs-shutdown")
+
+ root = ET.fromstring(self.machine.execute(f"virsh dumpxml --inactive --security-info {name}"))
+ graphics = root.find('devices').findall('graphics')
+ self.assertEqual(len(graphics), 1)
+ self.assertEqual(graphics[0].get('port'), "5901")
+ self.assertEqual(graphics[0].get('passwd'), "foobar")
+
+ b.click(f"#vm-{name}-consoles .pf-v5-c-console button:contains(Edit)")
+ b.wait_visible("#edit-vnc-dialog")
+ b.wait_val("#edit-vnc-port", "5901")
+ b.wait_val("#edit-vnc-password", "foobar")
+ b.assert_pixels("#edit-vnc-dialog", "edit")
+ b.set_input_text("#edit-vnc-port", "100000000000") # for testing failed libvirt calls
+ b.click("#edit-vnc-save")
+ b.wait_in_text("#edit-vnc-dialog", "VNC settings could not be saved")
+ b.wait_in_text("#edit-vnc-dialog", "cannot parse vnc port 100000000000")
+ b.set_checked("#edit-vnc-autoport", val=False)
+ b.click("#edit-vnc-save")
+ b.wait_not_present("#edit-vnc-dialog")
+
+ b.wait_in_text(f"#vm-{name}-consoles .pf-v5-c-console", "Graphical console: VNC, dynamic port")
+
+ root = ET.fromstring(self.machine.execute(f"virsh dumpxml --inactive --security-info {name}"))
+ graphics = root.find('devices').findall('graphics')
+ self.assertEqual(len(graphics), 1)
+ self.assertEqual(graphics[0].get('port'), "-1")
+ self.assertEqual(graphics[0].get('passwd'), "foobar")
+
+ # Shut down machine
+
+ self.performAction("subVmTest1", "forceOff")
+ b.wait_in_text("#vm-not-running-message", "Graphical console: VNC, dynamic port")
+ b.assert_pixels(f"#vm-{name}-consoles", "shutoff")
+
+ # Remove VNC from the outside and do the whole dance again
+
+ self.machine.execute(f"virt-xml --remove-device --graphics vnc {name}")
+ b.wait_in_text("#vm-not-running-message", "Graphical console: not supported")
+
+ b.click("#vm-not-running-message button:contains(Add VNC)")
+ b.wait_visible("#add-vnc-dialog")
+ b.click("#add-vnc-add")
+ b.wait_not_present("#add-vnc-dialog")
+ b.wait_in_text("#vm-not-running-message", "Graphical console: VNC, dynamic port")
+
+ b.click("#vm-not-running-message button:contains(Edit)")
+ b.wait_visible("#edit-vnc-dialog")
+ b.set_checked("#edit-vnc-autoport", val=True)
+ b.set_input_text("#edit-vnc-port", "5000")
+ b.click("#edit-vnc-save")
+ b.wait_visible("#edit-vnc-dialog .pf-m-error:contains('Port must be 5900 or larger.')")
+ b.set_input_text("#edit-vnc-port", "5900")
+ b.click("#edit-vnc-save")
+ b.wait_not_present("#edit-vnc-dialog")
+ b.wait_in_text("#vm-not-running-message", "Graphical console: VNC, port 5900")
+
if __name__ == '__main__':
testlib.test_main()
diff --git a/test/reference b/test/reference
index fef884db2..405dc8e13 160000
--- a/test/reference
+++ b/test/reference
@@ -1 +1 @@
-Subproject commit fef884db2f402038be5b2f37e0df9d45a7aca4c2
+Subproject commit 405dc8e13d990ab893659e4ed12285c40f3304a1