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;
}
diff --git a/src/components/vm/consoles/vncAdd.jsx b/src/components/vm/consoles/vncAdd.jsx
new file mode 100644
index 000000000..dc015c660
--- /dev/null
+++ b/src/components/vm/consoles/vncAdd.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 { 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';
+
+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,
+ 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 };
+
+ 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: 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..69792f910
--- /dev/null
+++ b/src/components/vm/consoles/vncBody.jsx
@@ -0,0 +1,100 @@
+/*
+ * 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,
+ Grid, GridItem,
+ InputGroup, TextInput, Button
+} 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('vncAddress', event.target.value)} />
+
+
+
+
+ onValueChanged('vncPort', event.target.value)} />
+ { validationErrors.vncPort &&
+
+
+ {validationErrors.vncPort}
+
+
+ }
+
+
+
+
+
+ onValueChanged('vncPassword', event.target.value)} />
+
+
+
+ >
+ );
+};
+
+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
new file mode 100644
index 000000000..bda5da247
--- /dev/null
+++ b/src/components/vm/consoles/vncEdit.jsx
@@ -0,0 +1,136 @@
+/*
+ * 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';
+
+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: 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: { [key]: null } };
+ this.setState(stateDelta);
+ }
+
+ dialogErrorSet(text, detail) {
+ this.setState({ dialogError: text, dialogErrorDetail: detail });
+ }
+
+ 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,
+ 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/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);
+}