Skip to content

Commit

Permalink
consoles: Redesign and reimplement
Browse files Browse the repository at this point in the history
- A ToggleGroup in the Card header is used to switch consoles

- The DesktopViewer is gone, but there is a footer with a "Launch
  viewer" button and a "How to connect" popup.

- Can't change consoles in the expanded view, but the expanded console
  keeps the type that was active in the collapsed view.

TODO:

- Code cleanup, "VncConsole" is now really "GraphicsConsole", etc..
  • Loading branch information
mvollmer committed Feb 4, 2025
1 parent d2f0841 commit 77c4eb9
Show file tree
Hide file tree
Showing 5 changed files with 371 additions and 192 deletions.
12 changes: 10 additions & 2 deletions src/components/vm/consoles/consoles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
225 changes: 120 additions & 105 deletions src/components/vm/consoles/consoles.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import HelpIcon.
import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup';

import SerialConsole from './serialConsole.jsx';
import Vnc, { VncState } from './vnc.jsx';
Expand All @@ -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 <VncState vm={vm} vnc={vnc} spice={spice} />;
}

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 = <SerialConsole
type={type}
connectionName={vm.connectionName}
vmName={vm.name}
spawnArgs={domainSerialConsoleCommand({ vm, alias: serials[idx].alias })} />;
} 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 (
<div id="vm-not-running-message">
<VncState vm={vm} vnc={inactive_vnc} />
</div>
);
}

const onDesktopConsole = () => { // prefer spice over vnc
this.onDesktopConsoleDownload(spice ? 'spice' : 'vnc');
};
con = <Vnc
type="VncConsole"
vm={vm}
consoleDetail={vnc}
spiceDetail={spice}
inactiveConsoleDetail={inactive_vnc}
onAddErrorNotification={onAddErrorNotification}
onLaunch={() => console_launch(vm, vnc || spice)}
connectionAddress={connection_address()}
isExpanded={isExpanded} />;
}

if (con) {
return (
<AccessConsoles preselectedType={this.getDefaultConsole()}
textSelectConsoleType={_("Select console type")}
textSerialConsole={_("Serial console")}
textVncConsole={_("Graphical console")}
textDesktopViewerConsole={_("Desktop viewer")}>
{serial.map((pty, idx) => (<SerialConsole type={serial.length == 1 ? "SerialConsole" : cockpit.format(_("Serial console ($0)"), pty.alias || idx)}
key={"pty-" + idx}
connectionName={vm.connectionName}
vmName={vm.name}
spawnArgs={domainSerialConsoleCommand({ vm, alias: pty.alias })} />))}
<Vnc type="VncConsole"
vm={vm}
consoleDetail={vnc}
inactiveConsoleDetail={inactive_vnc}
onAddErrorNotification={onAddErrorNotification}
isExpanded={isExpanded} />
{(vnc || spice) &&
<DesktopConsole type="DesktopViewer"
onDesktopConsole={onDesktopConsole}
vnc={vnc}
spice={spice} />}
</AccessConsoles>
<div className="pf-v5-c-console">
{con}
</div>
);
}
}

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(
<Button
key="expand"
variant="link"
onClick={() => {
const urlOptions = { name: vm.name, connection: vm.connectionName };
Expand All @@ -174,6 +161,33 @@ export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => {
iconPosition="right">{_("Expand")}
</Button>
);

if (serials.length > 0)
tabs.push(<ToggleGroupItem
key="vnc"
text={_("Graphical")}
isSelected={type == "vnc"}
onChange={() => setType("vnc")} />);

serials.forEach((pty, idx) => {
const t = "serial" + idx;
tabs.push(<ToggleGroupItem
key={t}
text={serials.length == 1 ? _("Serial") : cockpit.format(_("Serial ($0)"), pty.alias || idx)}
isSelected={type == t}
onChange={() => setType(t)} />);
})

body = <Console
vm={vm}
config={config}
onAddErrorNotification={onAddErrorNotification}
type={type}
isExpanded={false} />
} 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 = <VncState vm={vm} vnc={vnc} spice={spice} />;
}

return (
Expand All @@ -184,9 +198,10 @@ export const ConsoleCard = ({ vm, config, onAddErrorNotification }) => {
isClickable>
<CardHeader actions={{ actions }}>
<CardTitle component="h2">{_("Console")}</CardTitle>
<ToggleGroup>{tabs}</ToggleGroup>
</CardHeader>
<CardBody>
<Consoles vm={vm} config={config} onAddErrorNotification={onAddErrorNotification} />
{body}
</CardBody>
<CardFooter />
</Card>
Expand Down
19 changes: 13 additions & 6 deletions src/components/vm/consoles/serialConsole.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,15 +90,21 @@ class SerialConsoleCockpit extends React.Component {

return (
<>
<div className="pf-v5-c-console__actions-serial">
{this.state.channel
? <Button id={this.props.vmName + "-serialconsole-disconnect"} variant="secondary" onClick={this.onDisconnect}>{_("Disconnect")}</Button>
: <Button id={this.props.vmName + "-serialconsole-connect"} variant="secondary" onClick={() => this.createChannel(this.props.spawnArgs)}>{_("Connect")}</Button>
}
</div>
<div id={pid} className="vm-terminal pf-v5-c-console__serial">
{t}
</div>
<div className="vm-console-footer">
<Split>
<SplitItem isFilled>
</SplitItem>
<SplitItem>
{this.state.channel
? <Button id={this.props.vmName + "-serialconsole-disconnect"} variant="secondary" onClick={this.onDisconnect}>{_("Disconnect")}</Button>
: <Button id={this.props.vmName + "-serialconsole-connect"} variant="secondary" onClick={() => this.createChannel(this.props.spawnArgs)}>{_("Connect")}</Button>
}
</SplitItem>
</Split>
</div>
</>
);
}
Expand Down
Loading

0 comments on commit 77c4eb9

Please sign in to comment.