Skip to content

Commit

Permalink
consoles: Allow adding and editing VNC
Browse files Browse the repository at this point in the history
- The "Console" card is always present and let's people manage VNC
  server settings while the machine is off.

- The "VNC console" tab is always present when the machine is running
  and let's people add VNC.

- There is no way yet to change VNC server settings for a running
  machine since there is no good place for the button that would open
  the dialog.
  • Loading branch information
mvollmer committed Feb 7, 2025
1 parent 94d9680 commit de49cda
Show file tree
Hide file tree
Showing 7 changed files with 473 additions and 33 deletions.
24 changes: 24 additions & 0 deletions src/components/common/needsShutdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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"));
Expand Down
4 changes: 4 additions & 0 deletions src/components/vm/consoles/consoles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@
#pf-v5-c-console__send-shortcut {
display: none;
}

.consoles-card .pf-v5-c-empty-state__icon {
color: var(--pf-v5-global--custom-color--200);
}
108 changes: 93 additions & 15 deletions src/components/vm/consoles/consoles.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,19 @@ 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 {
EmptyState, EmptyStateHeader, EmptyStateBody, EmptyStateFooter, EmptyStateActions, EmptyStateIcon
} from "@patternfly/react-core/dist/esm/components/EmptyState";
import { PendingIcon } from "@patternfly/react-icons";

import { useDialogs } from 'dialogs.jsx';

import SerialConsole from './serialConsole.jsx';
import Vnc from './vnc.jsx';
import DesktopConsole from './desktopConsole.jsx';
import { AddEditVNCModal } from './vncAddEdit.jsx';

