feat(rcon): retain terminal transcript
This commit is contained in:
@@ -102,6 +102,26 @@ describe("RconConsole", () => {
|
|||||||
expect(input.value).toBe("draft command");
|
expect(input.value).toBe("draft command");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||||
|
render(<RconConsole servers={[server]} />);
|
||||||
|
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||||
|
|
||||||
|
let listResponses = 0;
|
||||||
|
for (const command of ["list", "say hello", "list"]) {
|
||||||
|
fireEvent.change(input, { target: { value: command } });
|
||||||
|
fireEvent.submit(input.form!);
|
||||||
|
if (command === "list") listResponses += 1;
|
||||||
|
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = screen.getByLabelText("Terminal transcript");
|
||||||
|
const text = transcript.textContent ?? "";
|
||||||
|
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||||
|
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||||
|
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||||
|
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns focus to the command prompt after changing servers", async () => {
|
it("returns focus to the command prompt after changing servers", async () => {
|
||||||
render(<RconConsole servers={[server, creative]} />);
|
render(<RconConsole servers={[server, creative]} />);
|
||||||
const select = screen.getByLabelText("Server");
|
const select = screen.getByLabelText("Server");
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ import { AdminModalForm } from "@/components/admin-modal-form";
|
|||||||
|
|
||||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||||
const MAX_COMMAND_HISTORY = 50;
|
const MAX_COMMAND_HISTORY = 50;
|
||||||
|
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||||
|
|
||||||
|
type TranscriptExchange = {
|
||||||
|
id: number;
|
||||||
|
serverName: string;
|
||||||
|
command: string;
|
||||||
|
status: "pending" | "success" | "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RconServerOption = {
|
export type RconServerOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,11 +49,13 @@ export function RconConsole({
|
|||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [history, setHistory] = useState<string[]>([]);
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||||
|
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||||
const draftRef = useRef("");
|
const draftRef = useRef("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const nextExchangeIdRef = useRef(0);
|
||||||
|
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||||
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||||
const responseServer = servers.find((server) => server.id === state.serverId);
|
|
||||||
const output = terminalOutput({ notice, pending, responseServer, selected, state });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -54,6 +65,20 @@ export function RconConsole({
|
|||||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||||
}, [pending, state.status]);
|
}, [pending, state.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const exchangeId = pendingExchangeIdRef.current;
|
||||||
|
if (exchangeId === null || state.status === "idle") return;
|
||||||
|
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||||
|
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||||
|
? { ...exchange, status: resultStatus, message: state.message }
|
||||||
|
: exchange));
|
||||||
|
pendingExchangeIdRef.current = null;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||||
|
}, [transcript]);
|
||||||
|
|
||||||
function navigateHistory(direction: "older" | "newer") {
|
function navigateHistory(direction: "older" | "newer") {
|
||||||
if (!history.length) return;
|
if (!history.length) return;
|
||||||
if (direction === "older") {
|
if (direction === "older") {
|
||||||
@@ -76,7 +101,17 @@ export function RconConsole({
|
|||||||
|
|
||||||
function rememberSubmittedCommand() {
|
function rememberSubmittedCommand() {
|
||||||
const submitted = command.trim();
|
const submitted = command.trim();
|
||||||
if (!submitted) return;
|
if (!submitted || !selected) return;
|
||||||
|
const exchangeId = ++nextExchangeIdRef.current;
|
||||||
|
pendingExchangeIdRef.current = exchangeId;
|
||||||
|
const exchange: TranscriptExchange = {
|
||||||
|
id: exchangeId,
|
||||||
|
serverName: selected.name,
|
||||||
|
command: submitted,
|
||||||
|
status: "pending",
|
||||||
|
message: "Command in progress…",
|
||||||
|
};
|
||||||
|
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||||
setHistoryIndex(null);
|
setHistoryIndex(null);
|
||||||
draftRef.current = "";
|
draftRef.current = "";
|
||||||
@@ -140,9 +175,27 @@ export function RconConsole({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" role={output.status === "error" ? "alert" : "status"}>
|
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${output.status === "error" ? "text-accent" : "text-muted"}`}>{output.label}</p>
|
{notice && (
|
||||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
|
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||||
|
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||||
|
<p className="mt-2">{notice.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{transcript.map((exchange) => (
|
||||||
|
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||||
|
<p className="break-words">
|
||||||
|
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||||
|
<span className="text-accent">$</span> {exchange.command}
|
||||||
|
</p>
|
||||||
|
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||||
|
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
||||||
@@ -177,29 +230,10 @@ export function RconConsole({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalOutput({
|
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||||
notice,
|
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||||
pending,
|
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||||
responseServer,
|
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||||
selected,
|
|
||||||
state,
|
|
||||||
}: {
|
|
||||||
notice?: RconTerminalNotice;
|
|
||||||
pending: boolean;
|
|
||||||
responseServer?: RconServerOption;
|
|
||||||
selected?: RconServerOption;
|
|
||||||
state: RconCommandState;
|
|
||||||
}) {
|
|
||||||
if (pending) return { status: "success" as const, label: "Executing", message: "Command in progress…" };
|
|
||||||
if (state.status !== "idle") return {
|
|
||||||
status: state.status,
|
|
||||||
label: `${state.status === "error" ? "Error" : "Response"}${responseServer ? ` — ${responseServer.name}` : ""}`,
|
|
||||||
message: state.message,
|
|
||||||
};
|
|
||||||
if (notice) return { ...notice, label: notice.status === "error" ? "Connection error" : "Connection update" };
|
|
||||||
if (!selected) return { status: "success" as const, label: "Ready", message: "No connections configured. Use Add to create a server connection." };
|
|
||||||
if (!selected.enabled) return { status: "error" as const, label: `Disabled — ${selected.name}`, message: "Enable this connection before testing commands." };
|
|
||||||
return { status: "success" as const, label: `Ready — ${selected.name}`, message: "Awaiting command" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderButton({ label }: { label: string }) {
|
function HeaderButton({ label }: { label: string }) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* **Refine**: Renamed RCON host configuration to server addresses, documented internal and external targets, and redesigned the console as a portal-colored terminal with a target bar, command prompt, and latest-response viewport.
|
* **Refine**: Renamed RCON host configuration to server addresses, documented internal and external targets, and redesigned the console as a portal-colored terminal with a target bar, command prompt, and latest-response viewport.
|
||||||
* **Refine**: Consolidated RCON connection management into a full-width terminal workspace with header controls, modal add/edit/delete flows, terminal-contained notices, and no duplicate configuration panels.
|
* **Refine**: Consolidated RCON connection management into a full-width terminal workspace with header controls, modal add/edit/delete flows, terminal-contained notices, and no duplicate configuration panels.
|
||||||
* **Extend**: Added bounded page-memory RCON command recall with Arrow Up/Arrow Down navigation, unsent-draft restoration, and prompt focus retention after results and server changes.
|
* **Extend**: Added bounded page-memory RCON command recall with Arrow Up/Arrow Down navigation, unsent-draft restoration, and prompt focus retention after results and server changes.
|
||||||
|
* **Extend**: Retained up to 50 chronological page-memory RCON command/response exchanges in the auto-scrolling terminal transcript without persisting them.
|
||||||
|
|
||||||
## 2026-08-07
|
## 2026-08-07
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Operate servers through an RCON console
|
title: Operate servers through an RCON console
|
||||||
description: Administrators execute bounded RCON commands through the server-side portal proxy.
|
description: Administrators execute bounded RCON commands through the server-side portal proxy.
|
||||||
tags: [admin, rcon, minecraft, console, security]
|
tags: [admin, rcon, minecraft, console, security]
|
||||||
timestamp: 2026-08-08T14:06:09Z
|
timestamp: 2026-08-08T14:46:10Z
|
||||||
story_id: US-022
|
story_id: US-022
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -21,20 +21,21 @@ As an administrator, I want an RCON console in the portal, so that I can operate
|
|||||||
- [x] Command responses are displayed safely, and commands and responses are not persisted in browser storage, audit data, or application logs.
|
- [x] Command responses are displayed safely, and commands and responses are not persisted in browser storage, audit data, or application logs.
|
||||||
- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses.
|
- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses.
|
||||||
- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
|
- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
|
||||||
- [x] The console spans the available content width and uses the portal color palette to present a terminal-style server header with connection controls, a single keyboard-accessible prompt, pending state, and scrollable latest-response viewport.
|
- [x] The console spans the available content width and uses the portal color palette to present a terminal-style server header with connection controls, a single keyboard-accessible prompt, pending state, and scrollable transcript viewport.
|
||||||
- [x] Configured server addresses may be internal or external, and operators receive guidance that RCON network exposure and transport security remain their responsibility.
|
- [x] Configured server addresses may be internal or external, and operators receive guidance that RCON network exposure and transport security remain their responsibility.
|
||||||
- [x] Command responses, connection errors, and connection-operation results appear in the terminal viewport, including an actionable empty state when no connection exists.
|
- [x] Connection errors and connection-operation results appear in the terminal viewport, including an actionable empty state when no connection exists.
|
||||||
- [x] The page has no duplicate connection form or connection-list panel outside the terminal workspace.
|
- [x] The page has no duplicate connection form or connection-list panel outside the terminal workspace.
|
||||||
- [x] A bounded page-memory-only history lets administrators use Arrow Up and Arrow Down to recall and edit commands, then restore the unsent draft after the newest history entry.
|
- [x] A bounded page-memory-only history lets administrators use Arrow Up and Arrow Down to recall and edit commands, then restore the unsent draft after the newest history entry.
|
||||||
- [x] After each command result and server selection change, focus returns to the command input for immediate editing or resubmission.
|
- [x] After each command result and server selection change, focus returns to the command input for immediate editing or resubmission.
|
||||||
|
- [x] A bounded chronological transcript retains up to 50 page-memory command/response exchanges, labels each selected server and submitted command, places its safe response or error directly below it, and scrolls to the newest exchange.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
The full-width portal-colored terminal workspace identifies and manages the selected server in its header, accepts one command through a keyboard-focused prompt, and displays command responses plus connection-operation notices in one scrollable viewport. It retains an actionable terminal and Add control when no connections exist, with no duplicate configuration panels. Up to 50 submitted commands remain only in page memory for editable Arrow Up/Arrow Down recall, including restoration of the current unsent draft; focus returns to the prompt after command results and server changes. The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
|
The full-width portal-colored terminal workspace identifies and manages the selected server in its header, accepts one command through a keyboard-focused prompt, and displays connection-operation notices plus up to 50 chronological command/response exchanges in one auto-scrolling viewport. Each exchange labels its server and complete submitted command, then places the bounded safe response or error directly below it. It retains an actionable terminal and Add control when no connections exist, with no duplicate configuration panels. Up to 50 submitted commands remain only in page memory for editable Arrow Up/Arrow Down recall, including restoration of the current unsent draft; focus returns to the prompt after command results and server changes. The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Component validation confirms the full-width workspace, labelled server and command controls, header actions, accessible modal forms, terminal-contained notices, the actionable no-server state, editable command-history navigation with draft restoration, and prompt focus after command results and server changes. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Component validation confirms the full-width workspace, labelled server and command controls, header actions, accessible modal forms, terminal-contained notices, the actionable no-server state, editable command-history navigation with draft restoration, prompt focus after command results and server changes, and ordered retention of repeated command/response exchanges. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ The password entered in the administrator connection form must match the server
|
|||||||
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
|
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
|
||||||
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
|
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
|
||||||
- Responses are sanitized and limited to 64 KiB.
|
- Responses are sanitized and limited to 64 KiB.
|
||||||
- Up to 50 submitted commands remain only in page memory for Arrow Up/Arrow Down recall and are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
|
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
|
||||||
- Connection passwords are never selected by page queries or returned to the browser.
|
- Connection passwords are never selected by page queries or returned to the browser.
|
||||||
|
|
||||||
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
|
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
|
||||||
|
|||||||
Reference in New Issue
Block a user