* Copyright (C) 2024 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React from 'react';
import ReactDOM from "react-dom";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import {
CaretDownIcon,
CaretUpIcon,
EditIcon,
MinusIcon,
} from '@patternfly/react-icons';
import { PageSidebar } from "@patternfly/react-core/dist/esm/components/Page";
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
import 'polyfills';
import { CockpitNav, CockpitNavItem } from "./nav.jsx";
import { encode_location } from "./util.jsx";
import { split_connection_string, Machine } from "./machines/machines";
import { add_host, edit_host, connect_host, HostModalState } from "./hosts_dialog.jsx";
import { ShellState } from "./state";
const _ = cockpit.gettext;
interface HostsSelectorProps {
children: React.ReactNode;
}
class HostsSelector extends React.Component {
el: HTMLDivElement;
props: HostsSelectorProps;
constructor(props: HostsSelectorProps) {
super(props);
this.props = props;
this.el = document.createElement("div");
this.el.className = "view-hosts";
}
componentDidMount() {
const hosts_sel = document.getElementById("nav-hosts");
hosts_sel?.appendChild(this.el);
}
componentWillUnmount() {
const hosts_sel = document.getElementById("nav-hosts");
hosts_sel?.removeChild(this.el);
}
render() {
const { children } = this.props;
return ReactDOM.createPortal(children, this.el);
}
}
const HostLine = ({
host,
user
} : {
host: React.ReactNode,
user: React.ReactNode
}) => {
return (
<>
<span id="current-username" className="username">{user}</span>
{user && <span className="at">@</span>}
<span className="hostname">{host}</span>
</>
);
};
export const CockpitCurrentHost = ({
current_user,
machine
} : {
current_user: string,
machine: Machine
}) => {
return (
<div className="ct-switcher ct-switcher-localonly pf-m-dark">
<HostLine user={machine.user || current_user || ""} host={machine.label || ""} />
</div>
);
};
interface CockpitHostsProps {
selector: string;
host_modal_state: ReturnType<typeof HostModalState>;
state: ShellState;
}
interface CockpitHostsState {
opened: boolean;
editing: boolean;
current_user: string;
current_key: string | undefined;
}
interface HostItem extends Machine {
keyword?: string;
}
export class CockpitHosts extends React.Component {
props: CockpitHostsProps;
state: CockpitHostsState;
constructor(props : CockpitHostsProps) {
super(props);
this.props = props;
this.state = {
opened: false,
editing: false,
current_user: "",
current_key: props.state.current_machine?.key,
};
this.toggleMenu = this.toggleMenu.bind(this);
this.filterHosts = this.filterHosts.bind(this);
this.onAddNewHost = this.onAddNewHost.bind(this);
this.onEditHosts = this.onEditHosts.bind(this);
this.onHostEdit = this.onHostEdit.bind(this);
this.onRemove = this.onRemove.bind(this);
}
componentDidMount() {
cockpit.user().then(user => {
this.setState({ current_user: user.name || "" });
}).catch(exc => console.log(exc));
}
static getDerivedStateFromProps(nextProps: CockpitHostsProps, prevState: CockpitHostsState) {
if (nextProps.state.current_machine?.key !== prevState.current_key) {
document.getElementById(nextProps.selector)?.classList.toggle("interact", false);
return {
current_key: nextProps.state.current_machine?.key,
opened: false,
editing: false,
};
}
return null;
}
toggleMenu() {
document.getElementById(this.props.selector)?.classList.toggle("interact", !this.state.opened);
this.setState((s: CockpitHostsState) => {
return (
{
opened: !s.opened,
editing: false,
}
);
});
}
async onAddNewHost() {
await add_host(this.props.host_modal_state, this.props.state);
}
async onHostEdit(machine: Machine) {
await edit_host(this.props.host_modal_state, this.props.state, machine);
}
async onHostSwitch(machine: Machine) {
const { state, host_modal_state } = this.props;
const connection_string = await connect_host(host_modal_state, state, machine);
if (connection_string) {
const parts = split_connection_string(connection_string);
state.jump({ host: parts.address });
}
}
onEditHosts() {
this.setState((s: CockpitHostsState) => { return { editing: !s.editing } });
}
onRemove(event: React.MouseEvent, machine: Machine) {
const { state } = this.props;
const { current_machine } = state;
event.preventDefault();
if (current_machine === machine) {
state.jump({ host: "localhost" });
}
if (state.machines.list.length <= 2)
this.setState({ editing: false });
state.machines.change(machine.key, { visible: false });
}
filterHosts(host: Machine, term: string): HostItem | null {
if (!term)
return host;
const new_host: HostItem = Object.assign({}, host);
term = term.toLowerCase();
if (host.label.toLowerCase().indexOf(term) > -1)
new_host.keyword = host.label.toLowerCase();
const user = host.user || this.state.current_user;
if (user.toLowerCase().indexOf(term) > -1)
new_host.keyword = user.toLowerCase() + " @";
if (new_host.keyword)
return new_host;
return null;
}
render() {
const { state } = this.props;
const { current_machine } = state;
const editing = this.state.editing;
const groups = [{
name: _("Hosts"),
items: state.machines.list,
}];
const render = (m: HostItem, term: string) => <CockpitNavItem
term={term}
keyword={m.keyword || ""}
href={encode_location({ host: m.address })}
active={m === current_machine}
key={m.key}
name={m.label}
header={(m.user ? m.user : this.state.current_user) + " @"}
status={m.state === "failed" ? { type: "error", title: _("Connection error") } : null}
className={m.state || ""}
onClick={() => this.onHostSwitch(m)}
actions={
editing && <>
<Tooltip content={_("Edit")} position="right">
<Button icon={<EditIcon />} isDisabled={m.address === "localhost"} className="nav-action" hidden={!editing} onClick={_e => this.onHostEdit(m)} key={m.label + "edit"} variant="secondary" />
</Tooltip>
<Tooltip content={_("Remove")} position="right">
<Button icon={<MinusIcon />} isDisabled={m.address === "localhost"} onClick={e => this.onRemove(e, m)} className="nav-action" hidden={!editing} key={m.label + "remove"} variant="danger" />
</Tooltip>
</>
}
/>;
const label = current_machine?.label || "";
const user = current_machine?.user || this.state.current_user;
const add_host_action = <Button variant="secondary" onClick={this.onAddNewHost}>{_("Add new host")}</Button>;
return (
<div className="ct-switcher">
<div className="pf-v6-c-select pf-m-dark">
<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">
<span className="pf-v6-c-button__text desktop_v">
<span className="pf-v6-c-select__toggle-wrapper">
<span className="pf-v6-c-select__toggle-text">
<HostLine user={user} host={label} />
</span>
</span>
</span>
<Icon size="xl" className="mobile_v">
<CaretUpIcon
className={`pf-v6-c-select__toggle-arrow ${this.state.opened ? 'clicked' : ''}`}
aria-hidden="true"
/>
</Icon>
<span className="pf-v6-c-select__toggle-wrapper mobile_v">
{_("Host")}
</span>
<Icon size="xl" className="desktop_v">
<CaretDownIcon
className={`pf-v6-c-select__toggle-arrow ${this.state.opened ? 'clicked' : ''}`}
aria-hidden="true"
/>
</Icon>
</button>
</div>
{ this.state.opened &&
<HostsSelector>
<PageSidebar className={"sidebar-hosts" + (this.state.editing ? " edit-hosts" : "")}>
<CockpitNav
selector={this.props.selector}
groups={groups}
item_render={render}
sorting={(_a, _b) => 1}
filtering={this.filterHosts}
current={label}
jump={() => console.error("internal error: jump not supported in hosts selector")}
/>
<div className="nav-hosts-actions">
{state.machines.list.length > 1 && <Button variant="secondary" onClick={this.onEditHosts}>{this.state.editing ? _("Stop editing hosts") : _("Edit hosts")}</Button>}
{add_host_action}
</div>
</PageSidebar>
</HostsSelector>
}
</div>
);
}
}