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()