LCOV - code coverage report
Current view: top level - src - ContainerTerminal.jsx Coverage Total Hit
Test: cockpit-podman Lines: 91.6 % 202 185
Test Date: 2025-05-21 17:35:03

            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;
        

Generated by: LCOV version 2.0-1