LCOV - code coverage report
Current view: top level - pkg/systemd - terminal.jsx Coverage Total Hit
Test: cockpit Lines: 89.5 % 237 212
Test Date: 2026-07-13 10:00:01

            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 : }());
        

Generated by: LCOV version 2.0-1