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

This commit is contained in:
Lemon-miaow committed 2026-10-06 00:08:36 +08:00
1 parent df19878c2a
commit 9d2de72c31
7 files changed
+95 -21

No files matched your search

+31
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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":
+19 -1
View File
@@ -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: () => <div data-testid="log-stream" /> }));
vi.mock("@/components/LogConsole", async (importActual) => ({
...await importActual<typeof import("@/components/LogConsole")>(),
LogConsole: () => <div data-testid="log-stream" />,
}));
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[31m<b>unsafe</b>\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("<b>unsafe</b>");
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();
+6 -5
View File
@@ -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<string[]>(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 && (
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 font-mono text-xs text-zinc-300">
<span className="text-zinc-500">{"> " + last.cmd + "\n"}</span>
{last.out}
<FormattedText formatted={last.out} />
</pre>
)}
<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">