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