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