Skip to content

Commit b677445

Browse files
committed
consoles: Allow adding and editing VNC
- 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.
1 parent 94d9680 commit b677445

File tree

8 files changed

+478
-35
lines changed

8 files changed

+478
-35
lines changed

src/components/common/needsShutdown.jsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,26 @@ export function needsShutdownSpice(vm) {
8585
return vm.hasSpice !== vm.inactiveXML.hasSpice;
8686
}
8787

88+
export function needsShutdownVnc(vm) {
89+
function find_vnc(v) {
90+
return v.displays && v.displays.find(d => d.type == "vnc");
91+
}
92+
93+
const active_vnc = find_vnc(vm);
94+
const inactive_vnc = find_vnc(vm.inactiveXML);
95+
96+
if (inactive_vnc) {
97+
if (!active_vnc)
98+
return true;
99+
if (inactive_vnc.port != -1 && active_vnc.port != inactive_vnc.port)
100+
return true;
101+
if (active_vnc.password != inactive_vnc.password)
102+
return true;
103+
}
104+
105+
return false;
106+
}
107+
88108
export function getDevicesRequiringShutdown(vm) {
89109
if (!vm.persistent)
90110
return [];
@@ -125,6 +145,10 @@ export function getDevicesRequiringShutdown(vm) {
125145
if (needsShutdownSpice(vm))
126146
devices.push(_("SPICE"));
127147

148+
// VNC
149+
if (needsShutdownVnc(vm))
150+
devices.push(_("VNC"));
151+
128152
// TPM
129153
if (needsShutdownTpm(vm))
130154
devices.push(_("TPM"));

src/components/vm/consoles/consoles.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@
2828
#pf-v5-c-console__send-shortcut {
2929
display: none;
3030
}
31+
32+
.consoles-card .pf-v5-c-empty-state__icon {
33+
color: var(--pf-v5-global--custom-color--200);
34+
}

src/components/vm/consoles/consoles.jsx

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,19 @@ import React from 'react';
2020
import PropTypes from 'prop-types';
2121
import cockpit from 'cockpit';
2222
import { AccessConsoles } from "@patternfly/react-console";
23+
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
24+
import {
25+
EmptyState, EmptyStateHeader, EmptyStateBody, EmptyStateFooter, EmptyStateActions, EmptyStateIcon
26+
} from "@patternfly/react-core/dist/esm/components/EmptyState";
27+
import { PendingIcon } from "@patternfly/react-icons";
28+
29+
import { useDialogs } from 'dialogs.jsx';
2330

2431
import SerialConsole from './serialConsole.jsx';
2532
import Vnc from './vnc.jsx';
2633
import DesktopConsole from './desktopConsole.jsx';
34+
import { AddEditVNCModal } from './vncAddEdit.jsx';
35+
2736
import {
2837
domainCanConsole,
2938
domainDesktopConsole,
@@ -34,11 +43,70 @@ import './consoles.css';
3443

3544
const _ = cockpit.gettext;
3645

37-
const VmNotRunning = () => {
46+
const ConsoleEmptyState = ({ vm }) => {
47+
const Dialogs = useDialogs();
48+
const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
49+
const inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(display => display.type == 'vnc');
50+
51+
function add_vnc() {
52+
Dialogs.show(<AddEditVNCModal idPrefix="add-vnc" vm={vm} consoleDetail={null} />);
53+
}
54+
55+
function edit_vnc() {
56+
Dialogs.show(<AddEditVNCModal idPrefix="edit-vnc" vm={vm} consoleDetail={inactive_vnc} />);
57+
}
58+
59+
let icon = null;
60+
let top_message = null;
61+
let bot_message = _("Graphical console support not enabled");
62+
63+
if (serials.length == 0 && !inactive_vnc) {
64+
bot_message = _("Console support not enabled");
65+
} else {
66+
if (vm.state != "running") {
67+
if (serials.length > 0 && !inactive_vnc) {
68+
top_message = _("Start the virtual machine to access its serial console");
69+
} else {
70+
top_message = _("Start the virtual machine to access its console");
71+
}
72+
} else if (inactive_vnc) {
73+
icon = <EmptyStateIcon icon={PendingIcon} />;
74+
if (serials.length > 0) {
75+
top_message = _("Restart this virtual machine to access its graphical console");
76+
} else {
77+
top_message = _("Restart this virtual machine to access its console");
78+
}
79+
}
80+
}
81+
3882
return (
39-
<div id="vm-not-running-message">
40-
{_("Please start the virtual machine to access its console.")}
41-
</div>
83+
<EmptyState>
84+
<EmptyStateHeader icon={icon} />
85+
<EmptyStateBody>
86+
{top_message}
87+
</EmptyStateBody>
88+
{ !inactive_vnc
89+
? <>
90+
<EmptyStateBody>
91+
{bot_message}
92+
</EmptyStateBody>
93+
<EmptyStateFooter>
94+
<EmptyStateActions>
95+
<Button variant="secondary" onClick={add_vnc}>
96+
{_("Add VNC")}
97+
</Button>
98+
</EmptyStateActions>
99+
</EmptyStateFooter>
100+
</>
101+
: <EmptyStateFooter>
102+
<EmptyStateActions>
103+
<Button variant="link" onClick={edit_vnc}>
104+
{_("VNC server settings")}
105+
</Button>
106+
</EmptyStateActions>
107+
</EmptyStateFooter>
108+
}
109+
</EmptyState>
42110
);
43111
};
44112

@@ -81,8 +149,9 @@ class Consoles extends React.Component {
81149
return 'SerialConsole';
82150
}
83151

84-
// no console defined
85-
return null;
152+
// no console defined, but the VncConsole is always there and
153+
// will instruct people how to enable it for real.
154+
return 'VncConsole';
86155
}
87156

88157
onDesktopConsoleDownload (type) {
@@ -116,7 +185,11 @@ class Consoles extends React.Component {
116185
const vnc = vm.displays && vm.displays.find(display => display.type == 'vnc');
117186

118187
if (!domainCanConsole || !domainCanConsole(vm.state)) {
119-
return (<VmNotRunning />);
188+
return (
189+
<div id="vm-not-running-message">
190+
<ConsoleEmptyState vm={vm} />
191+
</div>
192+
);
120193
}
121194

122195
const onDesktopConsole = () => { // prefer spice over vnc
@@ -134,14 +207,19 @@ class Consoles extends React.Component {
134207
connectionName={vm.connectionName}
135208
vmName={vm.name}
136209
spawnArgs={domainSerialConsoleCommand({ vm, alias: pty.alias })} />))}
137-
{vnc &&
138-
<Vnc type="VncConsole"
139-
vmName={vm.name}
140-
vmId={vm.id}
141-
connectionName={vm.connectionName}
142-
consoleDetail={vnc}
143-
onAddErrorNotification={onAddErrorNotification}
144-
isExpanded={isExpanded} />}
210+
{ vnc
211+
? <Vnc
212+
type="VncConsole"
213+
vmName={vm.name}
214+
vmId={vm.id}
215+
connectionName={vm.connectionName}
216+
consoleDetail={vnc}
217+
onAddErrorNotification={onAddErrorNotification}
218+
isExpanded={isExpanded} />
219+
: <div type="VncConsole" className="pf-v5-c-console__vnc">
220+
<ConsoleEmptyState vm={vm} />
221+
</div>
222+
}
145223
{(vnc || spice) &&
146224
<DesktopConsole type="DesktopViewer"
147225
onDesktopConsole={onDesktopConsole}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* This file is part of Cockpit.
3+
*
4+
* Copyright 2024 Fsas Technologies Inc.
5+
* Copyright (C) 2025 Red Hat, Inc.
6+
*
7+
* Cockpit is free software; you can redistribute it and/or modify it
8+
* under the terms of the GNU Lesser General Public License as published by
9+
* the Free Software Foundation; either version 2.1 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* Cockpit is distributed in the hope that it will be useful, but
13+
* WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15+
* Lesser General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Lesser General Public License
18+
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
import cockpit from 'cockpit';
22+
23+
import React, { useState } from 'react';
24+
import PropTypes from 'prop-types';
25+
import {
26+
Form, Modal, ModalVariant,
27+
FormGroup, FormHelperText, HelperText, HelperTextItem,
28+
InputGroup, TextInput, Button,
29+
} from "@patternfly/react-core";
30+
import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
31+
32+
import { ModalError } from 'cockpit-components-inline-notification.jsx';
33+
import { DialogsContext } from 'dialogs.jsx';
34+
import { domainChangeVncSettings, domainAttachVnc, domainGet } from '../../../libvirtApi/domain.js';
35+
import { NeedsShutdownAlert } from '../../common/needsShutdown.jsx';
36+
37+
const _ = cockpit.gettext;
38+
39+
const VncBody = ({ idPrefix, onValueChanged, dialogValues, validationErrors }) => {
40+
const [showPassword, setShowPassword] = useState(false);
41+
42+
return (
43+
<>
44+
<FormGroup
45+
fieldId={`${idPrefix}-portmode`}
46+
label={_("Port")} isInline hasNoPaddingTop isStack>
47+
<TextInput
48+
id={`${idPrefix}-port`}
49+
value={dialogValues.vncPort}
50+
type="text"
51+
validated={validationErrors?.vncPort ? "error" : null}
52+
onChange={(event) => onValueChanged('vncPort', event.target.value)} />
53+
<FormHelperText>
54+
<HelperText>
55+
{ validationErrors?.vncPort
56+
? <HelperTextItem variant='error'>{validationErrors?.vncPort}</HelperTextItem>
57+
: <HelperTextItem>
58+
{_("Leave empty to automatically assign a free port when machine starts")}
59+
</HelperTextItem>
60+
}
61+
</HelperText>
62+
</FormHelperText>
63+
</FormGroup>
64+
<FormGroup fieldId={`${idPrefix}-password`} label={_("Password")}>
65+
<InputGroup>
66+
<TextInput
67+
id={`${idPrefix}-password`}
68+
type={showPassword ? "text" : "password"}
69+
value={dialogValues.vncPassword}
70+
onChange={(event) => onValueChanged('vncPassword', event.target.value)} />
71+
<Button
72+
variant="control"
73+
onClick={() => setShowPassword(!showPassword)}>
74+
{ showPassword ? <EyeSlashIcon /> : <EyeIcon /> }
75+
</Button>
76+
</InputGroup>
77+
</FormGroup>
78+
</>
79+
);
80+
};
81+
82+
function validateDialogValues(values) {
83+
const res = { };
84+
85+
if (values.vncPort != "" && (!values.vncPort.match("^[0-9]+$") || Number(values.vncPort) < 5900))
86+
res.vncPort = _("Port must be 5900 or larger.");
87+
88+
return Object.keys(res).length > 0 ? res : null;
89+
}
90+
91+
VncBody.propTypes = {
92+
idPrefix: PropTypes.string.isRequired,
93+
onValueChanged: PropTypes.func.isRequired,
94+
dialogValues: PropTypes.object.isRequired,
95+
validationErrors: PropTypes.object,
96+
};
97+
98+
export class AddEditVNCModal extends React.Component {
99+
static contextType = DialogsContext;
100+
101+
constructor(props) {
102+
super(props);
103+
104+
this.state = {
105+
dialogError: undefined,
106+
vncPort: Number(props.consoleDetail?.port) == -1 ? "" : props.consoleDetail?.port || "",
107+
vncPassword: props.consoleDetail?.password || "",
108+
validationErrors: null,
109+
};
110+
111+
this.save = this.save.bind(this);
112+
this.onValueChanged = this.onValueChanged.bind(this);
113+
this.dialogErrorSet = this.dialogErrorSet.bind(this);
114+
}
115+
116+
onValueChanged(key, value) {
117+
const stateDelta = { [key]: value, validationErrors: null };
118+
this.setState(stateDelta);
119+
}
120+
121+
dialogErrorSet(text, detail) {
122+
this.setState({ dialogError: text, dialogErrorDetail: detail });
123+
}
124+
125+
save() {
126+
const Dialogs = this.context;
127+
const { vm } = this.props;
128+
129+
const errors = validateDialogValues(this.state);
130+
if (errors) {
131+
this.setState({ validationErrors: errors });
132+
return;
133+
}
134+
135+
const vncParams = {
136+
connectionName: vm.connectionName,
137+
vmName: vm.name,
138+
vncAddress: this.props.consoleDetail?.address || "",
139+
vncPort: this.state.vncPort || "",
140+
vncPassword: this.state.vncPassword || "",
141+
};
142+
143+
(this.props.consoleDetail ? domainChangeVncSettings(vncParams) : domainAttachVnc(vncParams))
144+
.then(() => {
145+
domainGet({ connectionName: vm.connectionName, id: vm.id });
146+
Dialogs.close();
147+
})
148+
.catch((exc) => {
149+
this.dialogErrorSet(_("VNC settings could not be saved"), exc.message);
150+
});
151+
}
152+
153+
render() {
154+
const Dialogs = this.context;
155+
const { idPrefix, vm } = this.props;
156+
157+
const defaultBody = (
158+
<Form onSubmit={e => e.preventDefault()} isHorizontal>
159+
<VncBody
160+
idPrefix={idPrefix}
161+
dialogValues={this.state}
162+
validationErrors={this.state.validationErrors}
163+
onValueChanged={this.onValueChanged} />
164+
</Form>
165+
);
166+
167+
return (
168+
<Modal position="top" variant={ModalVariant.medium} id={`${idPrefix}-dialog`} isOpen onClose={Dialogs.close} className='vnc-edit'
169+
title={this.props.consoleDetail ? _("Edit VNC server settings") : _("Add VNC server")}
170+
footer={
171+
<>
172+
<Button isDisabled={!!this.state.validationErrors} id={`${idPrefix}-save`} variant='primary' onClick={this.save}>
173+
{this.props.consoleDetail ? _("Save") : _("Add")}
174+
</Button>
175+
<Button id={`${idPrefix}-cancel`} variant='link' onClick={Dialogs.close}>
176+
{_("Cancel")}
177+
</Button>
178+
</>
179+
}>
180+
<>
181+
{ vm.state === 'running' && !this.state.dialogError && <NeedsShutdownAlert idPrefix={idPrefix} /> }
182+
{this.state.dialogError && <ModalError dialogError={this.state.dialogError} dialogErrorDetail={this.state.dialogErrorDetail} />}
183+
{defaultBody}
184+
</>
185+
</Modal>
186+
);
187+
}
188+
}
189+
190+
AddEditVNCModal.propTypes = {
191+
idPrefix: PropTypes.string.isRequired,
192+
vm: PropTypes.object.isRequired,
193+
consoleDetail: PropTypes.object,
194+
};

0 commit comments

Comments
 (0)