diff --git a/src/components/vm/consoles/consoles.css b/src/components/vm/consoles/consoles.css
index 53ac629af..927c2dc06 100644
--- a/src/components/vm/consoles/consoles.css
+++ b/src/components/vm/consoles/consoles.css
@@ -18,13 +18,21 @@
grid-template-rows: min-content 1fr;
}
+.vm-console-footer {
+ grid-area: 3 / 1 / 4 / 3;
+}
+
.consoles-page-expanded .actions-pagesection .pf-v5-c-page__main-body {
padding-block-end: 0;
}
-/* Hide send key button - there is not way to do that from the JS
+/* Hide standard VNC actions - there is not way to do that from the JS
* https://github.com/patternfly/patternfly-react/issues/3689
*/
-#pf-v5-c-console__send-shortcut {
+.pf-v5-c-console__actions-vnc {
display: none;
}
+
+.ct-remote-viewer-popover {
+ max-inline-size: 60ch;
+}
diff --git a/src/components/vm/consoles/consoles.jsx b/src/components/vm/consoles/consoles.jsx
index 45eb232b0..438d33043 100644
--- a/src/components/vm/consoles/consoles.jsx
+++ b/src/components/vm/consoles/consoles.jsx
@@ -23,6 +23,7 @@ import { AccessConsoles } from "@patternfly/react-console";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card';
import { ExpandIcon, HelpIcon } from '@patternfly/react-icons';
+import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup';
import SerialConsole from './serialConsole.jsx';
import Vnc, { VncState } from './vnc.jsx';
@@ -39,132 +40,118 @@ import './consoles.css';
const _ = cockpit.gettext;
-class Consoles extends React.Component {
- constructor (props) {
- super(props);
+export function console_default(vm) {
+ const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
+ const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc');
- this.state = {
- serial: props.vm.displays && props.vm.displays.filter(display => display.type == 'pty'),
- };
-
- this.getDefaultConsole = this.getDefaultConsole.bind(this);
- this.onDesktopConsoleDownload = this.onDesktopConsoleDownload.bind(this);
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- const oldSerial = prevState.serial;
- const newSerial = nextProps.vm.displays && nextProps.vm.displays.filter(display => display.type == 'pty');
-
- if (newSerial.length !== oldSerial.length || oldSerial.some((pty, index) => pty.alias !== newSerial[index].alias))
- return { serial: newSerial };
+ if (vnc || serials.length == 0)
+ return "vnc";
+ else
+ return "serial0";
+}
- return null;
+export function console_name(vm, type) {
+ if (!type)
+ type = console_default(vm);
+
+ if (type.startsWith("serial")) {
+ const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
+ if (serials.length == 1)
+ return _("Serial console");
+ const idx = Number(type.substr(6));
+ return cockpit.format(_("Serial console ($0)"), serials[idx]?.alias || idx);
+ } else if (type == "vnc") {
+ return _("Graphical console");
+ } else {
+ return _("Console");
}
+}
- getDefaultConsole () {
- const { vm } = this.props;
-
- if (vm.displays) {
- if (vm.displays.find(display => display.type == "vnc")) {
- return 'VncConsole';
- }
- if (vm.displays.find(display => display.type == "spice")) {
- return 'DesktopViewer';
- }
+function connection_address() {
+ let address;
+ if (cockpit.transport.host == "localhost") {
+ const app = cockpit.transport.application();
+ if (app.startsWith("cockpit+=")) {
+ address = app.substr(9);
+ } else {
+ address = window.location.hostname;
}
-
- const serialConsoleCommand = domainSerialConsoleCommand({ vm });
- if (serialConsoleCommand) {
- return 'SerialConsole';
+ } else {
+ address = cockpit.transport.host;
+ const pos = address.indexOf("@");
+ if (pos >= 0) {
+ address = address.substr(pos + 1);
}
-
- // no console defined, but the VncConsole is always there and
- // will instruct people how to enable it for real.
- return 'VncConsole';
}
+ return address;
+}
- onDesktopConsoleDownload (type) {
- const { vm } = this.props;
- // fire download of the .vv file
- const consoleDetail = vm.displays.find(display => display.type == type);
-
- let address;
- if (cockpit.transport.host == "localhost") {
- const app = cockpit.transport.application();
- if (app.startsWith("cockpit+=")) {
- address = app.substr(9);
- } else {
- address = window.location.hostname;
- }
- } else {
- address = cockpit.transport.host;
- const pos = address.indexOf("@");
- if (pos >= 0) {
- address = address.substr(pos + 1);
- }
- }
+function console_launch(vm, consoleDetail) {
+ // fire download of the .vv file
+ domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address: connection_address() } });
+}
+
+export const Console = ({ vm, config, type, onAddErrorNotification, isExpanded }) => {
+ let con = null;
- domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address } });
+ if (!type)
+ type = console_default(vm);
+
+ if (vm.state != "running") {
+ const vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc');
+ const spice = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'spice');
+ return ;
}
- render () {
- const { vm, onAddErrorNotification, isExpanded } = this.props;
- const { serial } = this.state;
- const spice = vm.displays && vm.displays.find(display => display.type == 'spice');
+ if (type.startsWith("serial")) {
+ const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
+ const idx = Number(type.substr(6));
+ if (serials.length > idx)
+ con = ;
+ } else if (type == "vnc") {
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');
+ const spice = vm.displays && vm.displays.find(display => display.type == 'spice');
- if (!domainCanConsole || !domainCanConsole(vm.state)) {
- return (
-
-
-
- );
- }
-
- const onDesktopConsole = () => { // prefer spice over vnc
- this.onDesktopConsoleDownload(spice ? 'spice' : 'vnc');
- };
+ con = console_launch(vm, vnc || spice)}
+ connectionAddress={connection_address()}
+ isExpanded={isExpanded} />;
+ }
+ if (con) {
return (
-
- {serial.map((pty, idx) => ())}
-
- {(vnc || spice) &&
- }
-
+
+ {con}
+
);
}
-}
-
-Consoles.propTypes = {
- vm: PropTypes.object.isRequired,
- onAddErrorNotification: PropTypes.func.isRequired,
};
-export default Consoles;
+export const ConsoleCard = ({ vm, config, type, setType, onAddErrorNotification }) => {
+ const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
-export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => {
- let actions = null;
- if (vm.state != "shut off") {
- actions = (
+ if (!type)
+ type = console_default(vm);
+
+ const actions = [];
+ const tabs = [];
+ let body;
+
+ if (vm.state == "running") {
+ actions.push(
);
+
+ if (serials.length > 0)
+ tabs.push( setType("vnc")} />);
+
+ serials.forEach((pty, idx) => {
+ const t = "serial" + idx;
+ tabs.push( setType(t)} />);
+ })
+
+ body =
+ } else {
+ const vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc');
+ const spice = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'spice');
+ body = ;
}
return (
@@ -184,9 +198,10 @@ export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => {
isClickable>
{_("Console")}
+ {tabs}
-
+ {body}
diff --git a/src/components/vm/consoles/serialConsole.jsx b/src/components/vm/consoles/serialConsole.jsx
index 6846193c3..e99789271 100644
--- a/src/components/vm/consoles/serialConsole.jsx
+++ b/src/components/vm/consoles/serialConsole.jsx
@@ -21,6 +21,7 @@ import PropTypes from 'prop-types';
import cockpit from 'cockpit';
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
+import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
import { Terminal } from "cockpit-components-terminal.jsx";
const _ = cockpit.gettext;
@@ -89,15 +90,21 @@ class SerialConsoleCockpit extends React.Component {
return (
<>
-
- {this.state.channel
- ?
- :
- }
-
{t}
+
+
+
+
+
+ {this.state.channel
+ ?
+ :
+ }
+
+
+
>
);
}
diff --git a/src/components/vm/consoles/vnc.jsx b/src/components/vm/consoles/vnc.jsx
index 2f27625b9..1bd87ed2f 100644
--- a/src/components/vm/consoles/vnc.jsx
+++ b/src/components/vm/consoles/vnc.jsx
@@ -20,19 +20,26 @@ import React from 'react';
import cockpit from 'cockpit';
import { VncConsole } from '@patternfly/react-console';
+import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
import { Dropdown, DropdownItem, DropdownList } from "@patternfly/react-core/dist/esm/components/Dropdown";
import { MenuToggle } from "@patternfly/react-core/dist/esm/components/MenuToggle";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import { EmptyState, EmptyStateBody, EmptyStateFooter } from "@patternfly/react-core/dist/esm/components/EmptyState";
import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
+import { DescriptionList, DescriptionListTerm, DescriptionListGroup, DescriptionListDescription } from "@patternfly/react-core/dist/esm/components/DescriptionList";
+import { Text, TextContent, TextVariants, TextList, TextListItem, TextListItemVariants, TextListVariants } from "@patternfly/react-core/dist/esm/components/Text";
+import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
-import { useDialogs } from 'dialogs.jsx';
+import { useDialogs, DialogsContext } from 'dialogs.jsx';
+import { KebabDropdown } from 'cockpit-components-dropdown.jsx';
+import { fmt_to_fragments } from 'utils.jsx';
import { logDebug } from '../../../helpers.js';
import { domainSendKey } from '../../../libvirtApi/domain.js';
import { AddVNC } from './vncAdd.jsx';
import { EditVNCModal } from './vncEdit.jsx';
+import { ReplaceSpiceDialog } from '../vmReplaceSpiceDialog.jsx';
const _ = cockpit.gettext;
// https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h
@@ -55,7 +62,7 @@ const Enum = {
KEY_DELETE: 111,
};
-export const VncState = ({ vm, vnc }) => {
+export const VncState = ({ vm, vnc, spice }) => {
const Dialogs = useDialogs();
function add_vnc() {
@@ -66,31 +73,59 @@ export const VncState = ({ vm, vnc }) => {
Dialogs.show();
}
+ function replace_spice() {
+ Dialogs.show();
+ }
+
if (vm.state == "running" && !vnc) {
- return (
-
-
- {_("Graphical support not enabled.")}
-
-
-
-
-
- );
+ if (!spice) {
+ return (
+
+
+ {_("Graphical support not enabled.")}
+
+
+
+
+
+ );
+ } else {
+ return (
+
+
+ {_("SPICE graphical console can not be shown here.")}
+
+
+
+
+
+ );
+ }
}
let vnc_info;
let vnc_action;
if (!vnc) {
- vnc_info = _("not supported");
- vnc_action = (
-
- );
+ if (!spice) {
+ vnc_info = _("not supported");
+ vnc_action = (
+
+ );
+ } else {
+ vnc_info = _("SPICE, can not be shown here");
+ vnc_action = (
+
+ );
+ }
} else {
if (vnc.port == -1)
vnc_info = _("VNC, dynamic port");
@@ -128,18 +163,74 @@ export const VncState = ({ vm, vnc }) => {
);
};
+const VncConnectInfo = ({ vm, connection_address, vnc, spice }) => {
+ function TLI(term, description) {
+ // What is this? Java?
+ return (
+ <>
+ {term}
+ {description}
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {fmt_to_fragments(_('Clicking "Launch viewer" will download a $0 file and launch the Remote Viewer application on your system.'), .vv
)}
+
+
+ {_('Remote Viewer is available for most operating systems. To install it, search for "Remote Viewer" in GNOME Software, KDE Discover, or run the following:')}
+
+
+ {TLI(_("RHEL, CentOS"), sudo yum install virt-viewer
)}
+ {TLI(_("Fedora"), sudo dnf install virt-viewer
)}
+ {TLI(_("Ubuntu, Debian"), sudo apt-get install virt-viewer
)}
+ {TLI(_("Windows"),
+ fmt_to_fragments(
+ _("Download the MSI from $0"),
+
+ {"virt-manager.org"}
+ ))}
+
+ { (vnc || spice) &&
+ <>
+
+ {_('Other remote viewer applications can connect to the following address:')}
+
+
+
+ {vnc
+ ? cockpit.format("vnc://$0:$1", connection_address, vnc.port)
+ : cockpit.format("spice://$0:$1", connection_address, spice.port)
+ }
+
+
+ >
+ }
+
+ >
+ );
+};
+
class Vnc extends React.Component {
+ static contextType = DialogsContext;
+
constructor(props) {
super(props);
this.state = {
path: undefined,
- isActionOpen: false,
+ connected: true,
};
this.connect = this.connect.bind(this);
this.onDisconnected = this.onDisconnected.bind(this);
this.onInitFailed = this.onInitFailed.bind(this);
- this.onExtraKeysDropdownToggle = this.onExtraKeysDropdownToggle.bind(this);
}
connect(props) {
@@ -184,34 +275,28 @@ class Vnc extends React.Component {
onDisconnected(detail) { // server disconnected
console.info('Connection lost: ', detail);
+ this.setState({ connected: false });
}
onInitFailed(detail) {
console.error('VncConsole failed to init: ', detail, this);
}
- onExtraKeysDropdownToggle() {
- this.setState({ isActionOpen: false });
- }
-
render() {
- const { consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded } = this.props;
- const { path, isActionOpen } = this.state;
+ const Dialogs = this.context;
+ const {
+ consoleDetail, inactiveConsoleDetail, vm, onAddErrorNotification, isExpanded, connectionAddress,
+ spiceDetail,
+ } = this.props;
+ const { path, connected } = this.state;
- if (!consoleDetail) {
- return (
-
-
-
- );
+ function edit_vnc() {
+ Dialogs.show();
}
- if (!path) {
- // postpone rendering until consoleDetail is known and channel ready
- return null;
- }
- const credentials = consoleDetail.password ? { password: consoleDetail.password } : undefined;
- const encrypt = this.getEncrypt();
const renderDropdownItem = keyName => {
return (
renderDropdownItem(key)),
,
...[...Array(12).keys()].map(key => renderDropdownItem(cockpit.format("F$0", key + 1))),
- ];
- const additionalButtons = [
- (
- this.setState({ isActionOpen: !isActionOpen })}>
- {_("Send key")}
-
- )}
- isOpen={isActionOpen}
- >
-
- {dropdownItems}
-
-
+ ,
+
+ {_("Edit VNC server settings")}
+ ,
+ this.setState({ connected: false })}>
+ {_("Disconnect")}
+ ,
];
+ const detail = (
+
+
+
+ }>
+
+
+
+
+ { (connected && consoleDetail) &&
+
+ }
+
+
+ );
+
+ if (!consoleDetail) {
+ return (
+ <>
+
+
+
+ { spiceDetail && {detail}
}
+ >
+ );
+ }
+
+ if (!path) {
+ // postpone rendering until consoleDetail is known and channel ready
+ return null;
+ }
+
+ const credentials = consoleDetail.password ? { password: consoleDetail.password } : undefined;
+ const encrypt = this.getEncrypt();
+
return (
-
+ <>
+ { connected
+ ?
+ :
+
+ {_("Disconnected")}
+
+
+
+
+
+ }
+ {detail}
+ >
);
}
}
diff --git a/src/components/vm/vmDetailsPage.jsx b/src/components/vm/vmDetailsPage.jsx
index ae11977e0..395a310eb 100644
--- a/src/components/vm/vmDetailsPage.jsx
+++ b/src/components/vm/vmDetailsPage.jsx
@@ -17,7 +17,7 @@
* along with Cockpit; If not, see .
*/
import PropTypes from 'prop-types';
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import cockpit from 'cockpit';
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb";
@@ -37,7 +37,7 @@ import { VmFilesystemsCard, VmFilesystemActions } from './filesystems/vmFilesyst
import { VmDisksCardLibvirt, VmDisksActions } from './disks/vmDisksCard.jsx';
import { VmNetworkTab, VmNetworkActions } from './nics/vmNicsCard.jsx';
import { VmHostDevCard, VmHostDevActions } from './hostdevs/hostDevCard.jsx';
-import Consoles, { ConsoleCard } from './consoles/consoles.jsx';
+import { ConsoleCard, Console, console_name } from './consoles/consoles.jsx';
import VmOverviewCard from './overview/vmOverviewCard.jsx';
import VmUsageTab from './vmUsageCard.jsx';
import { VmSnapshotsCard, VmSnapshotsActions } from './snapshots/vmSnapshotsCard.jsx';
@@ -64,6 +64,8 @@ export const VmDetailsPage = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const [consoleType, setConsoleType] = useState(null);
+
const vmActionsPageSection = (
@@ -98,14 +100,17 @@ export const VmDetailsPage = ({
{vm.name}
- {_("Console")}
+ {console_name(vm, consoleType)}
{vmActionsPageSection}
-
@@ -137,6 +142,8 @@ export const VmDetailsPage = ({
key={`${vmId(vm.name)}-consoles`}
vm={vm}
config={config}
+ type={consoleType}
+ setType={setConsoleType}
onAddErrorNotification={onAddErrorNotification} />
},
{