Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 338 : import cockpit from "cockpit";
7 338 : 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 338 : 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 338 : export class TopNav extends React.Component {
43 338 : static contextType = DialogsContext;
44 : declare context: React.ContextType<typeof DialogsContext>;
45 :
46 : props: TopNavProps;
47 : state: TopNavState;
48 :
49 335 : superuser_connection: cockpit.DBusClient | null = null;
50 335 : superuser: SuperuserProxy | null = null;
51 :
52 : handleClickOutside: () => void;
53 :
54 335 : constructor(props: TopNavProps) {
55 335 : super(props);
56 335 : this.props = props;
57 :
58 335 : this.state = {
59 335 : docsOpened: false,
60 335 : menuOpened: false,
61 335 : showActivePages: false,
62 335 : osRelease: {},
63 335 : theme: localStorage.getItem('shell:style') || 'auto',
64 335 : };
65 :
66 268 : this.handleClickOutside = () => this.setState({ menuOpened: false, docsOpened: false });
67 335 : }
68 :
69 335 : componentDidMount() {
70 62 : 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 335 : window.addEventListener("blur", this.handleClickOutside);
76 335 : }
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 335 : render() {
97 335 : const Dialogs = this.context!;
98 335 : const {
99 335 : current_machine,
100 335 : current_manifest_item,
101 335 : current_manifest,
102 335 : current_frame,
103 335 : } = this.props.state;
104 :
105 335 : cockpit.assert(current_machine && current_manifest && current_manifest_item);
106 :
107 335 : const connected = current_machine.state === "connected";
108 :
109 335 : let docs: ManifestDocs[] = [];
110 :
111 335 : if (!this.superuser_connection || (this.superuser_connection.options.host !=
112 335 : current_machine.connection_string)) {
113 71 : if (this.superuser_connection) {
114 71 : this.superuser_connection.close();
115 71 : this.superuser_connection = null;
116 71 : }
117 :
118 335 : if (connected) {
119 335 : this.superuser_connection = cockpit.dbus(null, { bus: "internal", host: current_machine.connection_string });
120 335 : this.superuser = superuser_proxy(this.superuser_connection);
121 335 : }
122 335 : }
123 :
124 : // Check first whether we have docs in the "parent" section of
125 : // the manifest.
126 331 : const parent = (current_manifest.parent || {}) as ManifestParentSection;
127 335 : if (parent.docs)
128 67 : docs = parent.docs;
129 331 : else if (current_manifest_item.docs)
130 295 : docs = current_manifest_item.docs;
131 :
132 335 : const docItems = [];
133 :
134 335 : if (typeof this.state.osRelease?.DOCUMENTATION_URL == "string")
135 334 : docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
136 334 : {cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)}
137 334 : </DropdownItem>);
138 :
139 335 : const shell_manifest = this.props.state.config.manifest;
140 :
141 : // global documentation for cockpit as a whole
142 61 : (shell_manifest.docs ?? []).forEach(doc => {
143 335 : docItems.push(<DropdownItem key={doc.label} to={doc.url} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
144 335 : {doc.label}
145 335 : </DropdownItem>);
146 335 : });
147 :
148 335 : if (docs.length > 0)
149 299 : docItems.push(<Divider key="separator" />);
150 :
151 296 : docs.forEach(e => {
152 296 : docItems.push(<DropdownItem key={e.label} to={e.url} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
153 296 : {_(e.label)}
154 296 : </DropdownItem>);
155 296 : });
156 :
157 335 : docItems.push(<Divider key="separator1" />);
158 335 : docItems.push(<DropdownItem key="about" component="button"
159 1 : onClick={() => Dialogs.run(AboutCockpitModal, {})}>
160 335 : {_("About Web Console")}
161 335 : </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 335 : const main_menu = [
165 335 : <li // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions,jsx-a11y/click-events-have-key-events
166 335 : id="super-user-indicator-mobile"
167 335 : className="pf-v6-c-menu__list-item pf-v6-c-menu__item mobile_v"
168 335 : key="superusermobile"
169 0 : onClick={() => {
170 0 : this.setState((prevState: TopNavState) => { return { menuOpened: !prevState.menuOpened } });
171 0 : }}>
172 335 : <SuperuserIndicator proxy={this.superuser} host={current_machine.connection_string} />
173 335 : </li>,
174 335 : <Divider key="separator2" className="mobile_v" />,
175 335 : <DropdownGroup label={_("Style")} key="dark-switcher">
176 335 : <DropdownList>
177 335 : <DropdownItem key="dark-switcher-menu" component="div">
178 335 : <ToggleGroup key="dark-switcher-togglegroup">
179 335 : <ToggleGroupItem key="dark-switcher-auto" buttonId="auto" text={_("Default")}
180 335 : isSelected={this.state.theme === "auto"}
181 1 : onChange={() => this.handleModeClick("auto")} />
182 335 : <ToggleGroupItem key="dark-switcher-light" buttonId="light" text={_("Light")}
183 335 : isSelected={this.state.theme === "light"}
184 1 : onChange={() => this.handleModeClick("light")} />
185 335 : <ToggleGroupItem key="dark-switcher-dark" buttonId="dark" text={_("Dark")}
186 335 : isSelected={this.state.theme === "dark"}
187 1 : onChange={() => this.handleModeClick("dark")} />
188 335 : </ToggleGroup>
189 335 : </DropdownItem>
190 335 : </DropdownList>
191 335 : </DropdownGroup>,
192 335 : <Divider key="separatorDark" />,
193 335 : ];
194 :
195 335 : if (shell_manifest.locales)
196 335 : main_menu.push(<DropdownItem key="languages" className="display-language-menu"
197 0 : onClick={() => Dialogs.run(LangModal, { state: this.props.state })}>
198 335 : {_("Display language")}
199 335 : </DropdownItem>);
200 :
201 335 : if (this.state.showActivePages)
202 62 : main_menu.push(
203 62 : <DropdownItem key="frames" id="active-pages" component="button"
204 1 : onClick={() => Dialogs.run(ActivePagesDialog, { state: this.props.state })}>
205 62 : {_("Active pages")}
206 62 : </DropdownItem>);
207 :
208 335 : main_menu.push(
209 335 : <DropdownItem key="creds" id="sshkeys" component="button"
210 2 : onClick={() => Dialogs.run(CredentialsModal, {})}>
211 335 : {_("SSH keys")}
212 335 : </DropdownItem>,
213 335 : <Divider key="separator3" />,
214 335 : <DropdownItem key="logout" id="logout" component="button" onClick={cockpit.logout}>
215 335 : {_("Log out")}
216 335 : </DropdownItem>,
217 335 : );
218 :
219 335 : return (
220 335 : <Masthead>
221 335 : <MastheadContent>
222 335 : <Toolbar id="toolbar" isFullHeight isStatic>
223 335 : <ToolbarContent className="ct-topnav-content">
224 335 : { (current_frame && !current_frame.ready) &&
225 335 : <ToolbarItem id="machine-spinner">
226 335 : <Spinner size="lg" style={{ "--pf-v6-c-spinner--diameter": "2rem" } as React.CSSProperties } />
227 335 : </ToolbarItem>
228 : }
229 335 : { connected &&
230 335 : <ToolbarItem id="super-user-indicator" className="super-user-indicator desktop_v">
231 335 : <SuperuserIndicator proxy={this.superuser} host={current_machine.connection_string} />
232 335 : </ToolbarItem>
233 : }
234 335 : { this.props.state.has_oops &&
235 62 : <ToolbarItem>
236 62 : <Button id="navbar-oops" variant="link" size="lg" isDanger
237 0 : onClick={() => Dialogs.run(OopsModal, {})}>{_("Ooops!")}</Button>
238 62 : </ToolbarItem>
239 : }
240 335 : <ToolbarItem>
241 335 : <Dropdown
242 335 : 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 335 : toggle={(toggleRef) => (
250 335 : <MenuToggle
251 335 : ref={toggleRef}
252 335 : id="toggle-docs"
253 335 : variant="plain"
254 335 : className="ct-nav-toggle"
255 335 : icon={<Icon size="lg"><HelpIcon className="toggle-docs-icon" /></Icon>}
256 335 : isExpanded={this.state.docsOpened}
257 335 : isFullHeight
258 1 : onClick={() => {
259 1 : this.setState((prevState: TopNavState) => ({
260 1 : docsOpened: !prevState.docsOpened,
261 1 : menuOpened: false
262 1 : }));
263 1 : }}>
264 335 : {_("Help")}
265 335 : </MenuToggle>
266 : )}
267 335 : isOpen={this.state.docsOpened}
268 335 : popperProps={{ position: "right" }}
269 : >
270 335 : <DropdownList>
271 335 : {docItems}
272 335 : </DropdownList>
273 335 : </Dropdown>
274 335 : </ToolbarItem>
275 335 : <ToolbarItem>
276 335 : <Dropdown
277 335 : 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 335 : toggle={(toggleRef) => (
285 335 : <MenuToggle
286 335 : ref={toggleRef}
287 335 : id="toggle-menu"
288 335 : variant="plain"
289 335 : className="ct-nav-toggle"
290 335 : icon={<Icon size="lg"><CogIcon /></Icon>}
291 335 : isExpanded={this.state.menuOpened}
292 335 : 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 335 : {_("Session")}
302 335 : </MenuToggle>
303 : )}
304 335 : isOpen={this.state.menuOpened}
305 335 : popperProps={{ position: "right" }}
306 : >
307 335 : <DropdownList>
308 335 : {main_menu}
309 335 : </DropdownList>
310 335 : </Dropdown>
311 335 : </ToolbarItem>
312 335 : </ToolbarContent>
313 335 : </Toolbar>
314 335 : </MastheadContent>
315 335 : </Masthead>
316 : );
317 335 : }
318 338 : }
|