From cfe126fbee7c22435ba10deb3c864d71a92718c6 Mon Sep 17 00:00:00 2001
From: Lemon-miaow
Date: Sat, 27 Jun 2026 22:52:54 +0800
Subject: [PATCH] feat(panel): add RCON command input to server console
---
panel/dev/mockApi.ts | 40 ++++++
panel/src/i18n/resources/en-US/servers.json | 1 +
panel/src/i18n/resources/zh-CN/servers.json | 1 +
panel/src/lib/api.ts | 7 ++
panel/src/pages/ServerConsole.tsx | 129 ++++++++++++++++++--
5 files changed, 171 insertions(+), 7 deletions(-)
diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts
index cb6dc4d..87f6ffd 100644
--- a/panel/dev/mockApi.ts
+++ b/panel/dev/mockApi.ts
@@ -465,6 +465,14 @@ function handleServerRoute(ctx: SessionContext): boolean {
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Stopped" });
return true;
}
+ if (is("POST", ctx) && ctx.parts[4] === "command") {
+ if (!canManage(ctx.account, serverInfo)) {
+ sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
+ return true;
+ }
+ handleCommandMock(ctx, serverInfo);
+ return true;
+ }
if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo);
return true;
@@ -473,6 +481,38 @@ function handleServerRoute(ctx: SessionContext): boolean {
return false;
}
+async function handleCommandMock(
+ ctx: SessionContext,
+ serverInfo: MockServer,
+): Promise {
+ const body = await readJSON<{ command?: string }>(ctx.req);
+ const cmd = body.command?.trim();
+ if (!cmd) {
+ sendError(ctx.res, 400, "bad_request", "command is required");
+ return;
+ }
+ if (serverInfo.phase !== "Running") {
+ sendError(
+ ctx.res,
+ 409,
+ "conflict",
+ "server is not running; wake it before sending console commands",
+ );
+ return;
+ }
+ const reply = mockCommandReply(cmd);
+ sendJSON(ctx.res, 200, { output: reply });
+}
+
+function mockCommandReply(cmd: string): string {
+ const lower = cmd.toLowerCase();
+ if (lower === "list") return "There are 2 of a max 20 players online: mock_player, test_player";
+ if (lower === "tps" || lower === "forge tps") return "TPS from last 5s, 10s, 1m, 5m, 15m: 20.00, 20.00, *19.87, 19.95, 19.98";
+ if (cmd.startsWith("say ")) return `[mock_server] ${cmd.slice(4)}`;
+ if (lower === "help") return "--- Showing help ---\n/felis\n/msg\n/list\n/rules";
+ return `[mock] command "${cmd}" executed`;
+}
+
function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed");
diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json
index 8a2153b..03fd0c0 100644
--- a/panel/src/i18n/resources/en-US/servers.json
+++ b/panel/src/i18n/resources/en-US/servers.json
@@ -29,6 +29,7 @@
"my_servers_breadcrumb": "My servers",
"console_card_title": "Console",
"console_card_desc": "Live, read-only output streamed from the running pod over SSE. Commands run through a separate path — the panel never holds an RCON password.",
+ "command_placeholder": "Type a command… e.g. list",
"log_connecting": "Connecting…",
"log_live": "Live",
"log_reconnecting": "Reconnecting…",
diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json
index 519cc35..ad3c6fd 100644
--- a/panel/src/i18n/resources/zh-CN/servers.json
+++ b/panel/src/i18n/resources/zh-CN/servers.json
@@ -29,6 +29,7 @@
"my_servers_breadcrumb": "我的服务器",
"console_card_title": "控制台",
"console_card_desc": "通过 SSE 从 Pod 实时流式输出的只读日志。命令执行走独立通道——面板不持有 RCON 密码。",
+ "command_placeholder": "输入命令…如 list",
"log_connecting": "连接中…",
"log_live": "实时",
"log_reconnecting": "重连中…",
diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts
index 05a4d8e..ef12d7a 100644
--- a/panel/src/lib/api.ts
+++ b/panel/src/lib/api.ts
@@ -91,6 +91,13 @@ export const api = {
claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`),
+ /** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
+ * The backend strips a leading "/", rejects control characters (newline → 400)
+ * and caps the command at 1000 bytes. The reply is the server's plain-text
+ * response body. */
+ sendCommand: (name: string, command: string) =>
+ request<{ output: string }>("POST", `/servers/${name}/command`, { command }),
+
listImages: () =>
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx
index 9331716..2eaa6ad 100644
--- a/panel/src/pages/ServerConsole.tsx
+++ b/panel/src/pages/ServerConsole.tsx
@@ -1,12 +1,13 @@
+import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom";
-import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react";
+import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
import { LogConsole } from "@/components/LogConsole";
import { Loading, ErrorState } from "@/components/States";
-import { api, consoleStreamURL } from "@/lib/api";
+import { api, consoleStreamURL, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { hostFor } from "@/lib/config";
import type { Phase } from "@/lib/types";
@@ -59,6 +60,115 @@ function NotStreaming({ phase }: { phase: Phase }) {
);
}
+function loadHistory(name: string): string[] {
+ try {
+ const raw = localStorage.getItem(HISTORY_KEY(name));
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+function persistHistory(name: string, h: string[]): void {
+ try {
+ localStorage.setItem(HISTORY_KEY(name), JSON.stringify(h));
+ } catch {
+ /* storage full — silently drop */
+ }
+}
+
+const MAX_HISTORY = 50;
+const HISTORY_KEY = (name: string) => `felis:cmd:history:${name}`;
+
+/** CommandInput is the §8 write-side console input: a one-line text field that
+ * sends an RCON command to the running server and displays its plain-text reply.
+ * Enter sends; Up/Down cycle through persistent per-server command history.
+ * Only available when the server is Running (RCON reachable). */
+function CommandInput({ name }: { name: string }) {
+ const { t } = useTranslation("servers");
+ const [command, setCommand] = useState("");
+ const [sending, setSending] = useState(false);
+ const [err, setErr] = useState(null);
+ const historyRef = useRef(loadHistory(name));
+ const cursorRef = useRef(-1);
+ const inputRef = useRef(null);
+ const composingRef = useRef(false);
+
+ const send = useCallback(async (text: string) => {
+ if (!text || sending) return;
+ setSending(true);
+ setErr(null);
+ setCommand("");
+ const h = historyRef.current;
+ if (h.length === 0 || h[h.length - 1] !== text) {
+ if (h.length >= MAX_HISTORY) h.shift();
+ h.push(text);
+ persistHistory(name, h);
+ }
+ cursorRef.current = h.length;
+ try {
+ await api.sendCommand(name, text);
+ } catch (ex) {
+ setErr(humanizeError(ex));
+ } finally {
+ setSending(false);
+ }
+ }, [name, sending]);
+
+ useLayoutEffect(() => {
+ if (!sending) inputRef.current?.focus();
+ }, [sending]);
+
+ const onKeyDown = useCallback((e: KeyboardEvent) => {
+ if (composingRef.current) return;
+ const h = historyRef.current;
+ if (e.key === "Enter") {
+ e.preventDefault();
+ send(command.trim());
+ return;
+ }
+ if (h.length === 0) return;
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ const idx = Math.max(0, cursorRef.current === -1 ? h.length - 1 : cursorRef.current - 1);
+ cursorRef.current = idx;
+ setCommand(h[idx]);
+ setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
+ }
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ const idx = Math.min(h.length, cursorRef.current + 1);
+ cursorRef.current = idx;
+ setCommand(idx < h.length ? h[idx] : "");
+ if (idx < h.length) {
+ setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
+ }
+ }
+ }, [command, send]);
+
+ return (
+
+
+ {">"}
+ { setCommand(e.target.value); cursorRef.current = -1; }}
+ onKeyDown={onKeyDown}
+ onCompositionStart={() => { composingRef.current = true; }}
+ onCompositionEnd={() => { composingRef.current = false; }}
+ placeholder={t("command_placeholder")}
+ autoComplete="off"
+ spellCheck={false}
+ className="flex-1 bg-transparent py-1.5 text-zinc-200 placeholder:text-zinc-500 focus:outline-none"
+ />
+ {sending && }
+
+ {err &&
{err}
}
+
+ );
+}
+
export function ServerConsole() {
const { name = "" } = useParams();
const cfg = useConfig();
@@ -131,14 +241,19 @@ export function ServerConsole() {
{t("console_card_desc")}
-
+
{!streamable ? (
) : cfg ? (
- // key on name so navigating between servers remounts the viewport
- // (fresh stream + scroll state). useLogStream also resets its
- // controller when the url changes, so this is belt-and-suspenders.
-
+ <>
+ {/* key on name so navigating between servers remounts the viewport
+ (fresh stream + scroll state). useLogStream also resets its
+ controller when the url changes, so this is belt-and-suspenders. */}
+
+ {data.phase === "Running" && (
+
+ )}
+ >
) : (
)}