diff --git a/pkg/systemd/terminal.jsx b/pkg/systemd/terminal.jsx index 03ada383bae5..ff611566b87c 100644 --- a/pkg/systemd/terminal.jsx +++ b/pkg/systemd/terminal.jsx @@ -7,6 +7,9 @@ import { createRoot } from "react-dom/client"; import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js"; import { NumberInput } from "@patternfly/react-core/dist/esm/components/NumberInput/index.js"; import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js"; +import { Alert, AlertActionCloseButton, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import { fsinfo } from "cockpit/fsinfo"; +import { Button } from '@patternfly/react-core'; import "./terminal.scss"; @@ -26,17 +29,20 @@ const _ = cockpit.gettext; * Spawns the user's shell in the user's home directory. */ class UserTerminal extends React.Component { - createChannel(user) { - return cockpit.channel({ + createChannel(user, dir) { + const ch = cockpit.channel({ payload: "stream", spawn: [user.shell || "/bin/bash"], environ: [ "TERM=xterm-256color", ], - directory: user.home || "/", + directory: dir || user.home || "/", pty: true, binary: true, }); + ch.addEventListener("ready", (_, msg) => this.setState({ pid: msg.pid })); + ch.addEventListener("close", () => this.setState({ pid: null })); + return ch; } constructor(props) { @@ -66,12 +72,18 @@ const _ = cockpit.gettext; title: 'Terminal', theme: theme || "black-theme", size: parseInt(size) || 16, + changePathBusy: false, + pathError: null, + pid: null, }; this.onTitleChanged = this.onTitleChanged.bind(this); this.onResetClick = this.onResetClick.bind(this); this.onThemeChanged = this.onThemeChanged.bind(this); this.onPlus = this.onPlus.bind(this); this.onMinus = this.onMinus.bind(this); + this.forceChangeDirectory = this.forceChangeDirectory.bind(this); + this.onNavigate = this.onNavigate.bind(this); + this.dismiss = this.dismiss.bind(this); this.terminalRef = React.createRef(); this.resetButtonRef = React.createRef(); @@ -81,8 +93,21 @@ const _ = cockpit.gettext; } async componentDidMount() { + cockpit.addEventListener("locationchanged", this.onNavigate); + + let dir; + if (cockpit.location.options.path) { + const validPath = await this.readyPath(); + if (validPath) { + dir = cockpit.location.options.path; + } + } const user = await cockpit.user(); - this.setState({ user, channel: this.createChannel(user) }); + this.setState({ user, channel: this.createChannel(user, dir) }); + } + + componentWillUnmount() { + cockpit.removeEventListener("locationchanged", this.onNavigate); } onTitleChanged(title) { @@ -95,6 +120,65 @@ const _ = cockpit.gettext; document.cookie = cookie; } + forceChangeDirectory() { + this.setState(prevState => ({ + channel: this.createChannel(prevState.user, cockpit.location.options.path), + changePathBusy: false, + })); + } + + dismiss() { + this.setState({ + pathError: null, + changePathBusy: false, + }); + cockpit.location.replace(""); + } + + async onNavigate() { + // Clear old path errors + this.setState({ + pathError: null, + changePathBusy: false, + }); + + // If there's no path to change to, then we're done here + if (!cockpit.location.options.path) { + return; + } + const changeNow = await this.readyPath(); + if (changeNow) { + this.setState(prevState => ({ channel: this.createChannel(prevState.user, cockpit.location.options.path) })); + } + } + + async readyPath() { + // Check if path we're changing to exists + try { + const info = await fsinfo(String(cockpit.location.options.path), ['type']); + if (info.type !== "dir") { + this.setState({ pathError: cockpit.format(_("$0 is not a directory"), cockpit.location.options.path) }); + return false; + } + } catch (err) { + this.setState({ pathError: cockpit.format(_("$0 does not exist"), cockpit.location.options.path) }); + return false; + } + + if (this.state.pid !== null) { + // Check if current shell has a process running in it, ie it's busy + const command = "grep -qr '^PPid:[[:space:]]*" + this.state.pid + "$' /proc/*/status"; + try { + await cockpit.script(command, [], { err: "message" }); + this.setState({ changePathBusy: true }); + return false; + } catch { + return true; + } + } + return true; + } + onPlus() { this.setState((state, _) => { localStorage.setItem('terminal:font-size', state.size + 1); @@ -186,6 +270,30 @@ const _ = cockpit.gettext; +
+ {this.state.pathError && }> +

{_(this.state.pathError)}

+
+ } + + {this.state.changePathBusy && + this.setState({ changePathBusy: false })} />} + actionLinks={ + <> + + {_("Cancel")} + + }> + {_("There is still a process running in this terminal. Changing the directory will kill it.")} + + } +
{terminal}
diff --git a/pkg/systemd/terminal.scss b/pkg/systemd/terminal.scss index 0535f1c379ae..0e882dfb40d3 100644 --- a/pkg/systemd/terminal.scss +++ b/pkg/systemd/terminal.scss @@ -5,7 +5,7 @@ .console-ct-container { block-size: 100%; display: grid; - grid-template-rows: auto 1fr; + grid-template-rows: auto auto 1fr; overflow: hidden; } diff --git a/test/verify/check-system-terminal b/test/verify/check-system-terminal index a8ef48f69443..6229ca3cce78 100755 --- a/test/verify/check-system-terminal +++ b/test/verify/check-system-terminal @@ -37,37 +37,40 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ # Remove failed units which will show up in the first terminal line self.machine.execute("systemctl reset-failed") + def wait_line(self, lineno, text): + b = self.browser + try: + b.wait_text(line_sel(lineno), text) + except Exception as e: + print("-----") + for j in range(max(1, lineno - 5), lineno + 5): + print(b.text(line_sel(j))) + print("-----") + raise e + + def clear_terminal(self): + b = self.browser + b.input_text("clear") + b.wait_js_cond("ph_text('.terminal').indexOf('clear') >= 0") + # now wait for clear to take effect + b.key("Enter") + b.wait_js_cond("ph_text('.xterm-accessibility-tree').indexOf('clear') < 0") + @testlib.nondestructive def testBasic(self): b = self.browser m = self.machine self.login_and_go("/system/terminal") - blank_state = ' ' - def wait_line(i, t): - try: - b.wait_text(line_sel(i), t) - except Exception as e: - print("-----") - for j in range(max(1, i - 5), i + 5): - print(b.text(line_sel(j))) - print("-----") - raise e - # wait until first line is not empty n = 1 b.wait_visible(".terminal .xterm-accessibility-tree") function_str = "(function (sel) { return ph_text(sel).trim() != '%s'})" % blank_state b.wait_js_func(function_str, line_sel(n)) - # clear any messages (for example, instructions about sudo) and wait for prompt - b.input_text("clear") - b.wait_js_cond("ph_text('.terminal').indexOf('clear') >= 0") - # now wait for clear to take effect - b.key("Enter") - b.wait_js_cond("ph_text('.xterm-accessibility-tree').indexOf('clear') < 0") + self.clear_terminal() # now we should get a clean prompt b.wait_in_text(line_sel(n), '$') @@ -83,19 +86,19 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ # Run some commands b.input_text("whoami\n") - wait_line(n + 1, "admin") + self.wait_line(n + 1, "admin") - wait_line(n + 2, prompt) + self.wait_line(n + 2, prompt) b.input_text('echo -e "1\\u0041"\n') - wait_line(n + 3, '1A') - wait_line(n + 4, prompt) + self.wait_line(n + 3, '1A') + self.wait_line(n + 4, prompt) # non-UTF8 data m.execute(r"echo -e 'hello\xFF\x01\xFF\x02world' > " + self.vm_tmpdir + "/garbage.txt") b.input_text(f'cat {self.vm_tmpdir}/garbage.txt\n') - wait_line(n + 5, 'helloworld') - wait_line(n + 6, prompt) + self.wait_line(n + 5, 'helloworld') + self.wait_line(n + 6, prompt) # The '@' sign is in the default prompt b.wait_in_text(".terminal-title", '@') @@ -112,14 +115,14 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.click('button:contains("Reset")') # assert that the output from earlier is gone - wait_line(n + 1, blank_state) + self.wait_line(n + 1, blank_state) self.assertNotIn('admin', b.text(line_sel(n + 1))) # Check that when we `exit` we can still reconnect with the 'Reset' button b.input_text("exit\n") b.wait_in_text(".terminal .xterm-accessibility-tree", "disconnected") b.click('button:contains("Reset")') - wait_line(n, prompt) + self.wait_line(n, prompt) b.wait_not_in_text(".terminal .xterm-accessibility-tree", "disconnected") def select_line(sel, width): @@ -135,11 +138,11 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.grant_permissions("clipboard-read", "clipboard-write") # Execute command - wait_line(n, prompt) + self.wait_line(n, prompt) b.input_text('echo "XYZ"\n') echo_result_line = n + 1 - wait_line(echo_result_line, "XYZ") + self.wait_line(echo_result_line, "XYZ") sel = line_sel(echo_result_line) @@ -157,7 +160,7 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.click('.contextMenu li:nth-child(2) button') # Wait for text to show up - wait_line(echo_result_line + 1, prompt + "XYZ") + self.wait_line(echo_result_line + 1, prompt + "XYZ") b.key('Enter') b.wait_in_text(line_sel(echo_result_line + 2), 'XYZ') @@ -165,14 +168,14 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.click('button:contains("Reset")') # assert that the output from earlier is gone - wait_line(n + 1, blank_state) + self.wait_line(n + 1, blank_state) # Execute another command - wait_line(n, prompt) + self.wait_line(n, prompt) b.input_text('echo "foo"\n') echo_result_line = n + 1 - wait_line(echo_result_line, "foo") + self.wait_line(echo_result_line, "foo") sel = line_sel(echo_result_line) # Highlight 40px (3 letters, never wider that ~14px) @@ -183,14 +186,14 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.key("Insert", modifiers=["Shift"]) # Wait for text to show up - wait_line(echo_result_line + 1, prompt + "foo") + self.wait_line(echo_result_line + 1, prompt + "foo") # check that we get a sensible $PATH; this varies across OSes, so don't be too strict about it b.key("Enter") b.input_text('clear\n') b.input_text("echo $PATH > /tmp/path\n") # don't use wait_line() for the full match here, as line breaks get in the way; just wait until command has run - wait_line(echo_result_line, prompt) + self.wait_line(echo_result_line, prompt) path = m.execute("cat /tmp/path").strip() if m.ws_container: self.assertIn("/usr/local/bin:/usr/bin", path) @@ -262,6 +265,77 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.input_text("id\n") b.wait_js_cond("ph_text('.terminal').indexOf('uid=') >= 0") + @testlib.nondestructive + def testOpenPath(self): + b = self.browser + m = self.machine + + self.login_and_go("/system/terminal") + + blank_state = ' ' + + # Wait until first line is not empty + b.wait_visible(".terminal .xterm-accessibility-tree") + function_str = "(function (sel) { return ph_text(sel).trim() != '%s'})" % blank_state + b.wait_js_func(function_str, line_sel(1)) + + self.clear_terminal() + + # Change to directory that exists + b.go("#/?path=/tmp") + b.wait_visible(".terminal .xterm-accessibility-tree") + self.wait_line(1, "disconnected") + self.clear_terminal() + b.input_text("pwd\n") + self.wait_line(2, "/tmp") + b.wait_not_present(".pf-v5-c-alert") + + # Error on dir non-exist + b.go("#/?path=/doesnotexist") + b.wait_in_text(".pf-v5-c-alert", "does not exist") + # If there's an error, we shouldn't reset the terminal + b.input_text("pwd\n") + self.wait_line(4, "/tmp") + # Clear the error + b.click('.pf-v5-c-alert__action button') + b.wait_not_present(".pf-v5-c-alert") + + # Error on change to non-dir + b.go("#/?path=/etc%2Fos-release") + b.wait_in_text(".pf-v5-c-alert", "not a directory") + # If there's an error, we shouldn't reset the terminal + b.input_text("pwd\n") + self.wait_line(6, "/tmp") + # Clear the error + b.click('div.pf-v5-c-alert__action button') + b.wait_not_present(".pf-v5-c-alert") + b.wait_js_cond('window.location.hash == "#/"') + + # Prompt for change if terminal busy + b.input_text("sh -c 'echo busybusybusy; echo $$; exec sleep infinity'\n") + b.wait_in_text(line_sel(8), 'busybusybusy') + pid = b.text(line_sel(9)) + # m.execute(["false"]) + b.go("#/?path=/tmp") + b.wait_in_text(".pf-v5-c-alert.pf-m-danger", "still a process running") + # Cancel change + b.click("button:contains('Cancel')") + b.wait_not_present(".pf-v5-c-alert") + b.wait_js_cond('window.location.hash == "#/"') + # Check busy process is still running + m.execute(f"kill -0 {pid}") + b.wait_in_text(line_sel(8), 'busybusybusy') + # Confirm change + b.go("#/?path=/var") + b.wait_in_text(".pf-v5-c-alert.pf-m-danger", "still a process running") + b.click("button:contains('Continue')") + b.wait_not_present(".pf-v5-c-alert.pf-m-danger") + self.wait_line(1, "disconnected") + self.clear_terminal() + b.input_text("pwd\n") + self.wait_line(2, "/var") + self.assertIn("No such process", m.execute(f"kill -0 {pid} 2>&1 || true")) + if __name__ == '__main__': testlib.test_main()