-
Notifications
You must be signed in to change notification settings - Fork 83
consoles: Redesign #2008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
consoles: Redesign #2008
Conversation
69dcfd9
to
77c4eb9
Compare
77c4eb9
to
0fe5bbc
Compare
f65bf5f
to
ace6a87
Compare
165110f
to
4f73613
Compare
5137d12
to
8128512
Compare
a363668
to
ae64118
Compare
ae64118
to
111442f
Compare
111442f
to
1edb67d
Compare
5c2254e
to
caadd56
Compare
a192cf0
to
5c63980
Compare
The move to PF6 makes this more important. React-console 6.0.0 uses inline CSS and generated class names, both of which is problematic for us. |
5c63980
to
aeb7548
Compare
45f1f50
to
cf185c5
Compare
cf185c5
to
4858e67
Compare
3a73c31
to
5f05d08
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @mvollmer - I've reviewed your changes - here's some feedback:
Overall Comments:
- This is a large change, so consider breaking it up into smaller, more manageable pull requests.
- The diff includes a lot of refactoring and moving code around, which makes it hard to review; consider separating functional changes from pure refactoring.
Here's what I looked at during the review
- 🟡 General issues: 1 issue found
- 🟢 Security: all looks good
- 🟢 Testing: all looks good
- 🟡 Complexity: 2 issues found
- 🟢 Documentation: all looks good
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
@@ -892,7 +892,7 @@ function shlex_quote(str) { | |||
return "'" + str.replaceAll("'", "'\"'\"'") + "'"; | |||
} | |||
|
|||
async function domainSetXML(vm, option, values) { | |||
async function domainModifyXML(vm, action, option, type, values) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Review the parameters and naming of domainModifyXML.
Since this function generalizes XML modifications for both adding and editing devices, consider whether its parameter order or naming might be improved for clarity. For instance, documenting the expected values for 'action' and 'type' would help maintainers safely extend this function.
async function domainModifyXML(vm, action, option, type, values) { | |
/** | |
* Modifies XML for Libvirt domains. | |
* | |
* @param {Object} vm - The virtual machine connection object. | |
* @param {string} action - The XML modification action. Expected values: "add", "edit", "delete". | |
* @param {string} option - The XML option to be modified. | |
* @param {string} type - The device type. Expected values include "disk", "interface", etc. | |
* @param {Object} values - Key-value pairs representing the new settings. | |
*/ | |
async function domainModifyXML(vm, action, option, type, values) { |
return 'DesktopViewer'; | ||
} | ||
} | ||
export const ConsoleCard = ({ state, vm, config, onAddErrorNotification, isExpanded }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (complexity): Consider extracting portions of the render logic into smaller, dedicated components or helper functions to improve clarity and maintainability, such as extracting the console body selection and tab generation into separate functions/components .
The overall changes add more UI details and branch handling, but the complexity in rendering logic (with deep nested conditions for each console type) remains high. To keep functionality intact while improving clarity and maintainability, consider extracting portions of the render logic into small, dedicated components or helper functions. For example, you might extract the console body selection and tab generation into separate functions/components. This not only makes the main render method leaner but also makes each part easier to test and update independently.
Actionable Steps:
-
Extract Console Body Logic:
Create a helper function/component (e.g.ConsoleBody
) that takes instate
,vm
,onAddErrorNotification
, andisExpanded
and returns the appropriate body JSX.// ConsoleBody.jsx import React from 'react'; import { VncMissing, VncInactive, VncActive, VncActiveActions, VncPending } from './vnc'; import { SpiceActive, SpiceInactive } from './spice'; import { SerialActive, SerialActiveActions, SerialInactive, SerialPending, SerialMissing } from './serial'; export const ConsoleBody = ({ state, vm, onAddErrorNotification, isExpanded }) => { const serials = vm.displays && vm.displays.filter(disp => disp.type === 'pty'); const inactive_serials = vm.inactiveXML.displays && vm.inactiveXML.displays.filter(disp => disp.type === 'pty'); const vnc = vm.displays && vm.displays.find(disp => disp.type === 'vnc'); const inactive_vnc = vm.inactiveXML.displays && vm.inactiveXML.displays.find(disp => disp.type === 'vnc'); const spice = vm.displays && vm.displays.find(disp => disp.type === 'spice'); const inactive_spice = vm.inactiveXML.displays && vm.inactiveXML.displays.find(disp => disp.type === 'spice'); const type = state.type || (vnc || serials.length === 0 ? "graphical" : "text0"); if (type === "graphical") { if (vm.state !== "running") { if (!inactive_vnc && !inactive_spice) { return <VncMissing vm={vm} />; } else if (inactive_vnc) { return <VncInactive vm={vm} inactive_vnc={inactive_vnc} onAddErrorNotification={onAddErrorNotification} />; } return <SpiceInactive vm={vm} />; } if (vnc) { return ( <> <VncActive state={state.vncState} vm={vm} consoleDetail={vnc} inactiveConsoleDetail={inactive_vnc} spiceDetail={spice} onAddErrorNotification={onAddErrorNotification} isExpanded={isExpanded} /> <VncActiveActions state={state.vncState} vm={vm} vnc={vnc} /> </> ); } else if (inactive_vnc) { return <VncPending vm={vm} inactive_vnc={inactive_vnc} onAddErrorNotification={onAddErrorNotification} />; } else if (spice) { return <SpiceActive vm={vm} spice={spice} />; } else { return <VncMissing vm={vm} onAddErrorNotification={onAddErrorNotification} />; } } // Handle serial / text consoles. if (serials.length > 0) { const serial_state = state.serialStates.get(serials[0].alias || 0); if (vm.state !== "running") { return <SerialInactive vm={vm} />; } return ( <> <SerialActive state={serial_state} connectionName={vm.connectionName} vmName={vm.name} spawnArgs={/* your spawnArgs */} /> <SerialActiveActions state={serial_state} /> </> ); } else { if (inactive_serials.length > 0) { return <SerialPending vm={vm} />; } return <SerialMissing vm={vm} onAddErrorNotification={onAddErrorNotification} />; } }; export default ConsoleBody;
-
Extract Tabs Generation:
Similarly, you can create a helper component (e.g.ConsoleTabs
) that generates the toggle group items based on the console types available.// ConsoleTabs.jsx import React from 'react'; import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core'; const ConsoleTabs = ({ vm, state }) => { const serials = vm.displays && vm.displays.filter(display => display.type === 'pty'); const vnc = vm.displays && vm.displays.find(display => display.type === 'vnc'); const spice = vm.displays && vm.displays.find(display => display.type === 'spice'); let type = state.type || (vnc || serials.length === 0 ? 'graphical' : 'text0'); const tabs = []; tabs.push( <ToggleGroupItem key="graphical" text="Graphical" isSelected={type === 'graphical'} onChange={() => state.setType('graphical')} /> ); if (serials.length > 0) { serials.forEach((pty, idx) => { const key = "text" + idx; tabs.push( <ToggleGroupItem key={key} text={serials.length === 1 ? "Text" : `Text (${pty.alias || idx})`} isSelected={type === key} onChange={() => state.setType(key)} /> ); }); } else { tabs.push( <ToggleGroupItem key="text0" text="Text" isSelected={type === 'text0'} onChange={() => state.setType('text0')} /> ); } return <ToggleGroup>{tabs}</ToggleGroup>; }; export default ConsoleTabs;
-
Simplify Main Component (ConsoleCard):
With these components, the main component becomes easier to parse.// ConsoleCard.jsx import React from 'react'; import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; import { Split, SplitItem } from '@patternfly/react-core/dist/esm/layouts/Split'; import { ExpandIcon, CompressIcon } from '@patternfly/react-icons'; import { Button } from "@patternfly/react-core"; import ConsoleBody from './ConsoleBody'; import ConsoleTabs from './ConsoleTabs'; import { vmId } from "../../../helpers.js"; export const ConsoleCard = ({ state, vm, config, onAddErrorNotification, isExpanded }) => { const actions = []; if (!isExpanded) { actions.push( <Button key="expand" variant="link" onClick={() => { const urlOptions = { name: vm.name, connection: vm.connectionName }; return cockpit.location.go(["vm", "console"], { ...cockpit.location.options, ...urlOptions }); }} icon={<ExpandIcon />} iconPosition="right">Expand</Button> ); } else { actions.push( <Button key="compress" variant="link" onClick={() => { const urlOptions = { name: vm.name, connection: vm.connectionName }; return cockpit.location.go(["vm"], { ...cockpit.location.options, ...urlOptions }); }} icon={<CompressIcon />} iconPosition="right">Compress</Button> ); } return ( <Card isPlain={isExpanded} className="ct-card consoles-card" id={`${vmId(vm.name)}-consoles`}> <CardHeader actions={{ actions }}> <Split hasGutter> <SplitItem> <CardTitle component="h2">{isExpanded ? vm.name : "Console"}</CardTitle> </SplitItem> <SplitItem> <ConsoleTabs vm={vm} state={state} /> </SplitItem> </Split> </CardHeader> <CardBody> <div className="vm-console"> <ConsoleBody state={state} vm={vm} onAddErrorNotification={onAddErrorNotification} isExpanded={isExpanded} /> </div> </CardBody> </Card> ); };
By extracting these pieces, you reduce the cognitive load within a single component and ease future maintenance or enhancements.
import { EventEmitter } from 'cockpit/event'; | ||
import { useObject, useOn } from 'hooks'; | ||
|
||
export class StateObject extends EventEmitter { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (complexity): Consider simplifying state management by using React's built-in hooks instead of an EventEmitter and custom event wiring when dynamic event wiring isn't needed, as this reduces abstraction and boilerplate while retaining dynamic update ability via a setState callback wrapper passed to state objects, encapsulated in a custom hook like useSimpleState
to keep behavior transparent and focused, replacing the need for EventEmitter
and follow
methods with a standard state update mechanism.
If you don’t need full event-based plumbing for most state updates – especially for simple state objects – you could consider simplifying by relying more on React’s built-in hooks. For example, instead of embedding an EventEmitter inside your state object and then wiring in “render” events, consider a custom hook that couples useState with an update call:
// Before (custom abstraction)
class StateObject extends EventEmitter {
update() {
this.emit("render");
}
follow(obj, callback) {
return obj.on("render", callback || (() => this.update()));
}
close() {}
}
export function useStateObject(constructor, deps, comps) {
const state = useObject(constructor, obj => obj.close(), deps || [], comps);
useOn(state, "render");
return state;
}
// After (simpler approach using built-in React hooks)
function useSimpleState(constructor) {
const [state, setState] = useState(constructor());
// Assume your state objects have methods that should trigger a re-render;
// you can wrap them to automatically update local state.
const update = useCallback(() => setState({ ...state }), [state]);
// Inject this updater into your state if needed:
if (typeof state.setUpdate === 'function') {
state.setUpdate(update);
}
return state;
}
Example usage:
class MyState {
constructor() {
this.whatever = 12;
}
// Allow a callback to be set that triggers a re-render.
setUpdate(cb) {
this._update = cb;
}
setWhatever(val) {
this.whatever = val;
if (this._update) {
this._update();
}
}
}
const MyComp = () => {
const state = useSimpleState(() => new MyState());
return <span>{state.whatever}</span>;
};
Actionable Steps:
- Remove EventEmitter and follow: If dynamic event wiring isn’t critical, substitute it with a standard state update mechanism (e.g. reassigning state with useState).
- Provide an updating callback: Instead of using an event “render” to trigger re-renders, pass a callback (like
setState
wrapper) to your state objects. - Encapsulate logic in a small custom hook: Use a focused hook (as in
useSimpleState
) to minimize boilerplate while keeping behavior transparent.
This retains your dynamic update ability while reducing the extra layer of abstraction introduced by EventEmitter and follow methods.
828abd9
to
ca0d0b9
Compare
ca0d0b9
to
95f8b3a
Compare
// a pending shutdown. | ||
// | ||
if (inactive_vnc.port != -1 && active_vnc.port != inactive_vnc.port) | ||
return true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
return true; | ||
|
||
if (active_vnc.password != inactive_vnc.password) | ||
return true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
repeaterID = '', | ||
vncLogging = 'warn', | ||
consoleContainerId, | ||
onDisconnected = () => {}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
(e) => { | ||
onSecurityFailure(e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 2 added lines are not executed by any test.
}, [rfb, _onDisconnected, _onSecurityFailure]); | ||
|
||
const connect = React.useCallback(() => { | ||
const protocol = encrypt ? 'wss' : 'ws'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
{ applyError && | ||
<ModalError dialogError={applyError} dialogErrorDetail={applyErrorDetail} /> | ||
} | ||
<Form onSubmit={e => e.preventDefault()} isHorizontal> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
.catch(ex => onAddErrorNotification({ | ||
text: cockpit.format(_("Failed to send key Ctrl+Alt+$0 to VM $1"), keyName, vm.name), | ||
detail: ex.message, | ||
resourceId: vm.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 4 added lines are not executed by any test.
{ state.connected | ||
? <VncConsole | ||
host={window.location.hostname} | ||
port={window.location.port || (encrypt ? '443' : '80')} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
encrypt={encrypt} | ||
shared | ||
credentials={this.credentials} | ||
vncLogging={ window.debugging?.includes("vnc") ? 'debug' : 'warn' } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
.catch(ex => onAddErrorNotification({ | ||
text: cockpit.format(_("Failed to add VNC to VM $0"), vm.name), | ||
detail: ex.message, | ||
resourceId: vm.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 4 added lines are not executed by any test.
- 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.
95f8b3a
to
8c98db2
Compare
COCKPIT-1225
Demo: https://www.youtube.com/watch?v=PHHoSxs70Ho
Questions: