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) && ( + + )} {status === "ended" && (