import {
domainCanConsole,
domainDesktopConsole,
Expand All @@ -34,11 +43,70 @@ import './consoles.css';

const _ = cockpit.gettext;

const VmNotRunning = () => {
const ConsoleEmptyState = ({ vm }) => {
const Dialogs = useDialogs();
const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
const inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc');

function add_vnc() {
Dialogs.show(<AddEditVNCModal idPrefix="add-vnc" vm={vm} consoleDetail={null} />);
}

function edit_vnc() {
Dialogs.show(<AddEditVNCModal idPrefix="edit-vnc" vm={vm} consoleDetail={inactive_vnc} />);
}

let icon = null;
let top_message = null;
let bot_message = _("Graphical support not enabled.");

if (serials.length == 0 && !inactive_vnc) {
bot_message = _("Console support not enabled.");
} else {
if (vm.state != "running") {
if (serials.length > 0 && !inactive_vnc) {
top_message = _("Please start the virtual machine to access its serial console.");
} else {
top_message = _("Please start the virtual machine to access its console.");
}
} else if (inactive_vnc) {
icon = <EmptyStateIcon icon={PendingIcon} />;
if (serials.length > 0) {
top_message = _("Please restart the virtual machine to access its graphical console.");
} else {
top_message = _("Please restart the virtual machine to access its console.");
}
}
}

return (
<div id="vm-not-running-message">
{_("Please start the virtual machine to access its console.")}
</div>
<EmptyState>
<EmptyStateHeader icon={icon} />
<EmptyStateBody>
{top_message}
</EmptyStateBody>
{ !inactive_vnc
? <>
<EmptyStateBody>
{bot_message}
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
<Button variant="secondary" onClick={add_vnc}>
{_("Add VNC")}
</Button>
</EmptyStateActions>
</EmptyStateFooter>
</>
: <EmptyStateFooter>
<EmptyStateActions>
<Button variant="link" onClick={edit_vnc}>
{_("VNC server settings")}
</Button>
</EmptyStateActions>
</EmptyStateFooter>
}
</EmptyState>
);
};

Expand Down Expand Up @@ -81,8 +149,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) {
Expand Down Expand Up @@ -116,7 +185,11 @@ class Consoles extends React.Component {
const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc');

if (!domainCanConsole || !domainCanConsole(vm.state)) {
return (<VmNotRunning />);
return (
<div id="vm-not-running-message">
<ConsoleEmptyState vm={vm} />
</div>
);
}

const onDesktopConsole = () => { // prefer spice over vnc
Expand All @@ -134,14 +207,19 @@ class Consoles extends React.Component {
connectionName={vm.connectionName}
vmName={vm.name}
spawnArgs={domainSerialConsoleCommand({ vm, alias: pty.alias })} />))}
{vnc &&
<Vnc type="VncConsole"
vmName={vm.name}
vmId={vm.id}
connectionName={vm.connectionName}
consoleDetail={vnc}
onAddErrorNotification={onAddErrorNotification}
isExpanded={isExpanded} />}
{ vnc
? <Vnc
type="VncConsole"
vmName={vm.name}
vmId={vm.id}
connectionName={vm.connectionName}
consoleDetail={vnc}
onAddErrorNotification={onAddErrorNotification}
isExpanded={isExpanded} />
: <div type="VncConsole" className="pf-v5-c-console__vnc">
<ConsoleEmptyState vm={vm} />
</div>
}
{(vnc || spice) &&
<DesktopConsole type="DesktopViewer"
onDesktopConsole={onDesktopConsole}
Expand Down
194 changes: 194 additions & 0 deletions src/components/vm/consoles/vncAddEdit.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
* This file is part of Cockpit.
*
* Copyright 2024 Fsas Technologies Inc.
* Copyright (C) 2025 Red Hat, 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 <http://www.gnu.org/licenses/>.
*/

import cockpit from 'cockpit';

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
Form, Modal, ModalVariant,
FormGroup, FormHelperText, HelperText, HelperTextItem,
InputGroup, TextInput, Button,
} from "@patternfly/react-core";
import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";

import { ModalError } from 'cockpit-components-inline-notification.jsx';
import { DialogsContext } from 'dialogs.jsx';
import { domainChangeVncSettings, domainAttachVnc, domainGet } from '../../../libvirtApi/domain.js';
import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx';

const _ = cockpit.gettext;

const VncBody = ({ idPrefix, onValueChanged, dialogValues, validationErrors }) => {
const [showPassword, setShowPassword] = useState(false);

return (
<>
<FormGroup
fieldId={`${idPrefix}-portmode`}
label={_("Port")} isInline hasNoPaddingTop isStack>
<TextInput
id={`${idPrefix}-port`}
value={dialogValues.vncPort}
type="text"
validated={validationErrors?.vncPort ? "error" : null}
onChange={(event) => onValueChanged('vncPort', event.target.value)} />
<FormHelperText>
<HelperText>
{ validationErrors?.vncPort
? <HelperTextItem variant='error'>{validationErrors?.vncPort}</HelperTextItem>
: <HelperTextItem>
{_("Leave empty to automatically assign a free port when machine starts")}
</HelperTextItem>
}
</HelperText>
</FormHelperText>
</FormGroup>
<FormGroup fieldId={`${idPrefix}-password`} label={_("Password")}>
<InputGroup>
<TextInput
id={`${idPrefix}-password`}
type={showPassword ? "text" : "password"}
value={dialogValues.vncPassword}
onChange={(event) => onValueChanged('vncPassword', event.target.value)} />
<Button
variant="control"
onClick={() => setShowPassword(!showPassword)}>
{ showPassword ? <EyeSlashIcon /> : <EyeIcon /> }
</Button>
</InputGroup>
</FormGroup>
</>
);
};

function validateDialogValues(values) {
const res = { };

if (values.vncPort != "" && (!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;
}

VncBody.propTypes = {
idPrefix: PropTypes.string.isRequired,
onValueChanged: PropTypes.func.isRequired,
dialogValues: PropTypes.object.isRequired,
validationErrors: PropTypes.object,
};

export class AddEditVNCModal extends React.Component {
static contextType = DialogsContext;

constructor(props) {
super(props);

this.state = {
dialogError: undefined,
vncPort: Number(props.consoleDetail?.port) == -1 ? "" : props.consoleDetail?.port || "",
vncPassword: props.consoleDetail?.password || "",
validationErrors: null,
};

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: null };
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.vncPort || "",
vncPassword: this.state.vncPassword || "",
};

(this.props.consoleDetail ? domainChangeVncSettings(vncParams) : domainAttachVnc(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 = (
<Form onSubmit={e => e.preventDefault()} isHorizontal>
<VncBody
idPrefix={idPrefix}
dialogValues={this.state}
validationErrors={this.state.validationErrors}
onValueChanged={this.onValueChanged} />
</Form>
);

return (
<Modal position="top" variant={ModalVariant.medium} id={`${idPrefix}-dialog`} isOpen onClose={Dialogs.close} className='vnc-edit'
title={this.props.consoleDetail ? _("Edit VNC server settings") : _("Add VNC server")}
footer={
<>
<Button isDisabled={!!this.state.validationErrors} id={`${idPrefix}-save`} variant='primary' onClick={this.save}>
{this.props.consoleDetail ? _("Save") : _("Add")}
</Button>
<Button id={`${idPrefix}-cancel`} variant='link' onClick={Dialogs.close}>
{_("Cancel")}
</Button>
</>
}>
<>
{ vm.state === 'running' && !this.state.dialogError && <NeedsShutdownAlert idPrefix={idPrefix} /> }
{this.state.dialogError && <ModalError dialogError={this.state.dialogError} dialogErrorDetail={this.state.dialogErrorDetail} />}
{defaultBody}
</>
</Modal>
);
}
}

AddEditVNCModal.propTypes = {
idPrefix: PropTypes.string.isRequired,
vm: PropTypes.object.isRequired,
consoleDetail: PropTypes.object,
};
Loading

0 comments on commit de49cda

Please sign in to comment.