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

fix(panel): format RCON replies and hide connection log noise

parent df19878c
Loading
Loading
Loading
Loading
+31 −0
Changes for panel/src/components/LogConsole.test.tsx: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -64,6 +64,37 @@ describe("LogConsole", () => {
    expect(container.textContent).not.toContain("§");
  });

  it("hides only RCON connection lifecycle notices and can reveal them", async () => {
    render(<LogConsole url="/c" />);
    const started = "[15:42:03 INFO]: Thread RCON Client /10.42.0.80 started";
    const stopped = "[15:42:03] [RCON Client /10.42.0.80/INFO]: Thread RCON Client /10.42.0.80 shutting down";
    const warning = "[15:42:04 WARN]: Thread RCON Client /10.42.0.80 shutting down";
    const error = "[15:42:04 ERROR]: RCON authentication failed";
    const chat = "[15:42:04 INFO]: <Steve> Thread RCON Client /10.42.0.80 started";
    await send(started, stopped, warning, error, chat);
    expect(screen.queryByText(started)).toBeNull();
    expect(screen.queryByText(stopped)).toBeNull();
    expect(screen.getByText(warning)).toBeTruthy();
    expect(screen.getByText(error).className).toContain("text-red-400");
    expect(screen.getByText(chat)).toBeTruthy();
    const toggle = screen.getByRole("button", { name: /Show RCON connection logs/ });
    expect(toggle.getAttribute("aria-pressed")).toBe("false");
    expect(toggle.textContent).toContain("(2)");
    fireEvent.click(toggle);
    expect(screen.getByText(started)).toBeTruthy();
    expect(screen.getByText(stopped)).toBeTruthy();
    fireEvent.click(screen.getByRole("button", { name: "Hide RCON connection logs" }));
    expect(screen.queryByText(started)).toBeNull();
  });

  it("explains when the buffer contains only filtered connection notices", async () => {
    render(<LogConsole url="/c" />);
    await send("[15:42:03 INFO]: Thread RCON Client /10.42.0.80 started");
    expect(screen.getByText(i18next.t("servers:log_rcon_only"))).toBeTruthy();
    fireEvent.click(screen.getByRole("button", { name: /Show RCON connection logs/ }));
    expect(screen.queryByText(i18next.t("servers:log_rcon_only"))).toBeNull();
  });

  it("keeps showing new lines in a chunk that was already on screen, and after a clear", async () => {
    const { container } = render(<LogConsole url="/c" />);
    await send(...range(0, 150));
+30 −12
Changes for panel/src/components/LogConsole.tsx: 30 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import { useLogStream } from "@/lib/useLogStream";
import { chunkLines, sameChunk } from "@/lib/logchunks";
import type { LogLevel, LogLine, StreamStatus } from "@/lib/logstream";
import type { Segment } from "@/lib/mcformat";
import type { Formatted, Segment } from "@/lib/mcformat";

// Per-level tint. Plain/info are the default readable foreground; warn/error draw
// the eye. Debug is dimmed so it recedes. The console body is a fixed dark
@@ -32,6 +32,15 @@ function segmentStyle(s: Segment): CSSProperties | undefined {
  };
}

export function FormattedText({ formatted }: { formatted: Formatted }) {
  return formatted.segments
    ? formatted.segments.map((segment, i) => <span key={i} style={segmentStyle(segment)}>{segment.text}</span>)
    : formatted.text;
}

// Match only INFO lifecycle messages; RCON failures and command output remain visible.
const RCON_LIFECYCLE = /^(?:\[\d{2}:\d{2}:\d{2} INFO\]|\[\d{2}:\d{2}:\d{2}(?:\.\d+)?\] \[[^\]]+\/INFO\]): Thread RCON Client \/\S+ (?:started|shutting down)$/;

