LCOV - code coverage report
Current view: top level - pkg/shell - topnav.tsx Coverage Total Hit
Test: cockpit Lines: 96.1 % 229 220
Test Date: 2026-06-17 06:28:00

            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 : }
        

Generated by: LCOV version 2.0-1