Line data Source code
1 : /*
2 : * This file is part of Cockpit.
3 : *
4 : * Copyright (C) 2019 Red Hat, Inc.
5 : *
6 : * Cockpit is free software; you can redistribute it and/or modify it
7 : * under the terms of the GNU Lesser General Public License as published by
8 : * the Free Software Foundation; either version 2.1 of the License, or
9 : * (at your option) any later version.
10 : *
11 : * Cockpit is distributed in the hope that it will be useful, but
12 : * WITHOUT ANY WARRANTY; without even the implied warranty of
13 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 : * Lesser General Public License for more details.
15 : *
16 : * You should have received a copy of the GNU Lesser General Public License
17 : * along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
18 : */
19 :
20 43 : import React from 'react';
21 :
22 43 : import { CanvasAddon } from '@xterm/addon-canvas';
23 43 : import { Terminal } from "@xterm/xterm";
24 43 : import PropTypes from 'prop-types';
25 :
26 : import cockpit from 'cockpit';
27 : import { EmptyStatePanel } from "cockpit-components-empty-state.tsx";
28 :
29 : import { ErrorNotification } from './Notification.jsx';
30 : import * as client from './client.js';
31 : import rest from './rest.js';
32 :
33 : import "./ContainerTerminal.css";
34 :
35 43 : const _ = cockpit.gettext;
36 43 : const decoder = new TextDecoder();
37 43 : const encoder = new TextEncoder();
38 :
39 4 : function sequence_find(seq, find) {
40 4 : let f;
41 4 : const fl = find.length;
42 4 : let s;
43 4 : const sl = (seq.length - fl) + 1;
44 4 : for (s = 0; s < sl; s++) {
45 4 : for (f = 0; f < fl; f++) {
46 4 : if (seq[s + f] !== find[f])
47 4 : break;
48 4 : }
49 4 : if (f == fl)
50 4 : return s;
51 4 : }
52 :
53 0 : return -1;
54 4 : }
55 :
56 43 : class ContainerTerminal extends React.Component {
57 4 : constructor(props) {
58 4 : super(props);
59 :
60 4 : this.onChannelClose = this.onChannelClose.bind(this);
61 4 : this.onChannelMessage = this.onChannelMessage.bind(this);
62 4 : this.disconnectChannel = this.disconnectChannel.bind(this);
63 4 : this.connectChannel = this.connectChannel.bind(this);
64 4 : this.resize = this.resize.bind(this);
65 4 : this.connectToTty = this.connectToTty.bind(this);
66 4 : this.execAndConnect = this.execAndConnect.bind(this);
67 4 : this.setUpBuffer = this.setUpBuffer.bind(this);
68 :
69 4 : this.terminalRef = React.createRef();
70 :
71 4 : this.term = new Terminal({
72 4 : cols: 80,
73 4 : rows: 24,
74 4 : screenKeys: true,
75 4 : cursorBlink: true,
76 4 : fontSize: 12,
77 4 : fontFamily: 'Menlo, Monaco, Consolas, monospace',
78 4 : screenReaderMode: true
79 4 : });
80 :
81 4 : this.state = {
82 4 : container: props.containerId,
83 4 : sessionId: props.containerId,
84 4 : channel: null,
85 4 : buffer: null,
86 4 : opened: false,
87 4 : errorMessage: "",
88 4 : };
89 4 : }
90 :
91 4 : componentDidMount() {
92 4 : this.connectChannel();
93 4 : }
94 :
95 4 : componentDidUpdate(prevProps) {
96 : // Connect channel when there is none and either container started or tty was resolved
97 4 : if (!this.state.channel && (
98 2 : (this.props.containerStatus === "running" && prevProps.containerStatus !== "running") ||
99 2 : (this.props.tty !== undefined && prevProps.tty === undefined)))
100 2 : this.connectChannel();
101 0 : if (prevProps.width !== this.props.width) {
102 0 : this.resize(this.props.width);
103 0 : }
104 4 : }
105 :
106 4 : resize(width) {
107 4 : if (!this.term?._core?._renderService?.dimensions)
108 4 : return;
109 : // 24 PF padding * 4
110 : // 3 line border
111 : // 21 inner padding of xterm.js
112 : // xterm.js scrollbar 20
113 4 : const padding = 24 * 4 + 3 + 21 + 20;
114 : // missing API: https://github.com/xtermjs/xterm.js/issues/702
115 4 : const realWidth = this.term._core._renderService.dimensions.css.cell.width;
116 4 : const cols = Math.floor((width - padding) / realWidth);
117 4 : this.term.resize(cols, 24);
118 4 : client.resizeContainersTTY(this.props.con, this.state.sessionId, this.props.tty, cols, 24)
119 1 : .catch(e => this.setState({ errorMessage: e.message }));
120 4 : }
121 :
122 4 : connectChannel() {
123 4 : if (this.state.channel)
124 4 : return;
125 :
126 4 : if (this.props.containerStatus !== "running")
127 4 : return;
128 :
129 4 : if (this.props.tty === undefined)
130 4 : return;
131 :
132 4 : if (this.props.tty)
133 2 : this.connectToTty();
134 : else
135 4 : this.execAndConnect();
136 4 : }
137 :
138 4 : setUpBuffer(channel) {
139 4 : const buffer = channel.buffer();
140 :
141 : // Parse the full HTTP response
142 4 : buffer.callback = (data) => {
143 4 : let ret = 0;
144 4 : let pos = 0;
145 :
146 : // Double line break separates header from body
147 4 : pos = sequence_find(data, [13, 10, 13, 10]);
148 4 : if (pos == -1)
149 0 : return ret;
150 :
151 4 : const headers = new TextDecoder().decode(
152 0 : data.subarray ? data.subarray(0, pos) : data.slice(0, pos));
153 :
154 4 : const parts = headers.split("\r\n", 1)[0].split(" ");
155 : // Check if we got `101` as we expect `HTTP/1.1 101 UPGRADED`
156 0 : if (parts[1] != "101") {
157 0 : console.log(parts.slice(2).join(" "));
158 0 : buffer.callback = null;
159 0 : return;
160 0 : } else if (data.subarray) {
161 4 : data = data.subarray(pos + 4);
162 4 : ret += pos + 4;
163 0 : } else {
164 0 : data = data.slice(pos + 4);
165 0 : ret += pos + 4;
166 0 : }
167 : // Set up callback for new incoming messages and if the first response
168 : // contained any body, pass it into the callback
169 4 : buffer.callback = this.onChannelMessage;
170 4 : const consumed = this.onChannelMessage(data);
171 4 : return ret + consumed;
172 4 : };
173 :
174 4 : channel.addEventListener('close', this.onChannelClose);
175 :
176 : // Show the terminal. Once it was shown, do not show it again but reuse the previous one
177 4 : if (!this.state.opened) {
178 4 : this.term.open(this.terminalRef.current);
179 4 : this.term.loadAddon(new CanvasAddon());
180 4 : this.setState({ opened: true });
181 :
182 4 : this.term.onData((data) => {
183 4 : if (this.state.channel)
184 4 : this.state.channel.send(encoder.encode(data));
185 4 : });
186 4 : }
187 4 : channel.send(String.fromCharCode(12)); // Send SIGWINCH to show prompt on attaching
188 :
189 4 : return buffer;
190 4 : }
191 :
192 4 : execAndConnect() {
193 4 : client.execContainer(this.props.con, this.state.container)
194 4 : .then(r => {
195 4 : const address = rest.getAddress(this.props.uid);
196 4 : const channel = cockpit.channel({
197 4 : payload: "stream",
198 4 : unix: address.path,
199 4 : superuser: address.superuser,
200 4 : binary: true
201 4 : });
202 :
203 4 : const body = JSON.stringify({ Detach: false, Tty: false });
204 4 : channel.send("POST " + client.VERSION + "libpod/exec/" + encodeURIComponent(r.Id) +
205 4 : "/start HTTP/1.0\r\n" +
206 4 : "Upgrade: WebSocket\r\nConnection: Upgrade\r\nContent-Length: " + body.length + "\r\n\r\n" + body);
207 :
208 4 : const buffer = this.setUpBuffer(channel);
209 4 : this.setState({ channel, errorMessage: "", buffer, sessionId: r.Id }, () => this.resize(this.props.width));
210 4 : })
211 0 : .catch(e => this.setState({ errorMessage: e.message }));
212 4 : }
213 :
214 2 : connectToTty() {
215 2 : const address = rest.getAddress(this.props.uid);
216 2 : const channel = cockpit.channel({
217 2 : payload: "stream",
218 2 : unix: address.path,
219 2 : superuser: address.superuser,
220 2 : binary: true
221 2 : });
222 :
223 2 : channel.send("POST " + client.VERSION + "libpod/containers/" + encodeURIComponent(this.state.container) +
224 2 : "/attach?&stdin=true&stdout=true&stderr=true HTTP/1.0\r\n" +
225 2 : "Upgrade: WebSocket\r\nConnection: Upgrade\r\nContent-Length: 0\r\n\r\n");
226 :
227 2 : const buffer = this.setUpBuffer(channel);
228 2 : this.setState({ channel, errorMessage: "", buffer });
229 2 : this.resize(this.props.width);
230 2 : }
231 :
232 2 : componentWillUnmount() {
233 2 : this.disconnectChannel();
234 2 : if (this.state.channel)
235 2 : this.state.channel.close();
236 2 : this.term.dispose();
237 2 : }
238 :
239 4 : onChannelMessage(buffer) {
240 4 : if (buffer)
241 4 : this.term.write(decoder.decode(buffer));
242 4 : return buffer.length;
243 4 : }
244 :
245 2 : onChannelClose() {
246 2 : this.term.write('\x1b[31m disconnected \x1b[m\r\n');
247 2 : this.disconnectChannel();
248 2 : this.setState({ channel: null });
249 2 : this.term.cursorHidden = true;
250 2 : }
251 :
252 4 : disconnectChannel() {
253 4 : if (this.state.buffer)
254 4 : this.state.buffer.callback = null; // eslint-disable-line react/no-direct-mutation-state
255 4 : if (this.state.channel) {
256 4 : this.state.channel.removeEventListener('close', this.onChannelClose);
257 4 : }
258 4 : }
259 :
260 4 : render() {
261 4 : let element = <div className="container-terminal" ref={this.terminalRef} />;
262 :
263 2 : if (this.props.containerStatus !== "running" && !this.state.opened)
264 2 : element = <EmptyStatePanel title={_("Container is not running")} />;
265 :
266 4 : return (
267 4 : <>
268 0 : {this.state.errorMessage && <ErrorNotification errorMessage={_("Error occurred while connecting console")} errorDetail={this.state.errorMessage} onDismiss={() => this.setState({ errorMessage: "" })} />}
269 4 : {element}
270 4 : </>
271 : );
272 4 : }
273 43 : }
274 :
275 43 : ContainerTerminal.propTypes = {
276 43 : con: PropTypes.object.isRequired,
277 43 : containerId: PropTypes.string.isRequired,
278 43 : containerStatus: PropTypes.string.isRequired,
279 43 : width: PropTypes.number.isRequired,
280 43 : uid: PropTypes.number,
281 43 : tty: PropTypes.bool,
282 43 : };
283 :
284 43 : export default ContainerTerminal;
|