// A chunk off screen is sized from its last layout, or before it has had one,
// from 100 unwrapped lines of text-xs at leading-relaxed (19.5px each).
const LogChunk = memo(function LogChunk({ lines }: { lines: LogLine[] }) {
@@ -39,13 +48,7 @@ const LogChunk = memo(function LogChunk({ lines }: { lines: LogLine[] }) {
    <div className="[content-visibility:auto] [contain-intrinsic-size:auto_1950px]">
      {lines.map((line) => (
        <div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
          {line.segments
            ? line.segments.map((s, i) => (
                <span key={i} style={segmentStyle(s)}>
                  {s.text}
                </span>
              ))
            : line.text || "\u00A0"}
          <FormattedText formatted={line.text ? line : { text: "\u00A0" }} />
        </div>
      ))}
    </div>
@@ -89,7 +92,10 @@ export function LogConsole({ url, className, starting = false }: { url: string;
    const timer = window.setTimeout(reconnect, 5000);
    return () => window.clearTimeout(timer);
  }, [starting, retryable, status, reconnect]);
  const chunks = useMemo(() => chunkLines(lines), [lines]);
  const [showRcon, setShowRcon] = useState(false);
  const visibleLines = useMemo(() => showRcon ? lines : lines.filter((line) => !RCON_LIFECYCLE.test(line.text)), [lines, showRcon]);
  const hiddenCount = lines.length - visibleLines.length;
  const chunks = useMemo(() => chunkLines(visibleLines), [visibleLines]);
  const scrollRef = useRef<HTMLDivElement>(null);
  const [pinned, setPinned] = useState(true);

@@ -107,7 +113,7 @@ export function LogConsole({ url, className, starting = false }: { url: string;
    if (!pinned) return;
    const el = scrollRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [lines, pinned]);
  }, [visibleLines, pinned]);

  const jumpToLatest = useCallback(() => setPinned(true), []);

@@ -117,6 +123,18 @@ export function LogConsole({ url, className, starting = false }: { url: string;
      <div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2 shrink-0">
        <StatusIndicator status={starting && retryable && status === "ended" ? "reconnecting" : status} />
        <div className="flex items-center gap-1.5">
          {(hiddenCount > 0 || showRcon) && (
            <Button
              variant="ghost"
              size="sm"
              aria-pressed={showRcon}
              onClick={() => setShowRcon((shown) => !shown)}
              className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50"
            >
              {t(showRcon ? "log_hide_rcon" : "log_show_rcon")}
              {hiddenCount > 0 && <span className="tabular-nums">({hiddenCount})</span>}
            </Button>
          )}
          {status === "ended" && (
            <Button
              variant="outline"
@@ -146,11 +164,11 @@ export function LogConsole({ url, className, starting = false }: { url: string;
          onScroll={onScroll}
          className="h-full overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed"
        >
          {lines.length === 0 ? (
          {visibleLines.length === 0 ? (
            <p className="select-none py-8 text-center text-zinc-600">
              {status === "ended" && (!starting || !retryable)
                ? t("log_ended_empty")
                : t(starting ? "log_starting_wait" : "log_waiting")}
                : t(hiddenCount > 0 ? "log_rcon_only" : starting ? "log_starting_wait" : "log_waiting")}
            </p>
          ) : (
            chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
+4 −1
Changes for panel/src/i18n/resources/en-US/servers.json: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -294,5 +294,8 @@
  "emergency_stop_unknown": "The result is unconfirmed. Stop intent may already be saved. Refresh status; if the control API is unreachable, use the host recovery steps.",
  "host_recovery": "Host emergency shutdown procedure",
  "host_recovery_hint": "When the control API is offline, the panel cannot perform cluster operations. Run these commands on the deployment host to save stop intent, set replicas to zero, and inspect game Pods. Replace minecraft if the deployment uses a different namespace.",
  "host_recovery_verify": "If a Pod is terminating or its node is unreachable, process termination cannot be confirmed. Inspect the node and container runtime. Force-deleting a Pod is not evidence of process termination."
  "host_recovery_verify": "If a Pod is terminating or its node is unreachable, process termination cannot be confirmed. Inspect the node and container runtime. Force-deleting a Pod is not evidence of process termination.",
  "log_show_rcon": "Show RCON connection logs",
  "log_hide_rcon": "Hide RCON connection logs",
  "log_rcon_only": "RCON connection lifecycle logs are hidden. Other logs will appear when available."
}
+4 −1
Changes for panel/src/i18n/resources/zh-CN/servers.json: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -293,5 +293,8 @@
  "emergency_stop_unknown": "操作结果尚未确认。停止意图可能已经保存,请重新读取状态;若控制 API 不可达,请使用主机侧恢复步骤。",
  "host_recovery": "主机侧应急停止步骤",
  "host_recovery_hint": "控制 API 离线时,面板无法执行集群操作。请在部署主机依次执行以下命令:保存停止意图、将副本数设为零、检查游戏 Pod。命名空间不是 minecraft 时须替换命令中的命名空间。",
  "host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。"
  "host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。",
  "log_show_rcon": "显示 RCON 连接日志",
  "log_hide_rcon": "隐藏 RCON 连接日志",
  "log_rcon_only": "RCON 连接生命周期日志已隐藏。其他日志将在产生后显示。"
}
+1 −1
Changes for panel/src/lib/logstream.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -112,7 +112,7 @@ const DEFAULT_MAX_LINES = 2000;
 * "plain" and renders in the default colour.
 */
export function classifyLogLine(line: string): LogLevel {
  const m = line.match(/[/[](SEVERE|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\]/);
  const m = line.match(/(?:[/[]|^\[\d{2}:\d{2}:\d{2} )(SEVERE|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\]/);
  if (!m) return "plain";
  switch (m[1]) {
    case "SEVERE":
Loading