diff --git a/pkg/systemd/overview.jsx b/pkg/systemd/overview.jsx index 7ade0dc27cb0..8300d8aabea8 100644 --- a/pkg/systemd/overview.jsx +++ b/pkg/systemd/overview.jsx @@ -168,6 +168,10 @@ class OverviewPage extends React.Component { this.state.hostnameData.OperatingSystemPrettyName &&
{cockpit.format(_("running $0"), this.state.hostnameData.OperatingSystemPrettyName)}
} + {/* DEV ARTIFACT: FOR TESTING */} +
{ show_superuser && } { "\n" } diff --git a/pkg/systemd/terminal.jsx b/pkg/systemd/terminal.jsx index 03ada383bae5..219ba17a245c 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 }), { once: true }); + ch.addEventListener("close", () => this.setState({ pid: null }), { once: true }); + return ch; } constructor(props) { @@ -66,12 +72,19 @@ 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.dismissError = this.dismissError.bind(this); + this.dismissChange = this.dismissChange.bind(this); this.terminalRef = React.createRef(); this.resetButtonRef = React.createRef(); @@ -81,8 +94,27 @@ const _ = cockpit.gettext; } async componentDidMount() { + cockpit.addEventListener("locationchanged", this.onNavigate); const user = await cockpit.user(); - this.setState({ user, channel: this.createChannel(user) }); + + let dir; + if (cockpit.location.options.path) { + try { + const info = await fsinfo(String(cockpit.location.options.path), ['type']); + if (info.type === "dir") { + dir = cockpit.location.options.path; + } else { + this.setState({ pathError: cockpit.format(_("$0 is not a directory"), cockpit.location.options.path) }); + } + } catch (err) { + this.setState({ pathError: cockpit.format(_("$0 does not exist"), cockpit.location.options.path) }); + } + } + this.setState({ user, channel: this.createChannel(user, dir) }); + } + + componentWillUnmount() { + cockpit.removeEventListener("locationchanged", this.onNavigate); } onTitleChanged(title) { @@ -95,6 +127,56 @@ const _ = cockpit.gettext; document.cookie = cookie; } + forceChangeDirectory() { + this.setState(prevState => ({ + channel: this.createChannel(prevState.user, cockpit.location.options.path), + changePathBusy: false, + })); + } + + dismissError() { + this.setState({ pathError: null }); + cockpit.location.replace(""); + } + + dismissChange() { + this.setState({ changePathBusy: false }); + cockpit.location.replace(""); + } + + async onNavigate() { + // Clear old path errors + this.setState({ pathError: null }); + + // If there's no path to change to, then we're done here + if (!cockpit.location.options.path) { + return; + } + // 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; + } + } catch (err) { + this.setState({ pathError: cockpit.format(_("$0 does not exist"), cockpit.location.options.path) }); + return; + } + + if (this.state.pid !== null) { + // Check if current shell has a process running in it, ie it's busy + const cmmd = "grep -qr '^PPid:[[:space:]]*" + this.state.pid + "$' /proc/*/status"; + cockpit.script(cmmd, [], { err: "message" }) + .then(() => { + this.setState({ changePathBusy: true }); + }) + .catch(() => { + this.setState(prevState => ({ channel: this.createChannel(prevState.user, cockpit.location.options.path) })); + }); + } + } + onPlus() { this.setState((state, _) => { localStorage.setItem('terminal:font-size', state.size + 1); @@ -186,6 +268,22 @@ const _ = cockpit.gettext;
+
+ {this.state.pathError && } />} + {this.state.changePathBusy && + this.setState({ changePathBusy: false })} />} + actionLinks={ + <> + + {_("Cancel")} + + } /> + } +
{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 a6c5134b2ef4..508a289140c4 100755 --- a/test/verify/check-system-terminal +++ b/test/verify/check-system-terminal @@ -25,6 +25,7 @@ def line_sel(i): class TestTerminal(testlib.MachineCase): + def setUp(self): super().setUp() @@ -37,6 +38,17 @@ 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, i, t): + b = self.browser + 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 + @testlib.nondestructive def testBasic(self): b = self.browser @@ -44,19 +56,8 @@ PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/ b.default_user = "admin" 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") @@ -84,19 +85,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", '@') @@ -113,14 +114,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): @@ -136,11 +137,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) @@ -158,7 +159,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') @@ -166,14 +167,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) @@ -184,14 +185,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) @@ -263,6 +264,98 @@ 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 + b.default_user = "admin" + + self.login_and_go("/system/terminal") + + blank_state = ' ' + + # 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") + # now we should get a clean prompt + b.wait_in_text(line_sel(n), '$') + + # cut trailing non-breaking spaces + prompt = b.text(line_sel(n)) + + # Make sure we are started in home directory + # Account for non-standard prompting + if "]" not in prompt: + self.assertIn(":~$", prompt) + else: + self.assertIn("~]$", prompt) + + # Change to directory that exists + b.go("#/?path=/tmp") + b.wait_visible(".terminal .xterm-accessibility-tree") + self.wait_line(n, "disconnected") + b.input_text("clear") + b.key("Enter") + b.wait_js_cond("ph_text('.xterm-accessibility-tree').indexOf('clear') < 0") + b.input_text("pwd\n") + self.wait_line(n + 1, "/tmp") + + # Error on dir non-exist + b.go("#/?path=/doesnotexist") + b.wait_visible(".pf-v5-c-alert.pf-m-danger") + b.wait_in_text(".pf-v5-c-alert.pf-m-danger", "does not exist") + # If there's an error, we shouldn't reset the terminal + b.input_text("pwd\n") + self.wait_line(n + 3, "/tmp") + # Clear the error + b.click('.pf-v5-c-alert__action button') + b.wait_not_present(".pf-v5-c-alert.pf-m-danger") + + # Error on change to non-dir + m.execute("touch /tmp/thisisafile") + b.go("#/?path=/tmp%2Fthisisafile") + b.wait_visible(".pf-v5-c-alert.pf-m-danger") + b.wait_in_text(".pf-v5-c-alert.pf-m-danger", "not a directory") + # If there's an error, we shouldn't reset the terminal + b.input_text("pwd\n") + self.wait_line(n + 5, "/tmp") + # Clear the error + b.click('div.pf-v5-c-alert__action button') + b.wait_not_present(".pf-v5-c-alert.pf-m-danger") + b.wait_js_cond('window.location.hash == "#/"') + + # Prompt for change if terminal busy + b.input_text("vi busybusybusy\n") + b.wait_in_text(line_sel(n + 1), '~') + b.go("#/?path=/tmp") + b.wait_visible(".ct-terminal-dir-alert") + b.wait_in_text(".ct-terminal-dir-alert", "still a process running") + # Cancel change + b.click("button:contains('Cancel')") + b.wait_not_present(".pf-v5-c-alert.pf-m-danger") + b.wait_js_cond('window.location.hash == "#/"') + # Confirm change + b.go("#/?path=/home%2Fadmin") + b.wait_visible(".ct-terminal-dir-alert") + b.wait_in_text(".ct-terminal-dir-alert", "still a process running") + b.click("button:contains('Continue')") + b.wait_not_present(".pf-v5-c-alert.pf-m-danger") + self.wait_line(n, "disconnected") + b.input_text("clear") + b.key("Enter") + b.wait_js_cond("ph_text('.xterm-accessibility-tree').indexOf('clear') < 0") + b.input_text("pwd\n") + self.wait_line(n + 1, "/home/admin") + if __name__ == '__main__': testlib.test_main()