From 19486150c32d4edd5b6542046caa89ac64148751 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 09:41:37 -0400 Subject: [PATCH] feat(rcon): unify terminal workspace --- .../web/src/app/admin/(console)/rcon/page.tsx | 102 ++------ apps/web/src/components/rcon-console.test.tsx | 41 +++- apps/web/src/components/rcon-console.tsx | 218 +++++++++++++++--- design/log.md | 1 + design/us-021-rcon-connections.md | 8 +- design/us-022-rcon-console.md | 10 +- 6 files changed, 245 insertions(+), 135 deletions(-) diff --git a/apps/web/src/app/admin/(console)/rcon/page.tsx b/apps/web/src/app/admin/(console)/rcon/page.tsx index 23bde28..fd88733 100644 --- a/apps/web/src/app/admin/(console)/rcon/page.tsx +++ b/apps/web/src/app/admin/(console)/rcon/page.tsx @@ -1,14 +1,7 @@ import { rconServers } from "@minecraft-account-manager/database"; import { asc } from "drizzle-orm"; -import { RconConsole } from "@/components/rcon-console"; +import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console"; import { db } from "@/lib/database"; -import { - createRconServer, - deleteRconServer, - setRconServerEnabled, - testSavedRconServer, - updateRconServer, -} from "./actions"; export const dynamic = "force-dynamic"; @@ -46,13 +39,17 @@ export default async function RconPage({ const query = await searchParams; const saved = queryValue(query.saved); const error = queryValue(query.error); + const notice: RconTerminalNotice | undefined = error + ? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." } + : saved + ? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." } + : undefined; const servers = await db.select({ id: rconServers.id, name: rconServers.name, host: rconServers.host, port: rconServers.port, enabled: rconServers.enabled, - updatedAt: rconServers.updatedAt, }).from(rconServers).orderBy(asc(rconServers.name)); return ( @@ -60,88 +57,19 @@ export default async function RconPage({

Server operations

RCON

-

Run commands through the portal backend to internal or external server addresses. Credentials are never sent to the browser.

+

Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.

- {saved &&

{savedMessages[saved] ?? "RCON settings saved."}

} - {error &&

{errorMessages[error] ?? "The RCON operation failed."}

} - -
-
-

Command proxy

-

Console

-

Only the latest bounded response is shown. Commands and responses are not saved as console history.

- server.enabled).map(({ id, name }) => ({ id, name }))} /> -
- -
-

Configuration

-

Add connection

-
- - - - -
-
- -
-
-

Saved endpoints

Connections

- {servers.length} configured -
-
- {servers.map((server) => ( -
-
-

{server.name}

{server.enabled ? "Enabled" : "Disabled"}
-

{server.host}:{server.port}

-

Updated {server.updatedAt.toISOString()}

-
-
-
-
-
- Edit -
- - - - - -
-
- Delete -
- -

Delete {server.name}? Its encrypted credential will be removed.

- -
-
-
-
- ))} - {!servers.length &&

No RCON connections configured.

} +
+
+
+

Command proxy

+

Terminal

+
+

Only the latest bounded response is shown. Commands and responses are not saved as console history.

+
); } - -function ConnectionFields({ - prefix, - defaults, -}: { - prefix: string; - defaults?: { name: string; host: string; port: number }; -}) { - const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent"; - return ( - <> - - - - - - ); -} diff --git a/apps/web/src/components/rcon-console.test.tsx b/apps/web/src/components/rcon-console.test.tsx index 3f0561e..b966a03 100644 --- a/apps/web/src/components/rcon-console.test.tsx +++ b/apps/web/src/components/rcon-console.test.tsx @@ -2,25 +2,58 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; vi.mock("@/app/admin/(console)/rcon/actions", () => ({ + createRconServer: vi.fn(), + deleteRconServer: vi.fn(), executeRconCommand: vi.fn(), + setRconServerEnabled: vi.fn(), + testSavedRconServer: vi.fn(), + updateRconServer: vi.fn(), })); import { RconConsole } from "./rcon-console"; +const server = { + id: "11111111-1111-4111-8111-111111111111", + name: "Season 4", + host: "season4.somc.svc.cluster.local", + port: 25575, + enabled: true, +}; + describe("RconConsole", () => { - it("renders a labelled keyboard-operable terminal without persisted history", () => { - const markup = renderToStaticMarkup(); + it("renders one wide terminal workspace with connection controls and modal forms", () => { + const markup = renderToStaticMarkup(); expect(markup).toContain('aria-label="RCON terminal"'); expect(markup).toContain('for="rcon-console-server"'); expect(markup).toContain('for="rcon-command"'); + expect(markup).toContain("w-full"); expect(markup).toContain("Season 4"); expect(markup).toContain("server://"); expect(markup).toContain("Awaiting command"); + expect(markup).toContain("Add"); + expect(markup).toContain("Edit"); + expect(markup).toContain("Test"); + expect(markup).toContain("Disable"); + expect(markup).toContain("Delete"); + expect(markup).toContain("Add RCON connection"); + expect(markup).toContain("Edit Season 4"); + expect(markup).toContain("Delete Season 4?"); expect(markup).toContain("Enter ↵"); expect(markup).not.toContain("Latest response"); }); - it("explains when no enabled connection is available", () => { - expect(renderToStaticMarkup()).toContain("Enable an RCON connection"); + it("renders connection operation notices inside the terminal viewport", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("RCON authentication timed out."); + expect(markup).toContain('role="alert"'); + }); + + it("keeps the terminal and add action available when no connection exists", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain('aria-label="RCON terminal"'); + expect(markup).toContain("No connections configured"); + expect(markup).toContain("Add"); + expect(markup).not.toContain("Edit"); + expect(markup).not.toContain("Delete"); }); }); diff --git a/apps/web/src/components/rcon-console.tsx b/apps/web/src/components/rcon-console.tsx index b27ed97..b381567 100644 --- a/apps/web/src/components/rcon-console.tsx +++ b/apps/web/src/components/rcon-console.tsx @@ -1,56 +1,202 @@ "use client"; -import { useActionState } from "react"; -import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions"; +import { useActionState, useState } from "react"; +import { + createRconServer, + deleteRconServer, + executeRconCommand, + setRconServerEnabled, + testSavedRconServer, + type RconCommandState, + updateRconServer, +} from "@/app/admin/(console)/rcon/actions"; +import { AdminModalForm } from "@/components/admin-modal-form"; const initialState: RconCommandState = { status: "idle", message: "", serverId: "" }; -type ServerOption = { id: string; name: string }; +export type RconServerOption = { + id: string; + name: string; + host: string; + port: number; + enabled: boolean; +}; -export function RconConsole({ servers }: { servers: ServerOption[] }) { +export type RconTerminalNotice = { + status: "success" | "error"; + message: string; +}; + +export function RconConsole({ + notice, + servers, +}: { + notice?: RconTerminalNotice; + servers: RconServerOption[]; +}) { + const [selectedId, setSelectedId] = useState(servers[0]?.id ?? ""); const [state, action, pending] = useActionState(executeRconCommand, initialState); + const selected = servers.find((server) => server.id === selectedId) ?? servers[0]; const responseServer = servers.find((server) => server.id === state.serverId); - - if (!servers.length) { - return

Enable an RCON connection before opening the console.

; - } + const output = terminalOutput({ notice, pending, responseServer, selected, state }); return ( -
-
-
-