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" && ( + + )} + ) : ( )}