LCOV - code coverage report
Current view: top level - pkg/shell - hosts.tsx Coverage Total Hit
Test: cockpit Lines: 14.2 % 212 30
Test Date: 2026-07-02 14:11:36

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2024 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          123 : import cockpit from "cockpit";
       7              : 
       8          123 : import React from 'react';
       9          123 : import ReactDOM from "react-dom";
      10              : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
      11              : import {
      12              :     CaretDownIcon,
      13              :     CaretUpIcon,
      14              :     EditIcon,
      15              :     MinusIcon,
      16              : } from '@patternfly/react-icons';
      17              : import { PageSidebar } from "@patternfly/react-core/dist/esm/components/Page";
      18              : import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip";
      19              : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
      20              : 
      21              : import 'polyfills';
      22              : import { CockpitNav, CockpitNavItem } from "./nav.jsx";
      23              : import { encode_location } from "./util.jsx";
      24              : import { split_connection_string, Machine } from "./machines/machines";
      25              : import { add_host, edit_host, connect_host, HostModalState } from "./hosts_dialog.jsx";
      26              : 
      27              : import { ShellState } from "./state";
      28              : 
      29          123 : const _ = cockpit.gettext;
      30              : 
      31              : interface HostsSelectorProps {
      32              :     children: React.ReactNode;
      33              : }
      34              : 
      35          123 : class HostsSelector extends React.Component {
      36              :     el: HTMLDivElement;
      37              :     props: HostsSelectorProps;
      38              : 
      39            0 :     constructor(props: HostsSelectorProps) {
      40            0 :         super(props);
      41            0 :         this.props = props;
      42              : 
      43            0 :         this.el = document.createElement("div");
      44            0 :         this.el.className = "view-hosts";
      45            0 :     }
      46              : 
      47            0 :     componentDidMount() {
      48            0 :         const hosts_sel = document.getElementById("nav-hosts");
      49            0 :         hosts_sel?.appendChild(this.el);
      50            0 :     }
      51              : 
      52            0 :     componentWillUnmount() {
      53            0 :         const hosts_sel = document.getElementById("nav-hosts");
      54            0 :         hosts_sel?.removeChild(this.el);
      55            0 :     }
      56              : 
      57            0 :     render() {
      58            0 :         const { children } = this.props;
      59            0 :         return ReactDOM.createPortal(children, this.el);
      60            0 :     }
      61          123 : }
      62              : 
      63          120 : const HostLine = ({
      64          120 :     host,
      65          120 :     user
      66          120 : } : {
      67              :     host: React.ReactNode,
      68              :     user: React.ReactNode
      69          120 : }) => {
      70          120 :     return (
      71          120 :         <>
      72          120 :             <span id="current-username" className="username">{user}</span>
      73          120 :             {user && <span className="at">@</span>}
      74          120 :             <span className="hostname">{host}</span>
      75          120 :         </>
      76              :     );
      77          120 : };
      78              : 
      79              : // top left navigation element when host switching is disabled
      80          120 : export const CockpitCurrentHost = ({
      81          120 :     current_user,
      82          120 :     machine
      83          120 : } : {
      84              :     current_user: string,
      85              :     machine: Machine
      86          120 : }) => {
      87          120 :     return (
      88          120 :         <div className="ct-switcher ct-switcher-localonly pf-m-dark">
      89           19 :             <HostLine user={machine.user || current_user || ""} host={machine.label || ""} />
      90          120 :         </div>
      91              :     );
      92          120 : };
      93              : 
      94              : interface CockpitHostsProps {
      95              :     selector: string;
      96              :     host_modal_state: ReturnType<typeof HostModalState>;
      97              :     state: ShellState;
      98              : }
      99              : 
     100              : interface CockpitHostsState {
     101              :     opened: boolean;
     102              :     editing: boolean;
     103              :     current_user: string;
     104              :     current_key: string | undefined;
     105              : }
     106              : 
     107              : interface HostItem extends Machine {
     108              :     keyword?: string;
     109              : }
     110              : 
     111              : // full host switcher
     112          123 : export class CockpitHosts extends React.Component {
     113              :     props: CockpitHostsProps;
     114              :     state: CockpitHostsState;
     115              : 
     116            0 :     constructor(props : CockpitHostsProps) {
     117            0 :         super(props);
     118            0 :         this.props = props;
     119              : 
     120            0 :         this.state = {
     121            0 :             opened: false,
     122            0 :             editing: false,
     123            0 :             current_user: "",
     124            0 :             current_key: props.state.current_machine?.key,
     125            0 :         };
     126              : 
     127            0 :         this.toggleMenu = this.toggleMenu.bind(this);
     128            0 :         this.filterHosts = this.filterHosts.bind(this);
     129            0 :         this.onAddNewHost = this.onAddNewHost.bind(this);
     130            0 :         this.onEditHosts = this.onEditHosts.bind(this);
     131            0 :         this.onHostEdit = this.onHostEdit.bind(this);
     132            0 :         this.onRemove = this.onRemove.bind(this);
     133            0 :     }
     134              : 
     135            0 :     componentDidMount() {
     136            0 :         cockpit.user().then(user => {
     137            0 :             this.setState({ current_user: user.name || "" });
     138            0 :         }).catch(exc => console.log(exc));
     139            0 :     }
     140              : 
     141            0 :     static getDerivedStateFromProps(nextProps: CockpitHostsProps, prevState: CockpitHostsState) {
     142            0 :         if (nextProps.state.current_machine?.key !== prevState.current_key) {
     143            0 :             document.getElementById(nextProps.selector)?.classList.toggle("interact", false);
     144            0 :             return {
     145            0 :                 current_key: nextProps.state.current_machine?.key,
     146            0 :                 opened: false,
     147            0 :                 editing: false,
     148            0 :             };
     149            0 :         }
     150            0 :         return null;
     151            0 :     }
     152              : 
     153            0 :     toggleMenu() {
     154            0 :         document.getElementById(this.props.selector)?.classList.toggle("interact", !this.state.opened);
     155              : 
     156            0 :         this.setState((s: CockpitHostsState) => {
     157            0 :             return (
     158            0 :                 {
     159            0 :                     opened: !s.opened,
     160            0 :                     editing: false,
     161            0 :                 }
     162              :             );
     163            0 :         });
     164            0 :     }
     165              : 
     166            0 :     async onAddNewHost() {
     167            0 :         await add_host(this.props.host_modal_state, this.props.state);
     168            0 :     }
     169              : 
     170            0 :     async onHostEdit(machine: Machine) {
     171            0 :         await edit_host(this.props.host_modal_state, this.props.state, machine);
     172            0 :     }
     173              : 
     174            0 :     async onHostSwitch(machine: Machine) {
     175            0 :         const { state, host_modal_state } = this.props;
     176              : 
     177            0 :         const connection_string = await connect_host(host_modal_state, state, machine);
     178            0 :         if (connection_string) {
     179            0 :             const parts = split_connection_string(connection_string);
     180            0 :             state.jump({ host: parts.address });
     181            0 :         }
     182            0 :     }
     183              : 
     184            0 :     onEditHosts() {
     185            0 :         this.setState((s: CockpitHostsState) => { return { editing: !s.editing } });
     186            0 :     }
     187              : 
     188            0 :     onRemove(event: React.MouseEvent, machine: Machine) {
     189            0 :         const { state } = this.props;
     190            0 :         const { current_machine } = state;
     191              : 
     192            0 :         event.preventDefault();
     193              : 
     194            0 :         if (current_machine === machine) {
     195              :             // Removing machine underneath ourself - jump to localhost
     196            0 :             state.jump({ host: "localhost" });
     197            0 :         }
     198              : 
     199            0 :         if (state.machines.list.length <= 2)
     200            0 :             this.setState({ editing: false });
     201            0 :         state.machines.change(machine.key, { visible: false });
     202            0 :     }
     203              : 
     204            0 :     filterHosts(host: Machine, term: string): HostItem | null {
     205            0 :         if (!term)
     206            0 :             return host;
     207            0 :         const new_host: HostItem = Object.assign({}, host);
     208            0 :         term = term.toLowerCase();
     209              : 
     210            0 :         if (host.label.toLowerCase().indexOf(term) > -1)
     211            0 :             new_host.keyword = host.label.toLowerCase();
     212              : 
     213            0 :         const user = host.user || this.state.current_user;
     214            0 :         if (user.toLowerCase().indexOf(term) > -1)
     215            0 :             new_host.keyword = user.toLowerCase() + " @";
     216              : 
     217            0 :         if (new_host.keyword)
     218            0 :             return new_host;
     219            0 :         return null;
     220            0 :     }
     221              : 
     222              :     // HACK: using HTML rather than Select PF4 component as:
     223              :     // 1. It does not change the arrow when opened/closed
     224              :     // 2. It closes the dropdown even when trying to search... and cannot tell it not to
     225            0 :     render() {
     226            0 :         const { state } = this.props;
     227            0 :         const { current_machine } = state;
     228              : 
     229            0 :         const editing = this.state.editing;
     230            0 :         const groups = [{
     231            0 :             name: _("Hosts"),
     232            0 :             items: state.machines.list,
     233            0 :         }];
     234            0 :         const render = (m: HostItem, term: string) => <CockpitNavItem
     235            0 :                 term={term}
     236            0 :                 keyword={m.keyword || ""}
     237            0 :                 href={encode_location({ host: m.address })}
     238            0 :                 active={m === current_machine}
     239            0 :                 key={m.key}
     240            0 :                 name={m.label}
     241            0 :                 header={(m.user ? m.user : this.state.current_user) + " @"}
     242            0 :                 status={m.state === "failed" ? { type: "error", title: _("Connection error") } : null}
     243            0 :                 className={m.state || ""}
     244            0 :                 onClick={() => this.onHostSwitch(m)}
     245            0 :                 actions={
     246            0 :                     editing && <>
     247            0 :                         <Tooltip content={_("Edit")} position="right">
     248            0 :                             <Button icon={<EditIcon />} isDisabled={m.address === "localhost"} className="nav-action" hidden={!editing} onClick={_e => this.onHostEdit(m)} key={m.label + "edit"} variant="secondary" />
     249            0 :                         </Tooltip>
     250            0 :                         <Tooltip content={_("Remove")} position="right">
     251            0 :                             <Button icon={<MinusIcon />} isDisabled={m.address === "localhost"} onClick={e => this.onRemove(e, m)} className="nav-action" hidden={!editing} key={m.label + "remove"} variant="danger" />
     252            0 :                         </Tooltip>
     253            0 :                     </>
     254              :                 }
     255            0 :         />;
     256            0 :         const label = current_machine?.label || "";
     257            0 :         const user = current_machine?.user || this.state.current_user;
     258              : 
     259            0 :         const add_host_action = <Button variant="secondary" onClick={this.onAddNewHost}>{_("Add new host")}</Button>;
     260              : 
     261            0 :         return (
     262            0 :             <div className="ct-switcher">
     263            0 :                 <div className="pf-v6-c-select pf-m-dark">
     264            0 :                     <button onClick={this.toggleMenu} id="host-toggle" aria-labelledby="host-toggle" aria-expanded={(this.state.opened ? "true" : "false")} aria-haspopup="listbox" type="button" className="ct-nav-toggle pf-v6-c-select__toggle pf-v6-c-menu-toggle pf-m-plain">
     265            0 :                         <span className="pf-v6-c-button__text desktop_v">
     266            0 :                             <span className="pf-v6-c-select__toggle-wrapper">
     267            0 :                                 <span className="pf-v6-c-select__toggle-text">
     268            0 :                                     <HostLine user={user} host={label} />
     269            0 :                                 </span>
     270            0 :                             </span>
     271            0 :                         </span>
     272            0 :                         <Icon size="xl" className="mobile_v">
     273            0 :                             <CaretUpIcon
     274            0 :                                 className={`pf-v6-c-select__toggle-arrow ${this.state.opened ? 'clicked' : ''}`}
     275            0 :                                 aria-hidden="true"
     276            0 :                             />
     277            0 :                         </Icon>
     278            0 :                         <span className="pf-v6-c-select__toggle-wrapper mobile_v">
     279            0 :                             {_("Host")}
     280            0 :                         </span>
     281            0 :                         <Icon size="xl" className="desktop_v">
     282            0 :                             <CaretDownIcon
     283            0 :                                 className={`pf-v6-c-select__toggle-arrow ${this.state.opened ? 'clicked' : ''}`}
     284            0 :                                 aria-hidden="true"
     285            0 :                             />
     286            0 :                         </Icon>
     287            0 :                     </button>
     288            0 :                 </div>
     289              : 
     290            0 :                 { this.state.opened &&
     291            0 :                 <HostsSelector>
     292            0 :                     <PageSidebar className={"sidebar-hosts" + (this.state.editing ? " edit-hosts" : "")}>
     293            0 :                         <CockpitNav
     294            0 :                             selector={this.props.selector}
     295            0 :                             groups={groups}
     296            0 :                             item_render={render}
     297            0 :                             sorting={(_a, _b) => 1}
     298            0 :                             filtering={this.filterHosts}
     299            0 :                             current={label}
     300            0 :                             jump={() => console.error("internal error: jump not supported in hosts selector")}
     301            0 :                         />
     302            0 :                         <div className="nav-hosts-actions">
     303            0 :                             {state.machines.list.length > 1 && <Button variant="secondary" onClick={this.onEditHosts}>{this.state.editing ? _("Stop editing hosts") : _("Edit hosts")}</Button>}
     304            0 :                             {add_host_action}
     305            0 :                         </div>
     306            0 :                     </PageSidebar>
     307            0 :                 </HostsSelector>
     308              :                 }
     309            0 :             </div>
     310              :         );
     311            0 :     }
     312          123 : }
        

Generated by: LCOV version 2.0-1