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