/*
* This file is part of Cockpit.
*
* Copyright (C) 2019 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see .
*/
import React from 'react';
import PropTypes from 'prop-types';
import cockpit from 'cockpit';
import { Terminal } from "xterm";
import { CanvasAddon } from 'xterm-addon-canvas';
import { ErrorNotification } from './Notification.jsx';
import * as client from './client.js';
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
import "./ContainerTerminal.css";
const _ = cockpit.gettext;
const decoder = cockpit.utf8_decoder();
const encoder = cockpit.utf8_encoder();
function sequence_find(seq, find) {
let f;
const fl = find.length;
let s;
const sl = (seq.length - fl) + 1;
for (s = 0; s < sl; s++) {
for (f = 0; f < fl; f++) {
if (seq[s + f] !== find[f])
break;
}
if (f == fl)
return s;
}
return -1;
}
class ContainerTerminal extends React.Component {
constructor(props) {
super(props);
this.onChannelClose = this.onChannelClose.bind(this);
this.onChannelMessage = this.onChannelMessage.bind(this);
this.disconnectChannel = this.disconnectChannel.bind(this);
this.connectChannel = this.connectChannel.bind(this);
this.resize = this.resize.bind(this);
this.connectToTty = this.connectToTty.bind(this);
this.execAndConnect = this.execAndConnect.bind(this);
this.setUpBuffer = this.setUpBuffer.bind(this);
this.terminalRef = React.createRef();
this.term = new Terminal({
cols: 80,
rows: 24,
screenKeys: true,
cursorBlink: true,
fontSize: 12,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
screenReaderMode: true
});
this.state = {
container: props.containerId,
sessionId: props.containerId,
channel: null,
buffer: null,
opened: false,
errorMessage: "",
};
}
componentDidMount() {
this.connectChannel();
}
componentDidUpdate(prevProps, prevState) {
// Connect channel when there is none and either container started or tty was resolved
if (!this.state.channel && (
(this.props.containerStatus === "running" && prevProps.containerStatus !== "running") ||
(this.props.tty !== undefined && prevProps.tty === undefined)))
this.connectChannel();
if (prevProps.width !== this.props.width) {
this.resize(this.props.width);
}
}
resize(width) {
// 24 PF padding * 4
// 3 line border
// 21 inner padding of xterm.js
// xterm.js scrollbar 20
const padding = 24 * 4 + 3 + 21 + 20;
const realWidth = this.term._core._renderService.dimensions.css.cell.width;
const cols = Math.floor((width - padding) / realWidth);
this.term.resize(cols, 24);
client.resizeContainersTTY(this.props.system, this.state.sessionId, this.props.tty, cols, 24)
.catch(e => this.setState({ errorMessage: e.message }));
}
connectChannel() {
if (this.state.channel)
return;
if (this.props.containerStatus !== "running")
return;
if (this.props.tty === undefined)
return;
if (this.props.tty)
this.connectToTty();
else
this.execAndConnect();
}
setUpBuffer(channel) {
const buffer = channel.buffer();
// Parse the full HTTP response
buffer.callback = (data) => {
let ret = 0;
let pos = 0;
let headers = "";
// Double line break separates header from body
pos = sequence_find(data, [13, 10, 13, 10]);
if (pos == -1)
return ret;
if (data.subarray)
headers = cockpit.utf8_decoder().decode(data.subarray(0, pos));
else
headers = cockpit.utf8_decoder().decode(data.slice(0, pos));
const parts = headers.split("\r\n", 1)[0].split(" ");
// Check if we got `101` as we expect `HTTP/1.1 101 UPGRADED`
if (parts[1] != "101") {
console.log(parts.slice(2).join(" "));
buffer.callback = null;
return;
} else if (data.subarray) {
data = data.subarray(pos + 4);
ret += pos + 4;
} else {
data = data.slice(pos + 4);
ret += pos + 4;
}
// Set up callback for new incoming messages and if the first response
// contained any body, pass it into the callback
buffer.callback = this.onChannelMessage;
const consumed = this.onChannelMessage(data);
return ret + consumed;
};
channel.addEventListener('close', this.onChannelClose);
// Show the terminal. Once it was shown, do not show it again but reuse the previous one
if (!this.state.opened) {
this.term.open(this.terminalRef.current);
this.term.loadAddon(new CanvasAddon());
this.setState({ opened: true });
this.term.onData((data) => {
if (this.state.channel)
this.state.channel.send(encoder.encode(data));
});
}
channel.send(String.fromCharCode(12)); // Send SIGWINCH to show prompt on attaching
return buffer;
}
execAndConnect() {
client.execContainer(this.props.system, this.state.container)
.then(r => {
const channel = cockpit.channel({
payload: "stream",
unix: client.getAddress(this.props.system),
superuser: this.props.system ? "require" : null,
binary: true
});
const body = JSON.stringify({ Detach: false, Tty: false });
channel.send("POST " + client.VERSION + "libpod/exec/" + encodeURIComponent(r.Id) +
"/start HTTP/1.0\r\n" +
"Upgrade: WebSocket\r\nConnection: Upgrade\r\nContent-Length: " + body.length + "\r\n\r\n" + body);
const buffer = this.setUpBuffer(channel);
this.setState({ channel, errorMessage: "", buffer, sessionId: r.Id }, () => this.resize(this.props.width));
})
.catch(e => this.setState({ errorMessage: e.message }));
}
connectToTty() {
const channel = cockpit.channel({
payload: "stream",
unix: client.getAddress(this.props.system),
superuser: this.props.system ? "require" : null,
binary: true
});
channel.send("POST " + client.VERSION + "libpod/containers/" + encodeURIComponent(this.state.container) +
"/attach?&stdin=true&stdout=true&stderr=true HTTP/1.0\r\n" +
"Upgrade: WebSocket\r\nConnection: Upgrade\r\nContent-Length: 0\r\n\r\n");
const buffer = this.setUpBuffer(channel);
this.setState({ channel, errorMessage: "", buffer });
this.resize(this.props.width);
}
componentWillUnmount() {
this.disconnectChannel();
if (this.state.channel)
this.state.channel.close();
this.term.dispose();
}
onChannelMessage(buffer) {
if (buffer)
this.term.write(decoder.decode(buffer));
return buffer.length;
}
onChannelClose(event, options) {
this.term.write('\x1b[31m disconnected \x1b[m\r\n');
this.disconnectChannel();
this.setState({ channel: null });
this.term.cursorHidden = true;
}
disconnectChannel() {
if (this.state.buffer)
this.state.buffer.callback = null; // eslint-disable-line react/no-direct-mutation-state
if (this.state.channel) {
this.state.channel.removeEventListener('close', this.onChannelClose);
}
}
render() {
let element =
;
if (this.props.containerStatus !== "running" && !this.state.opened)
element = ;
return (
<>
{this.state.errorMessage && this.setState({ errorMessage: "" })} />}
{element}
>
);
}
}
ContainerTerminal.propTypes = {
containerId: PropTypes.string.isRequired,
containerStatus: PropTypes.string.isRequired,
width: PropTypes.number.isRequired,
system: PropTypes.bool.isRequired,
tty: PropTypes.bool,
};
export default ContainerTerminal;