Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 341 : import cockpit from "cockpit";
7 341 : import React from "react";
8 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
9 : import { Dropdown, DropdownGroup, DropdownItem, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
10 : import { MenuToggle } from "@patternfly/react-core/dist/esm/components/MenuToggle";
11 : import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
12 : import { Masthead, MastheadContent } from "@patternfly/react-core/dist/esm/components/Masthead/index.js";
13 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
14 : import { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/components/ToggleGroup/index.js";
15 : import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
16 : import { CogIcon, ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
17 : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
18 :
19 : import { ShellState } from "./state";
20 : import { ManifestDocs, ManifestParentSection } from "./manifests";
21 : import { ActivePagesDialog } from "./active-pages-modal.jsx";
22 : import { CredentialsModal } from './credentials.jsx';
23 : import { AboutCockpitModal, LangModal, OopsModal } from "./shell-modals.jsx";
24 : import { superuser_proxy, SuperuserProxy, SuperuserIndicator } from "superuser-dialogs";
25 : import { read_os_release } from "os-release.js";
26 : import { DialogsContext } from "dialogs.jsx";
27 :
28 341 : const _ = cockpit.gettext;
29 :
30 : interface TopNavProps {
31 : state: ShellState;
32 : }
33 :
34 : interface TopNavState {
35 : docsOpened: boolean;
36 : menuOpened: boolean;
37 : showActivePages: boolean;
38 : osRelease: cockpit.JsonObject;
39 : theme: string;
40 : }
41 :
42 341 : export class TopNav extends React.Component {
43 341 : static contextType = DialogsContext;
44 : declare context: React.ContextType<typeof DialogsContext>;
45 :
46 : props: TopNavProps;
47 : state: TopNavState;
48 :
49 338 : superuser_connection: cockpit.DBusClient | null = null;
50 338 : superuser: SuperuserProxy | null = null;
51 :
52 : handleClickOutside: () => void;
53 :
54 338 : constructor(props: TopNavProps) {
55 338 : super(props);
56 338 : this.props = props;
57 :
58 338 : this.state = {
59 338 : docsOpened: false,
60 338 : menuOpened: false,
61 338 : showActivePages: false,
62 338 : osRelease: {},
63 338 : theme: localStorage.getItem('shell:style') || 'auto',
64 338 : };
65 :
66 271 : this.handleClickOutside = () => this.setState({ menuOpened: false, docsOpened: false });
67 338 : }
68 :
69 338 : componentDidMount() {
70 64 : read_os_release().then(os => this.setState({ osRelease: os || {} }));
71 : /* This is a HACK for catching lost clicks on the pages which live in iframes so as to close dropdown menus on the shell.
72 : * Note: Clicks on an <iframe> element won't trigger document.documentElement listeners, because it's literally different page with different security domain.
73 : * However, when clicking on an iframe moves focus to its content's window that triggers the main window.blur event.
74 : */
75 338 : window.addEventListener("blur", this.handleClickOutside);
76 338 : }
77 :
78 0 : componentWillUnmount() {
79 0 : window.removeEventListener("blur", this.handleClickOutside);
80 0 : }
81 :
82 1 : handleModeClick = (theme: string) => {
83 1 : this.setState({ theme });
84 :
85 1 : const styleEvent = new CustomEvent("cockpit-style", {
86 1 : detail: {
87 1 : style: theme,
88 1 : }
89 1 : });
90 1 : window.dispatchEvent(styleEvent);
91 :
92 1 : localStorage.setItem("shell:style", theme);
93 1 : this.props.state.update();
94 1 : };
95 :
96 338 : render() {
97 338 : const Dialogs = this.context!;
98 338 : const {
99 338 : current_machine,
100 338 : current_manifest_item,
101 338 : current_manifest,
102 338 : current_frame,
103 338 : } = this.props.state;
104 :
105 338 : cockpit.assert(current_machine && current_manifest && current_manifest_item);
106 :
107 338 : const connected = current_machine.state === "connected";
108 :
109 338 : let docs: ManifestDocs[] = [];
110 :
111 338 : if (!this.superuser_connection || (this.superuser_connection.options.host !=
112 338 : current_machine.connection_string)) {
113 73 : if (this.superuser_connection) {
114 73 : this.superuser_connection.close();
115 73 : this.superuser_connection = null;
116 73 : }
117 :
118 338 : if (connected) {
119 338 : this.superuser_connection = cockpit.dbus(null, { bus: "internal", host: current_machine.connection_string });
120 338 : this.superuser = superuser_proxy(this.superuser_connection);
121 338 : }
122 338 : }
123 :
124 : // Check first whether we have docs in the "parent" section of
125 : // the manifest.
126 334 : const parent = (current_manifest.parent || {}) as ManifestParentSection;
127 338 : if (parent.docs)
128 69 : docs = parent.docs;
129 334 : else if (current_manifest_item.docs)
130 297 : docs = current_manifest_item.docs;
131 :
132 338 : const docItems = [];
133 :
134 338 : if (typeof this.state.osRelease?.DOCUMENTATION_URL == "string")
135 337 : docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
136 337 : {cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)}
137 337 : </DropdownItem>);
138 :
139 338 : const shell_manifest = this.props.state.config.manifest;
140 :
141 : // global documentation for cockpit as a whole
142 63 : (shell_manifest.docs ?? []).forEach(doc => {
143 338 : docItems.push(<DropdownItem key={doc.label} to={doc.url} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
144 338 : {doc.label}
145 338 : </DropdownItem>);
146 338 : });
147 :
148 338 : if (docs.length > 0)
149 301 : docItems.push(<Divider key="separator" />);
150 :
151 297 : docs.forEach(e => {
152 297 : docItems.push(<DropdownItem key={e.label} to={e.url} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
153 297 : {_(e.label)}
154 297 : </DropdownItem>);
155 297 : });
156 :
157 338 : docItems.push(<Divider key="separator1" />);
158 338 : docItems.push(<DropdownItem key="about" component="button"
159 1 : onClick={() => Dialogs.run(AboutCockpitModal, {})}>
160 338 : {_("About Web Console")}
161 338 : </DropdownItem>);
162 :
163 : // HACK: This should be a DropdownItem so the normal onSelect closing behaviour works, but we can't embed a button in a button
164 338 : const main_menu = [
165 338 : <li // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions,jsx-a11y/click-events-have-key-events
166 338 : id="super-user-indicator-mobile"
167 338 : className="pf-v6-c-menu__list-item pf-v6-c-menu__item mobile_v"
168 338 : key="superusermobile"
169 0 : onClick={() => {
170 0 : this.setState((prevState: TopNavState) => { return { menuOpened: !prevState.menuOpened } });
171 0 : }}>
172 338 : <SuperuserIndicator proxy={this.superuser} host={current_machine.connection_string} />
173 338 : </li>,
174 338 : <Divider key="separator2" className="mobile_v" />,
175 338 : <DropdownGroup label={_("Style")} key="dark-switcher">
176 338 : <DropdownList>
177 338 : <DropdownItem key="dark-switcher-menu" component="div">
178 338 : <ToggleGroup key="dark-switcher-togglegroup">
179 338 : <ToggleGroupItem key="dark-switcher-auto" buttonId="auto" text={_("Default")}
180 338 : isSelected={this.state.theme === "auto"}
181 1 : onChange={() => this.handleModeClick("auto")} />
182 338 : <ToggleGroupItem key="dark-switcher-light" buttonId="light" text={_("Light")}
183 338 : isSelected={this.state.theme === "light"}
184 1 : onChange={() => this.handleModeClick("light")} />
185 338 : <ToggleGroupItem key="dark-switcher-dark" buttonId="dark" text={_("Dark")}
186 338 : isSelected={this.state.theme === "dark"}
187 1 : onChange={() => this.handleModeClick("dark")} />
188 338 : </ToggleGroup>
189 338 : </DropdownItem>
190 338 : </DropdownList>
191 338 : </DropdownGroup>,
192 338 : <Divider key="separatorDark" />,
193 338 : ];
194 :
195 338 : if (shell_manifest.locales)
196 338 : main_menu.push(<DropdownItem key="languages" className="display-language-menu"
197 0 : onClick={() => Dialogs.run(LangModal, { state: this.props.state })}>
198 338 : {_("Display language")}
199 338 : </DropdownItem>);
200 :
201 338 : if (this.state.showActivePages)
202 64 : main_menu.push(
203 64 : <DropdownItem key="frames" id="active-pages" component="button"
204 1 : onClick={() => Dialogs.run(ActivePagesDialog, { state: this.props.state })}>
205 64 : {_("Active pages")}
206 64 : </DropdownItem>);
207 :
208 338 : main_menu.push(
209 338 : <DropdownItem key="creds" id="sshkeys" component="button"
210 2 : onClick={() => Dialogs.run(CredentialsModal, {})}>
211 338 : {_("SSH keys")}
212 338 : </DropdownItem>,
213 338 : <Divider key="separator3" />,
214 338 : <DropdownItem key="logout" id="logout" component="button" onClick={cockpit.logout}>
215 338 : {_("Log out")}
216 338 : </DropdownItem>,
217 338 : );
218 :
219 338 : return (
220 338 : <Masthead>
221 338 : <MastheadContent>
222 338 : <Toolbar id="toolbar" isFullHeight isStatic>
223 338 : <ToolbarContent className="ct-topnav-content">
224 338 : { (current_frame && !current_frame.ready) &&
225 338 : <ToolbarItem id="machine-spinner">
226 338 : <Spinner size="lg" style={{ "--pf-v6-c-spinner--diameter": "2rem" } as React.CSSProperties } />
227 338 : </ToolbarItem>
228 : }
229 338 : { connected &&
230 338 : <ToolbarItem id="super-user-indicator" className="super-user-indicator desktop_v">
231 338 : <SuperuserIndicator proxy={this.superuser} host={current_machine.connection_string} />
232 338 : </ToolbarItem>
233 : }
234 338 : { this.props.state.has_oops &&
235 64 : <ToolbarItem>
236 64 : <Button id="navbar-oops" variant="link" size="lg" isDanger
237 0 : onClick={() => Dialogs.run(OopsModal, {})}>{_("Ooops!")}</Button>
238 64 : </ToolbarItem>
239 : }
240 338 : <ToolbarItem>
241 338 : <Dropdown
242 338 : id="toggle-docs-menu"
243 1 : onSelect={() => {
244 1 : this.setState((prevState: TopNavState) => {
245 1 : return { docsOpened: !prevState.docsOpened };
246 1 : });
247 1 : document.getElementById("toggle-docs")?.focus();
248 1 : }}
249 338 : toggle={(toggleRef) => (
250 338 : <MenuToggle
251 338 : ref={toggleRef}
252 338 : id="toggle-docs"
253 338 : variant="plain"
254 338 : className="ct-nav-toggle"
255 338 : icon={<Icon size="lg"><HelpIcon className="toggle-docs-icon" /></Icon>}
256 338 : isExpanded={this.state.docsOpened}
257 338 : isFullHeight
258 1 : onClick={() => {
259 1 : this.setState((prevState: TopNavState) => ({
260 1 : docsOpened: !prevState.docsOpened,
261 1 : menuOpened: false
262 1 : }));
263 1 : }}>
264 338 : {_("Help")}
265 338 : </MenuToggle>
266 : )}
267 338 : isOpen={this.state.docsOpened}
268 338 : popperProps={{ position: "right" }}
269 : >
270 338 : <DropdownList>
271 338 : {docItems}
272 338 : </DropdownList>
273 338 : </Dropdown>
274 338 : </ToolbarItem>
275 338 : <ToolbarItem>
276 338 : <Dropdown
277 338 : id="toggle-menu-menu"
278 4 : onSelect={() => {
279 4 : this.setState((prevState: TopNavState) => {
280 4 : return { menuOpened: !prevState.menuOpened };
281 4 : });
282 4 : document.getElementById("toggle-menu")?.focus();
283 4 : }}
284 338 : toggle={(toggleRef) => (
285 338 : <MenuToggle
286 338 : ref={toggleRef}
287 338 : id="toggle-menu"
288 338 : variant="plain"
289 338 : className="ct-nav-toggle"
290 338 : icon={<Icon size="lg"><CogIcon /></Icon>}
291 338 : isExpanded={this.state.menuOpened}
292 338 : isFullHeight
293 4 : onClick={(event) => {
294 4 : this.setState((prevState: TopNavState) => ({
295 4 : menuOpened: !prevState.menuOpened,
296 4 : docsOpened: false,
297 4 : showActivePages: event.altKey
298 4 : }));
299 4 : }}
300 : >
301 338 : {_("Session")}
302 338 : </MenuToggle>
303 : )}
304 338 : isOpen={this.state.menuOpened}
305 338 : popperProps={{ position: "right" }}
306 : >
307 338 : <DropdownList>
308 338 : {main_menu}
309 338 : </DropdownList>
310 338 : </Dropdown>
311 338 : </ToolbarItem>
312 338 : </ToolbarContent>
313 338 : </Toolbar>
314 338 : </MastheadContent>
315 338 : </Masthead>
316 : );
317 338 : }
318 341 : }
|