diff --git a/panel/src/components/LogConsole.test.tsx b/panel/src/components/LogConsole.test.tsx index c56fc21..43e05b7 100644 --- a/panel/src/components/LogConsole.test.tsx +++ b/panel/src/components/LogConsole.test.tsx @@ -64,6 +64,37 @@ describe("LogConsole", () => { expect(container.textContent).not.toContain("§"); }); + it("hides only RCON connection lifecycle notices and can reveal them", async () => { + render(); + 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]: 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(); + 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(); await send(...range(0, 150)); diff --git a/panel/src/components/LogConsole.tsx b/panel/src/components/LogConsole.tsx index 6a0bbd3..a47fac9 100644 --- a/panel/src/components/LogConsole.tsx +++ b/panel/src/components/LogConsole.tsx @@ -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) => {segment.text}) + : 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[] }) { {lines.map((line) => ( - {line.segments - ? line.segments.map((s, i) => ( - - {s.text} - - )) - : line.text || "\u00A0"} + ))} @@ -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(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; + {(hiddenCount > 0 || showRcon) && ( + 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 && ({hiddenCount})} + + )} {status === "ended" && ( - {lines.length === 0 ? ( + {visibleLines.length === 0 ? ( {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")} ) : ( chunks.map((chunk) => ) diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 5753609..78541bb 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -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." } diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 986d61e..faa34f8 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -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 连接生命周期日志已隐藏。其他日志将在产生后显示。" } diff --git a/panel/src/lib/logstream.ts b/panel/src/lib/logstream.ts index 9f052a3..e74e3d6 100644 --- a/panel/src/lib/logstream.ts +++ b/panel/src/lib/logstream.ts @@ -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": diff --git a/panel/src/pages/ServerConsole.test.tsx b/panel/src/pages/ServerConsole.test.tsx index b59b72a..3d54897 100644 --- a/panel/src/pages/ServerConsole.test.tsx +++ b/panel/src/pages/ServerConsole.test.tsx @@ -27,7 +27,10 @@ vi.mock("@/lib/api", async (importActual) => { return { ...actual, api: { ...actual.api, ...calls } }; }); // The live log is a WebSocket; here it only matters whether the page shows it. -vi.mock("@/components/LogConsole", () => ({ LogConsole: () => })); +vi.mock("@/components/LogConsole", async (importActual) => ({ + ...await importActual(), + LogConsole: () => , +})); beforeEach(() => { tier.isAdmin = true; @@ -268,6 +271,21 @@ describe("ServerConsole command line", () => { expect(box.value).toBe(""); }); + it("renders Minecraft and ANSI formatting in command replies without interpreting markup", async () => { + calls.sendCommand.mockResolvedValue({ output: "§eHelp: §fIndex §lBold§r\n§x§1§2§3§4§5§6Hex\n\x1b[31munsafe\x1b[0m" }); + const box = await commandLine(); + await userEvent.type(box, "help{Enter}"); + const help = await screen.findByText("Help:"); + expect(help.style.color).toBe("rgb(255, 255, 85)"); + expect(screen.getByText("Index").style.color).toBe("rgb(255, 255, 255)"); + expect(screen.getByText("Bold").style.fontWeight).toBe("700"); + expect(screen.getByText("Hex").style.color).toBe("rgb(18, 52, 86)"); + const unsafe = screen.getByText("unsafe"); + expect(unsafe.style.color).toBe("rgb(205, 49, 49)"); + expect(unsafe.closest("pre")?.querySelector("b")).toBeNull(); + expect(unsafe.closest("pre")?.textContent).not.toMatch(/§|\x1b/); + }); + it("sends nothing for a blank line", async () => { const user = userEvent.setup(); const box = await commandLine(); diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 9fdebd7..af39fb2 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -7,7 +7,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { BackLink } from "@/components/BackLink"; import { MAX_AUTO_RESTARTS, PhaseBadge, pendingPower, shownPhase, startFailure, type StartFailure } from "@/components/PhaseBadge"; import { PageHeader } from "@/components/PageHeader"; -import { LogConsole } from "@/components/LogConsole"; +import { FormattedText, LogConsole } from "@/components/LogConsole"; import { Loading, ErrorState } from "@/components/States"; import { api, consoleStreamURL, humanizeError } from "@/lib/api"; import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks"; @@ -19,6 +19,7 @@ import type { Phase, AutostartPolicy } from "@/lib/types"; import { EditServerDialog } from "@/components/EditServerDialog"; import { PowerButton } from "@/components/PowerButton"; import { RetireCard, RetireNotice } from "@/components/Retirement"; +import { parseFormatting, type Formatted } from "@/lib/mcformat"; import { cn } from "@/lib/utils"; import { InlineError, MessageLine } from "@/components/MessageLine"; @@ -122,7 +123,7 @@ 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. + * sends an RCON command to the running server and displays its formatted 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 }) { @@ -133,7 +134,7 @@ function CommandInput({ name }: { name: string }) { // The last command's echo + the server's reply, rendered above the prompt the // way a terminal does. The reply IS the result of a write: RCON returns text // only, so dropping it would leave the user with no way to see what happened. - const [last, setLast] = useState<{ cmd: string; out: string } | null>(null); + const [last, setLast] = useState<{ cmd: string; out: Formatted } | null>(null); const historyRef = useRef(loadHistory(name)); const cursorRef = useRef(-1); // What was typed before stepping up into the history. @@ -155,7 +156,7 @@ function CommandInput({ name }: { name: string }) { cursorRef.current = h.length; try { const res = await api.sendCommand(name, text); - setLast({ cmd: text, out: (res.output ?? "").trim() }); + setLast({ cmd: text, out: parseFormatting((res.output ?? "").trim()) }); } catch (ex) { setErr(humanizeError(ex)); } finally { @@ -203,7 +204,7 @@ function CommandInput({ name }: { name: string }) { {last && ( {"> " + last.cmd + "\n"} - {last.out} + )}
{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")}
{"> " + last.cmd + "\n"} - {last.out} +