feat(panel): add RCON command input to server console
This commit is contained in:
5 files changed
+171
-7
No files matched your search
@@ -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": "重连中…",
|
||||||
|
|||||||
@@ -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 ?? []),
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user