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