Line data Source code
1 : // SPDX-License-Identifier: LGPL-2.1-or-later
2 11 : import cockpit from "cockpit";
3 : import 'cockpit-dark-theme'; // once per page
4 : import '../lib/patternfly/patternfly-6-cockpit.scss';
5 :
6 11 : import React from "react";
7 11 : import { createRoot } from "react-dom/client";
8 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
9 : import { NumberInput } from "@patternfly/react-core/dist/esm/components/NumberInput/index.js";
10 : import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
11 : import { Alert, AlertActionCloseButton, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
12 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
13 : import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
14 : import { fsinfo } from "cockpit/fsinfo";
15 :
16 : import "./terminal.scss";
17 :
18 : import { Terminal } from "cockpit-components-terminal.jsx";
19 : import { EmptyStatePanel } from "cockpit-components-empty-state";
20 :
21 11 : const _ = cockpit.gettext;
22 :
23 11 : (function() {
24 11 : cockpit.translate();
25 :
26 : /*
27 : * A terminal component for the cockpit user.
28 : *
29 : * Uses the Terminal component from base1 internally, but adds a header
30 : * with title and Reset button.
31 : *
32 : * Spawns the user's shell in the user's home directory.
33 : */
34 11 : class UserTerminal extends React.Component {
35 11 : createChannel(user, dir) {
36 11 : const ch = cockpit.channel({
37 11 : payload: "stream",
38 2 : spawn: [user.shell || "/bin/bash"],
39 11 : environ: [
40 11 : "TERM=xterm-256color",
41 11 : ],
42 2 : directory: dir || user.home || "/",
43 11 : pty: true,
44 11 : binary: true,
45 11 : });
46 11 : ch.addEventListener("ready", (_, msg) => this.setState({ pid: msg.pid }));
47 2 : ch.addEventListener("close", () => this.setState({ pid: null }));
48 11 : return ch;
49 11 : }
50 :
51 11 : constructor(props) {
52 11 : super(props);
53 :
54 11 : const theme = localStorage.getItem('terminal:theme');
55 11 : const size = localStorage.getItem('terminal:font-size');
56 :
57 : // Check if WebGL2 is available
58 : // Only checking if WebGL2RenderingContext is defined is not sufficient, in Firefox tests it is defined
59 : // as WebGL is enabled but it is not available in headless mode.
60 11 : this.webglAvailable = !!document.createElement("canvas").getContext("webgl2");
61 :
62 11 : this.state = {
63 11 : title: 'Terminal',
64 11 : theme: theme || "black-theme",
65 11 : size: parseInt(size) || 16,
66 11 : changePathBusy: false,
67 11 : pathError: null,
68 11 : pid: null,
69 11 : user: null,
70 11 : };
71 11 : this.onTitleChanged = this.onTitleChanged.bind(this);
72 11 : this.onResetClick = this.onResetClick.bind(this);
73 11 : this.onThemeChanged = this.onThemeChanged.bind(this);
74 11 : this.onPlus = this.onPlus.bind(this);
75 11 : this.onMinus = this.onMinus.bind(this);
76 11 : this.forceChangeDirectory = this.forceChangeDirectory.bind(this);
77 11 : this.onNavigate = this.onNavigate.bind(this);
78 11 : this.dismiss = this.dismiss.bind(this);
79 :
80 11 : this.terminalRef = React.createRef();
81 11 : this.resetButtonRef = React.createRef();
82 :
83 11 : this.minSize = 6;
84 11 : this.maxSize = 40;
85 11 : }
86 :
87 11 : async componentDidMount() {
88 11 : cockpit.addEventListener("locationchanged", this.onNavigate);
89 :
90 11 : let dir;
91 2 : if (cockpit.location.options.path) {
92 2 : const validPath = await this.readyPath();
93 2 : if (validPath) {
94 2 : dir = cockpit.location.options.path;
95 2 : }
96 2 : }
97 11 : const user = await cockpit.user();
98 11 : this.setState({ user, channel: this.createChannel(user, dir) });
99 11 : }
100 :
101 0 : componentWillUnmount() {
102 0 : cockpit.removeEventListener("locationchanged", this.onNavigate);
103 0 : }
104 :
105 9 : onTitleChanged(title) {
106 9 : this.setState({ title });
107 9 : }
108 :
109 1 : forceChangeDirectory() {
110 1 : this.setState(prevState => ({
111 1 : channel: this.createChannel(prevState.user, cockpit.location.options.path),
112 1 : changePathBusy: false,
113 1 : }));
114 1 : }
115 :
116 1 : dismiss() {
117 1 : this.setState({
118 1 : pathError: null,
119 1 : changePathBusy: false,
120 1 : });
121 1 : cockpit.location.replace("");
122 1 : }
123 :
124 1 : async onNavigate() {
125 : // Clear old path errors
126 1 : this.setState({
127 1 : pathError: null,
128 1 : changePathBusy: false,
129 1 : });
130 :
131 : // If there's no path to change to, then we're done here
132 1 : if (!cockpit.location.options.path) {
133 1 : return;
134 1 : }
135 1 : const changeNow = await this.readyPath();
136 1 : if (changeNow) {
137 1 : const user = await cockpit.user();
138 1 : this.setState({ channel: this.createChannel(user, cockpit.location.options.path) });
139 1 : }
140 1 : }
141 :
142 1 : async readyPath() {
143 : // Check if path we're changing to exists
144 1 : try {
145 1 : const info = await fsinfo(String(cockpit.location.options.path), ['type']);
146 1 : if (info.type !== "dir") {
147 1 : this.setState({ pathError: cockpit.format(_("$0 is not a directory"), cockpit.location.options.path) });
148 1 : return false;
149 1 : }
150 1 : } catch (err) {
151 1 : this.setState({ pathError: cockpit.format(_("$0 does not exist"), cockpit.location.options.path) });
152 1 : return false;
153 1 : }
154 :
155 1 : if (this.state.pid !== null) {
156 : // Check if current shell has a process running in it, ie it's busy
157 1 : const command = "grep -qr '^PPid:[[:space:]]*" + this.state.pid + "$' /proc/*/status";
158 1 : try {
159 1 : await cockpit.script(command, [], { err: "message" });
160 1 : this.setState({ changePathBusy: true });
161 1 : return false;
162 1 : } catch {
163 1 : return true;
164 1 : }
165 1 : }
166 0 : return true;
167 1 : }
168 :
169 0 : onPlus() {
170 0 : this.setState((state, _) => {
171 0 : localStorage.setItem('terminal:font-size', state.size + 1);
172 0 : return { size: state.size + 1 };
173 0 : });
174 0 : }
175 :
176 1 : onMinus() {
177 1 : this.setState((state, _) => {
178 1 : localStorage.setItem('terminal:font-size', state.size - 1);
179 1 : return { size: state.size - 1 };
180 1 : });
181 1 : }
182 :
183 0 : onThemeChanged(_, value) {
184 0 : this.setState({ theme: value });
185 0 : localStorage.setItem('terminal:theme', value);
186 0 : }
187 :
188 0 : onResetClick(event) {
189 0 : if (event.button !== 0)
190 0 : return;
191 :
192 0 : if (!this.state.channel.valid && this.state.user)
193 0 : this.setState(prevState => ({ channel: this.createChannel(prevState.user) }));
194 : else
195 0 : this.terminalRef.current.reset();
196 :
197 : // don't focus the button, but keep it on the terminal
198 0 : this.resetButtonRef.current.blur();
199 0 : this.terminalRef.current.focus();
200 0 : }
201 :
202 11 : render() {
203 11 : const terminal = this.state.channel
204 11 : ? <Terminal ref={this.terminalRef}
205 11 : channel={this.state.channel}
206 11 : theme={this.state.theme}
207 11 : fontSize={this.state.size}
208 11 : parentId="cockpit-terminal"
209 11 : onTitleChanged={this.onTitleChanged} />
210 11 : : <span>Loading...</span>;
211 :
212 11 : return (
213 11 : <Page className="pf-m-no-sidebar">
214 11 : <PageSection hasShadowBottom>
215 11 : <div className="terminal-group">
216 11 : <code className="terminal-title">{this.state.title}</code>
217 11 : <Toolbar id="toolbar">
218 11 : <ToolbarContent>
219 11 : <ToolbarGroup>
220 11 : <ToolbarItem variant="label" id="size-select">
221 11 : {_("Font size")}
222 11 : </ToolbarItem>
223 11 : <ToolbarItem>
224 11 : <NumberInput
225 11 : className="font-size"
226 11 : value={this.state.size}
227 11 : min={this.minSize}
228 11 : max={this.maxSize}
229 11 : onMinus={this.onMinus}
230 11 : onPlus={this.onPlus}
231 11 : inputAriaLabel={_("Font size")}
232 11 : minusBtnAriaLabel={_("Decrease by one")}
233 11 : plusBtnAriaLabel={_("Increase by one")}
234 11 : widthChars={2}
235 11 : />
236 11 : </ToolbarItem>
237 11 : </ToolbarGroup>
238 11 : <ToolbarGroup>
239 11 : <ToolbarItem variant="label" id="theme-select">
240 11 : {_("Appearance")}
241 11 : </ToolbarItem>
242 11 : <ToolbarItem>
243 11 : <FormSelect onChange={this.onThemeChanged}
244 11 : aria-labelledby="theme-select"
245 11 : value={this.state.theme}>
246 11 : <FormSelectOption value='black-theme' label={_("Black")} />
247 11 : <FormSelectOption value='dark-theme' label={_("Dark")} />
248 11 : <FormSelectOption value='light-theme' label={_("Light")} />
249 11 : <FormSelectOption value='white-theme' label={_("White")} />
250 11 : </FormSelect>
251 11 : </ToolbarItem>
252 11 : </ToolbarGroup>
253 11 : <ToolbarItem>
254 11 : <button ref={this.resetButtonRef}
255 11 : disabled={!this.webglAvailable}
256 11 : className="pf-v6-c-button pf-m-secondary terminal-reset"
257 11 : onClick={this.onResetClick}>{_("Reset")}</button>
258 11 : </ToolbarItem>
259 11 : </ToolbarContent>
260 11 : </Toolbar>
261 11 : </div>
262 11 : </PageSection>
263 11 : <PageSection className="console-ct-container" isFilled hasBodyWrapper={false} padding={{ default: 'noPadding' }}>
264 11 : <div className="ct-terminal-dir-alert">
265 3 : {this.state.pathError && <Alert isInline
266 3 : title={_("Unable to open directory")}
267 3 : variant="warning"
268 3 : actionClose={<AlertActionCloseButton onClose={this.dismiss} />}>
269 3 : <p>{_(this.state.pathError)}</p>
270 3 : </Alert>
271 : }
272 :
273 3 : {this.state.changePathBusy && <Alert isInline
274 3 : title={_("Running process prevents directory change")}
275 3 : variant="danger"
276 0 : actionClose={<AlertActionCloseButton onClose={() =>
277 0 : this.setState({ changePathBusy: false })} />}
278 3 : actionLinks={
279 3 : <>
280 3 : <Button variant="danger" size="sm" onClick={this.forceChangeDirectory}>{_("Change directory")}</Button>
281 3 : <AlertActionLink onClick={this.dismiss}>{_("Cancel")}</AlertActionLink>
282 3 : </>
283 : }>
284 3 : {_("Changing the directory will forcefully stop the currently running process. The process can also be stopped manually in the terminal before continuing.")}
285 3 : </Alert>
286 : }
287 11 : </div>
288 11 : {this.webglAvailable
289 11 : ? (<div className={"terminal-body " + this.state.theme} id="cockpit-terminal">
290 11 : {terminal}
291 11 : </div>)
292 2 : : <EmptyStatePanel title={_("Terminal not available")} paragraph={_("This browser does not support WebGL2.")} />}
293 11 : </PageSection>
294 11 : </Page>
295 : );
296 11 : }
297 11 : }
298 11 : UserTerminal.displayName = "UserTerminal";
299 :
300 11 : const root = createRoot(document.getElementById('terminal'));
301 11 : root.render(<UserTerminal />);
302 :
303 : /* And show the body */
304 11 : document.body.removeAttribute("hidden");
305 11 : }());
|