Unverified Commit cfe126fb authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 061482d3
Loading
Loading
Loading
Loading
+40 −0
Changes for panel/dev/mockApi.ts: 40 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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<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 {
  if (serverInfo.owner !== null) {
    sendError(ctx.res, 409, "already_claimed", "server is already claimed");
+1 −0
Changes for panel/src/i18n/resources/en-US/servers.json: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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…",
+1 −0
Changes for panel/src/i18n/resources/zh-CN/servers.json: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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": "重连中…",
+7 −0
Changes for panel/src/lib/api.ts: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 ?? []),

+121 −6
Changes for panel/src/pages/ServerConsole.tsx: 121 added lines, 6 removed lines.
Original line number Diff line number Diff line
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<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() {
  const { name = "" } = useParams();
  const cfg = useConfig();
@@ -131,14 +241,19 @@ export function ServerConsole() {
                {t("console_card_desc")}
              </p>
            </CardHeader>
            <CardContent>
            <CardContent className="space-y-3">
              {!streamable ? (
                <NotStreaming phase={data.phase} />
              ) : 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. */}
                  <LogConsole key={name} url={consoleStreamURL(cfg.apiBase, name)} />
                  {data.phase === "Running" && (
                    <CommandInput name={name} />
                  )}
                </>
              ) : (
                <Loading />
              )}