feat(panel): add RCON command input to server console

This commit is contained in:
Lemon-miaow committed 2026-06-27 23:10:16 +08:00
1 parent 061482d380
commit cfe126fbee
5 files changed
+171 -7

No files matched your search

+40
View File
@@ -465,6 +465,14 @@ function handleServerRoute(ctx: SessionContext): boolean {
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Stopped" }); sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Stopped" });
return true; 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") { if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo); claimServer(ctx, serverInfo);
return true; return true;
@@ -473,6 +481,38 @@ function handleServerRoute(ctx: SessionContext): boolean {
return false; return false;
} }
async function handleCommandMock(
ctx: SessionContext,
serverInfo: MockServer,
): Promise<void> {
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 { function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) { if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed"); sendError(ctx.res, 409, "already_claimed", "server is already claimed");
@@ -29,6 +29,7 @@
"my_servers_breadcrumb": "My servers", "my_servers_breadcrumb": "My servers",
"console_card_title": "Console", "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.", "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_connecting": "Connecting…",
"log_live": "Live", "log_live": "Live",
"log_reconnecting": "Reconnecting…", "log_reconnecting": "Reconnecting…",
@@ -29,6 +29,7 @@
"my_servers_breadcrumb": "我的服务器", "my_servers_breadcrumb": "我的服务器",
"console_card_title": "控制台", "console_card_title": "控制台",
"console_card_desc": "通过 SSE 从 Pod 实时流式输出的只读日志。命令执行走独立通道——面板不持有 RCON 密码。", "console_card_desc": "通过 SSE 从 Pod 实时流式输出的只读日志。命令执行走独立通道——面板不持有 RCON 密码。",
"command_placeholder": "输入命令…如 list",
"log_connecting": "连接中…", "log_connecting": "连接中…",
"log_live": "实时", "log_live": "实时",
"log_reconnecting": "重连中…", "log_reconnecting": "重连中…",
+7
View File
@@ -91,6 +91,13 @@ export const api = {
claim: (name: string) => claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`), 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: () => listImages: () =>
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []), request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
+122 -7
View File
@@ -1,12 +1,13 @@
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom"; 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 { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge } from "@/components/PhaseBadge";
import { LogConsole } from "@/components/LogConsole"; import { LogConsole } from "@/components/LogConsole";
import { Loading, ErrorState } from "@/components/States"; 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 { useAsync, useConfig } from "@/lib/hooks";
import { hostFor } from "@/lib/config"; import { hostFor } from "@/lib/config";
import type { Phase } from "@/lib/types"; 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<string | null>(null);
const historyRef = useRef<string[]>(loadHistory(name));
const cursorRef = useRef(-1);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-1.5">
<div className="flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs text-zinc-200">
<span className="shrink-0 select-none text-zinc-500">{">"}</span>
<input
ref={inputRef}
value={command}
onChange={(e) => { 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 && <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 text-zinc-500" />}
</div>
{err && <p className="text-xs text-destructive">{err}</p>}
</div>
);
}
export function ServerConsole() { export function ServerConsole() {
const { name = "" } = useParams(); const { name = "" } = useParams();
const cfg = useConfig(); const cfg = useConfig();
@@ -131,14 +241,19 @@ export function ServerConsole() {
{t("console_card_desc")} {t("console_card_desc")}
</p> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
{!streamable ? ( {!streamable ? (
<NotStreaming phase={data.phase} /> <NotStreaming phase={data.phase} />
) : cfg ? ( ) : cfg ? (
// key on name so navigating between servers remounts the viewport <>
// (fresh stream + scroll state). useLogStream also resets its {/* key on name so navigating between servers remounts the viewport
// controller when the url changes, so this is belt-and-suspenders. (fresh stream + scroll state). useLogStream also resets its
<LogConsole key={name} url={consoleStreamURL(cfg.apiBase, name)} /> controller when the url changes, so this is belt-and-suspenders. */}
<LogConsole key={name} url={consoleStreamURL(cfg.apiBase, name)} />
{data.phase === "Running" && (
<CommandInput name={name} />
)}
</>
) : ( ) : (
<Loading /> <Loading />
)} )}