LCOV - code coverage report
Current view: top level - pkg/shell - hosts.tsx Coverage Total Hit
Test: cockpit Lines: 99.1 % 212 210
Test Date: 2026-06-25 11:17:56

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

Generated by: LCOV version 2.0-1