Skip to content

Commit 95f8b3a

Browse files
committed
consoles: Redesign and reimplement
- A ToggleGroup in the Card header is used to switch consoles - The console switcher is also there for shut-off machines. This gives us a place for type-specific actions that also make sense for a shut-off machine, like editing VNC server settings. - The DesktopViewer is gone, but there is a footer with a "Launch viewer" button and a "How to connect" popover. - Actions for the Graphics and Serial consoles are collected into kebab menus. - The expanded console has less UI around it, and it keeps the type that was active in the collapsed view. Instead of the breadcrumb it has a "Collapse" button. - When there is no actual console for a given type, there is now a EmptyState component where you can enable it. - It is possible to change VNC server settings via the "How to connect" popup. - The SPICE console invites you to replace it with VNC. - The size of the expanded console is now always controlled by the browser window and never overflows in height. - The VncConsole has been imported from @patternfly/react-console and stripped down.
1 parent b13c22f commit 95f8b3a

18 files changed

+1673
-428
lines changed

node_modules

Submodule node_modules updated 1122 files

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
},
4545
"dependencies": {
4646
"@patternfly/patternfly": "6.1.0",
47-
"@patternfly/react-console": "6.0.0",
4847
"@patternfly/react-core": "6.1.0",
4948
"@patternfly/react-icons": "6.1.0",
5049
"@patternfly/react-styles": "6.1.0",

src/components/common/needsShutdown.jsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,47 @@ export function needsShutdownSpice(vm) {
8383
return vm.hasSpice !== vm.inactiveXML.hasSpice;
8484
}
8585

86+
export function needsShutdownVnc(vm) {
87+
function find_vnc(v) {
88+
return v.displays && v.displays.find(d => d.type == "vnc");
89+
}
90+
91+
const active_vnc = find_vnc(vm);
92+
const inactive_vnc = find_vnc(vm.inactiveXML);
93+
94+
if (inactive_vnc) {
95+
if (!active_vnc)
96+
return true;
97+
98+
// The active_vnc.port value is the actual port allocated at
99+
// machine start, it is never -1. Thus, we can't just compare
100+
// inactive_vnc.port with active_vnc.port here when
101+
// inactive_vnc.port is -1. Also, when inactive_vnc.port _is_
102+
// -1, we can't tell whether active_vnc.port has been
103+
// allocated based on some old fixed port in inactive_vnc.port
104+
// (in which case we might want to shutdown and restart), or
105+
// whether it was allocated dynamically (in which case we
106+
// don't want to). But luckily that doesn't really matter and
107+
// a shutdown would not have any useful effect anyway, so we
108+
// don't have to worry that we are missing a notification for
109+
// a pending shutdown.
110+
//
111+
if (inactive_vnc.port != -1 && active_vnc.port != inactive_vnc.port)
112+
return true;
113+
114+
if (active_vnc.password != inactive_vnc.password)
115+
return true;
116+
}
117+
118+
return false;
119+
}
120+
121+
export function needsShutdownSerialConsole(vm) {
122+
const serials = vm.displays && vm.displays.filter(display => display.type == 'pty');
123+
const inactive_serials = vm.inactiveXML.displays && vm.inactiveXML.displays.filter(display => display.type == 'pty');
124+
return serials.length != inactive_serials.length;
125+
}
126+
86127
export function getDevicesRequiringShutdown(vm) {
87128
if (!vm.persistent)
88129
return [];
@@ -123,6 +164,14 @@ export function getDevicesRequiringShutdown(vm) {
123164
if (needsShutdownSpice(vm))
124165
devices.push(_("SPICE"));
125166

167+
// VNC
168+
if (needsShutdownVnc(vm))
169+
devices.push(_("VNC"));
170+
171+
// Serial console
172+
if (needsShutdownSerialConsole(vm))
173+
devices.push(_("Text console"));
174+
126175
// TPM
127176
if (needsShutdownTpm(vm))
128177
devices.push(_("TPM"));
@@ -172,7 +221,7 @@ export const VmNeedsShutdown = ({ vm }) => {
172221
position="bottom"
173222
hasAutoWidth
174223
bodyContent={body}>
175-
<Label className="resource-state-text" color="teal" id={`vm-${vm.name}-needs-shutdown`}
224+
<Label className="resource-state-text" status="custom" id={`vm-${vm.name}-needs-shutdown`}
176225
icon={<PendingIcon />} onClick={() => null}>
177226
{_("Changes pending")}
178227
</Label>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2025 Red Hat, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import React from 'react';
28+
29+
import { initLogging } from '@novnc/novnc/lib/util/logging';
30+
import RFB_module from '@novnc/novnc/lib/rfb';
31+
const RFB = RFB_module.default;
32+
33+
export const VncConsole = ({
34+
children,
35+
host,
36+
port = '80',
37+
path = '',
38+
encrypt = false,
39+
resizeSession = true,
40+
clipViewport = false,
41+
dragViewport = false,
42+
scaleViewport = false,
43+
viewOnly = false,
44+
shared = false,
45+
credentials,
46+
repeaterID = '',
47+
vncLogging = 'warn',
48+
consoleContainerId,
49+
onDisconnected = () => {},
50+
onInitFailed,
51+
onSecurityFailure,
52+
}) => {
53+
const rfb = React.useRef();
54+
55+
const novncElem = React.useRef(null);
56+
57+
const onConnected = () => {
58+
};
59+
60+
const _onDisconnected = React.useCallback(
61+
(e) => {
62+
onDisconnected(e);
63+
},
64+
[onDisconnected]
65+
);
66+
67+
const _onSecurityFailure = React.useCallback(
68+
(e) => {
69+
onSecurityFailure(e);
70+
},
71+
[onSecurityFailure]
72+
);
73+
74+
const addEventListeners = React.useCallback(() => {
75+
if (rfb.current) {
76+
rfb.current?.addEventListener('connect', onConnected);
77+
rfb.current?.addEventListener('disconnect', _onDisconnected);
78+
rfb.current?.addEventListener('securityfailure', _onSecurityFailure);
79+
}
80+
}, [rfb, _onDisconnected, _onSecurityFailure]);
81+
82+
const removeEventListeners = React.useCallback(() => {
83+
if (rfb.current) {
84+
rfb.current.removeEventListener('connect', onConnected);
85+
rfb.current.removeEventListener('disconnect', _onDisconnected);
86+
rfb.current.removeEventListener('securityfailure', _onSecurityFailure);
87+
}
88+
}, [rfb, _onDisconnected, _onSecurityFailure]);
89+
90+
const connect = React.useCallback(() => {
91+
const protocol = encrypt ? 'wss' : 'ws';
92+
const url = `${protocol}://${host}:${port}/${path}`;
93+
94+
const options = {
95+
repeaterID,
96+
shared,
97+
credentials
98+
};
99+
rfb.current = new RFB(novncElem.current, url, options);
100+
addEventListeners();
101+
rfb.current.viewOnly = viewOnly;
102+
rfb.current.clipViewport = clipViewport;
103+
rfb.current.dragViewport = dragViewport;
104+
rfb.current.scaleViewport = scaleViewport;
105+
rfb.current.resizeSession = resizeSession;
106+
}, [
107+
addEventListeners,
108+
host,
109+
path,
110+
port,
111+
resizeSession,
112+
clipViewport,
113+
dragViewport,
114+
scaleViewport,
115+
viewOnly,
116+
encrypt,
117+
rfb,
118+
repeaterID,
119+
shared,
120+
credentials
121+
]);
122+
123+
React.useEffect(() => {
124+
initLogging(vncLogging);
125+
try {
126+
connect();
127+
} catch (e) {
128+
onInitFailed && onInitFailed(e);
129+
rfb.current = undefined;
130+
}
131+
132+
return () => {
133+
disconnect();
134+
removeEventListeners();
135+
rfb.current = undefined;
136+
};
137+
}, [connect, onInitFailed, removeEventListeners, vncLogging]);
138+
139+
const disconnect = () => {
140+
if (!rfb.current) {
141+
return;
142+
}
143+
rfb.current.disconnect();
144+
};
145+
146+
return (
147+
<div className="vm-console-vnc">
148+
<div id={consoleContainerId} ref={novncElem} />
149+
</div>
150+
);
151+
};
152+
153+
VncConsole.displayName = 'VncConsole';

src/components/vm/consoles/common.jsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* This file is part of Cockpit.
3+
*
4+
* Copyright (C) 2025 Red Hat, Inc.
5+
*
6+
* Cockpit is free software; you can redistribute it and/or modify it
7+
* under the terms of the GNU Lesser General Public License as published by
8+
* the Free Software Foundation; either version 2.1 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* Cockpit is distributed in the hope that it will be useful, but
12+
* WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
import React from 'react';
21+
import cockpit from 'cockpit';
22+
23+
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
24+
import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content";
25+
import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
26+
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
27+
import { HelpIcon } from "@patternfly/react-icons";
28+
import { fmt_to_fragments } from 'utils.jsx';
29+
import { domainDesktopConsole } from '../../../libvirtApi/domain.js';
30+
31+
const _ = cockpit.gettext;
32+
33+
export function connection_address() {
34+
let address;
35+
if (cockpit.transport.host == "localhost") {
36+
const app = cockpit.transport.application();
37+
if (app.startsWith("cockpit+=")) {
38+
address = app.substr(9);
39+
} else {
40+
address = window.location.hostname;
41+
}
42+
} else {
43+
address = cockpit.transport.host;
44+
const pos = address.indexOf("@");
45+
if (pos >= 0) {
46+
address = address.substr(pos + 1);
47+
}
48+
}
49+
return address;
50+
}
51+
52+
export function console_launch(vm, consoleDetail) {
53+
// fire download of the .vv file
54+
domainDesktopConsole({ name: vm.name, consoleDetail: { ...consoleDetail, address: connection_address() } });
55+
}
56+
57+
const RemoteConnectionInfo = ({ hide, url, onEdit }) => {
58+
function CDD(term, description) {
59+
// What is this? Java?
60+
return (
61+
<>
62+
<Content component={ContentVariants.dt}>{term}</Content>
63+
<Content component={ContentVariants.dd}>{description}</Content>
64+
</>
65+
);
66+
}
67+
68+
return (
69+
<>
70+
<Content component={ContentVariants.p}>
71+
{fmt_to_fragments(_("Clicking \"Launch viewer\" will download a $0 file and launch the Remote Viewer application on your system."), <code>.vv</code>)}
72+
</Content>
73+
<Content component={ContentVariants.p}>
74+
{_("Remote Viewer is available for most operating systems. To install it, search for \"Remote Viewer\" in GNOME Software, KDE Discover, or run the following:")}
75+
</Content>
76+
<Content component={ContentVariants.dl}>
77+
{CDD(_("RHEL, CentOS"), <code>sudo yum install virt-viewer</code>)}
78+
{CDD(_("Fedora"), <code>sudo dnf install virt-viewer</code>)}
79+
{CDD(_("Ubuntu, Debian"), <code>sudo apt-get install virt-viewer</code>)}
80+
{CDD(_("Windows"),
81+
fmt_to_fragments(
82+
_("Download the MSI from $0"),
83+
<a href="https://virt-manager.org/download" target="_blank" rel="noopener noreferrer">
84+
virt-manager.org
85+
</a>))}
86+
</Content>
87+
{ url &&
88+
<>
89+
<Content component={ContentVariants.p}>
90+
{_("Other remote viewer applications can connect to the following address:")}
91+
</Content>
92+
<ClipboardCopy
93+
hoverTip={_("Copy to clipboard")}
94+
clickTip={_("Successfully copied to clipboard!")}
95+
variant="inline-compact"
96+
>
97+
{url}
98+
</ClipboardCopy>
99+
<Content component={ContentVariants.p} />
100+
</>
101+
}
102+
{ onEdit &&
103+
<Button isInline variant="link" onClick={() => { hide(); onEdit() }}>
104+
{_("Set port and password")}
105+
</Button>
106+
}
107+
</>
108+
);
109+
};
110+
111+
export const RemoteConnectionPopover = ({ url, onEdit }) => {
112+
return (
113+
<Popover
114+
className="ct-remote-viewer-popover"
115+
headerContent={_("Remote viewer")}
116+
bodyContent={(hide) =>
117+
<RemoteConnectionInfo
118+
hide={hide}
119+
url={url}
120+
onEdit={onEdit}
121+
/>
122+
}
123+
>
124+
<Button icon={<HelpIcon />} variant="plain" />
125+
</Popover>
126+
);
127+
};

0 commit comments

Comments
 (0)