Loading panel/dev/mockApi.ts +10 −0 Changes for panel/dev/mockApi.ts: 10 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1629,6 +1629,10 @@ function streamBuildLogs( Connection: "keep-alive", }); res.write(": connected\n\n"); // A modpack boot floods the console; the burst exercises the frame batching. for (let n = 0; n < 400; n++) { res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\n\n`); } const timer = setInterval(() => { if (i < lines.length) { Loading Loading @@ -2129,6 +2133,8 @@ function streamConsole( "[12:00:02] [Server thread/WARN]: Mock world uses in-memory state only", "[12:00:03] [Server thread/INFO]: Preparing spawn area: 100%", "[12:00:04] [Server thread/INFO]: Done (4.123s)! For help, type \"help\"", "[12:00:05] [Server thread/INFO]: [Essentials] §aReady§r — §6§lwelcome§r to §x§5§5§c§c§f§fFelis", "[12:00:06] [Server thread/WARN]: \x1b[33mTPS dropped to 18.2\x1b[0m", ]; let i = 0; Loading @@ -2138,6 +2144,10 @@ function streamConsole( Connection: "keep-alive", }); res.write(": connected\n\n"); // A modpack boot floods the console; the burst exercises the frame batching. for (let n = 0; n < 400; n++) { res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\n\n`); } const timer = setInterval(() => { res.write(`data: ${lines[i % lines.length]}\n\n`); Loading panel/src/components/LogConsole.test.tsx 0 → 100644 +106 −0 Changes for panel/src/components/LogConsole.test.tsx: 106 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import i18next from "i18next"; import { LogConsole } from "./LogConsole"; const tier = vi.hoisted(() => ({ revalidate: () => Promise.resolve() })); vi.mock("@/lib/tier", () => ({ useTier: () => tier })); // sameChunk is wrapped so a test can see which chunks React skipped. const spies = vi.hoisted(() => ({ sameChunk: null as unknown as ReturnType<typeof vi.fn> })); vi.mock("@/lib/logchunks", async (importActual) => { const actual = await importActual<typeof import("@/lib/logchunks")>(); spies.sameChunk = vi.fn(actual.sameChunk); return { ...actual, sameChunk: spies.sameChunk }; }); // FakeEventSource replaces the browser one: jsdom has none. class FakeEventSource { static last: FakeEventSource | null = null; onopen: ((ev: unknown) => void) | null = null; onmessage: ((ev: { data: string }) => void) | null = null; onerror: ((ev: unknown) => void) | null = null; readyState = 0; constructor() { FakeEventSource.last = this; } close() { this.readyState = 2; } addEventListener() {} } // send delivers lines and waits past the frame that commits them. async function send(...lines: string[]) { await act(async () => { for (const line of lines) FakeEventSource.last!.onmessage?.({ data: line }); await new Promise((resolve) => setTimeout(resolve, 40)); }); } const range = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => `line ${from + i}`); const lineRows = (container: HTMLElement) => container.querySelectorAll(".whitespace-pre-wrap"); beforeEach(() => { vi.stubGlobal("EventSource", FakeEventSource); spies.sameChunk.mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("LogConsole", () => { it("renders colour codes as styled text and never as markup", async () => { const { container } = render(<LogConsole url="/c" />); await send("§c<b>boom</b> §lloud§r plain"); const red = screen.getByText("<b>boom</b>", { exact: false }); expect(red.tagName).toBe("SPAN"); expect(red.style.color).toBe("rgb(255, 85, 85)"); expect(screen.getByText("loud").style.fontWeight).toBe("700"); expect(container.querySelector("b")).toBeNull(); expect(container.textContent).not.toContain("§"); }); 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)); expect(screen.getByText("line 149")).toBeTruthy(); await send(...range(150, 160)); expect(screen.getByText("line 159")).toBeTruthy(); expect(lineRows(container)).toHaveLength(160); fireEvent.click(screen.getByRole("button", { name: i18next.t("servers:log_clear_btn") })); await send("after clear"); expect(screen.getByText("after clear")).toBeTruthy(); expect(screen.queryByText("line 159")).toBeNull(); expect(lineRows(container)).toHaveLength(1); }); it("drops the oldest lines from the first chunk as the buffer trims", async () => { const { container } = render(<LogConsole url="/c" />); await send(...range(0, 2000)); expect(screen.getByText("line 0")).toBeTruthy(); await send(...range(2000, 2050)); expect(screen.queryByText("line 0")).toBeNull(); expect(screen.queryByText("line 49")).toBeNull(); expect(screen.getByText("line 50")).toBeTruthy(); expect(lineRows(container)).toHaveLength(2000); }); it("re-renders only the chunk a new line lands in", async () => { render(<LogConsole url="/c" />); await send(...range(0, 250)); spies.sameChunk.mockClear(); await send("line 250"); expect(screen.getByText("line 250")).toBeTruthy(); const verdicts = spies.sameChunk.mock.results.map((r) => r.value); expect(verdicts.filter((same) => same === true)).toHaveLength(2); expect(verdicts.filter((same) => same === false)).toHaveLength(1); }); }); panel/src/components/LogConsole.tsx +39 −8 Changes for panel/src/components/LogConsole.tsx: 39 added lines, 8 removed lines. Original line number Diff line number Diff line import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { ArrowDown, RotateCw, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useLogStream } from "@/lib/useLogStream"; import type { LogLevel, StreamStatus } from "@/lib/logstream"; import { chunkLines, sameChunk } from "@/lib/logchunks"; import type { LogLevel, LogLine, StreamStatus } from "@/lib/logstream"; import type { 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 Loading @@ -19,6 +21,37 @@ const LEVEL_CLASS: Record<LogLevel, string> = { const PIN_THRESHOLD_PX = 24; function segmentStyle(s: Segment): CSSProperties | undefined { const decoration = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" "); if (!s.color && !s.bold && !s.italic && !decoration) return undefined; return { color: s.color, fontWeight: s.bold ? 700 : undefined, fontStyle: s.italic ? "italic" : undefined, textDecorationLine: decoration || undefined, }; } // 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[] }) { return ( <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"} </div> ))} </div> ); }, sameChunk); function StatusIndicator({ status }: { status: StreamStatus }) { const { t } = useTranslation("servers"); const map: Record<StreamStatus, { dot: string; label: string; text: string }> = { Loading @@ -45,11 +78,13 @@ function StatusIndicator({ status }: { status: StreamStatus }) { * * UX follows modern log viewers: it follows the tail, but if you scroll up to * read history it stops yanking you down and offers a "Jump to latest" pill; * scrolling back to the bottom re-pins. The buffer is bounded by the controller. * scrolling back to the bottom re-pins. The buffer is bounded by the controller, * which also batches lines to one update per frame. */ export function LogConsole({ url, className }: { url: string; className?: string }) { const { t } = useTranslation("servers"); const { lines, status, clear, reconnect } = useLogStream(url); const chunks = useMemo(() => chunkLines(lines), [lines]); const scrollRef = useRef<HTMLDivElement>(null); const [pinned, setPinned] = useState(true); Loading Loading @@ -113,11 +148,7 @@ export function LogConsole({ url, className }: { url: string; className?: string : t("log_waiting")} </p> ) : ( lines.map((line) => ( <div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}> {line.text || "\u00A0"} </div> )) chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />) )} </div> Loading panel/src/lib/logchunks.ts 0 → 100644 +28 −0 Changes for panel/src/lib/logchunks.ts: 28 added lines, 0 removed lines. Original line number Diff line number Diff line import type { LogLine } from "./logstream"; // Lines render in chunks of CHUNK_LINES, grouped by seq so a line never moves // between chunks. A full chunk never changes again, so on each frame React // re-renders only the tail chunk (and the head one while the ring buffer // trims), whatever the buffer holds. Chunks off screen skip layout and paint // through content-visibility. The DOM keeps every line, unlike a virtualised // list, so find-in-page and selecting across lines to copy both keep working. export const CHUNK_LINES = 100; /** chunkLines groups a buffer into chunks keyed by seq / CHUNK_LINES. */ export function chunkLines(lines: LogLine[]): { key: number; lines: LogLine[] }[] { const chunks: { key: number; lines: LogLine[] }[] = []; for (const line of lines) { const key = Math.floor(line.seq / CHUNK_LINES); const last = chunks[chunks.length - 1]; if (last?.key === key) last.lines.push(line); else chunks.push({ key, lines: [line] }); } return chunks; } /** sameChunk: lines are immutable and a chunk's seqs are consecutive, so the * same first and last line mean the same content. The first changes when the * ring buffer trims the chunk, the last when a line lands in it. */ export function sameChunk(a: { lines: LogLine[] }, b: { lines: LogLine[] }): boolean { return a.lines[0] === b.lines[0] && a.lines[a.lines.length - 1] === b.lines[b.lines.length - 1]; } panel/src/lib/logstream.test.ts +130 −22 Changes for panel/src/lib/logstream.test.ts: 130 added lines, 22 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import { classifyLogLine, type EventSourceFactory, type EventSourceLike, type FrameScheduler, } from "./logstream"; // FakeEventSource drives the controller without a browser: Node has no Loading Loading @@ -50,14 +51,34 @@ class FakeEventSource implements EventSourceLike { } } function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[] } { // ManualFrames stands in for requestAnimationFrame: queued callbacks run only // when the test says a frame has passed. class ManualFrames { private queue: Array<() => void> = []; schedule: FrameScheduler = (cb) => { this.queue.push(cb); return () => { this.queue = this.queue.filter((f) => f !== cb); }; }; get queued(): number { return this.queue.length; } run(): void { const due = this.queue; this.queue = []; due.forEach((f) => f()); } } function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[]; frames: ManualFrames } { const created: FakeEventSource[] = []; const factory: EventSourceFactory = (url) => { const es = new FakeEventSource(url); created.push(es); return es; }; return { factory, created }; return { factory, created, frames: new ManualFrames() }; } describe("classifyLogLine", () => { Loading @@ -84,8 +105,8 @@ describe("classifyLogLine", () => { describe("LogStreamController", () => { it("transitions connecting -> open and appends classified lines", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule }); ctrl.open(); expect(created).toHaveLength(1); expect(created[0].url).toBe("/api/v1/servers/s/console"); Loading @@ -95,6 +116,7 @@ describe("LogStreamController", () => { expect(ctrl.getSnapshot().status).toBe("open"); created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up"); frames.run(); const { lines } = ctrl.getSnapshot(); expect(lines).toHaveLength(1); expect(lines[0]).toMatchObject({ seq: 0, level: "warn" }); Loading @@ -102,12 +124,13 @@ describe("LogStreamController", () => { }); it("bounds the ring buffer, dropping the oldest lines", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2 }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2, frame: frames.schedule }); ctrl.open(); created[0].emitMessage("line0"); created[0].emitMessage("line1"); created[0].emitMessage("line2"); frames.run(); const { lines } = ctrl.getSnapshot(); expect(lines).toHaveLength(2); // Oldest dropped; the survivors keep their monotonic seq (1 then 2). Loading @@ -116,34 +139,38 @@ describe("LogStreamController", () => { }); it("returns a stable snapshot reference between mutations", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); // status already 'connecting' -> no commit, snapshot stable const s1 = ctrl.getSnapshot(); expect(ctrl.getSnapshot()).toBe(s1); created[0].emitMessage("x"); expect(ctrl.getSnapshot()).toBe(s1); // queued until the frame frames.run(); expect(ctrl.getSnapshot()).not.toBe(s1); }); it("notifies subscribers on mutation and stops after unsubscribe", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); let hits = 0; const unsub = ctrl.subscribe(() => { hits++; }); created[0].emitOpen(); // commit created[0].emitMessage("a"); // commit created[0].emitMessage("a"); frames.run(); // commit expect(hits).toBe(2); unsub(); created[0].emitMessage("b"); frames.run(); expect(hits).toBe(2); // no further notifications }); it("maps a transient drop to reconnecting and a fatal close to ended", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); created[0].emitError(0); // readyState CONNECTING -> auto-retrying Loading @@ -153,8 +180,8 @@ describe("LogStreamController", () => { }); it("ignores events after close (teardown linchpin)", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); ctrl.close(); Loading @@ -167,8 +194,8 @@ describe("LogStreamController", () => { }); it("reconnect closes the old stream and dials a fresh one", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitError(2); // ended ctrl.reconnect(); Loading @@ -180,27 +207,108 @@ describe("LogStreamController", () => { }); it("clear empties the buffer without disturbing status", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); created[0].emitMessage("a"); frames.run(); created[0].emitMessage("b"); // still queued ctrl.clear(); frames.run(); expect(ctrl.getSnapshot().lines).toHaveLength(0); expect(ctrl.getSnapshot().status).toBe("open"); }); it("ends for good when the server revokes the stream", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule }); ctrl.open(); const es = created[0]; es.emitOpen(); es.emitMessage("[12:00:00] [Server thread/INFO]: hello"); es.emitEvent("revoked"); es.emitEvent("revoked"); // no frame ran: the status change carries the line expect(ctrl.getSnapshot().status).toBe("ended"); expect(es.closed).toBe(true); expect(ctrl.getSnapshot().lines).toHaveLength(1); expect(created).toHaveLength(1); }); it("delivers a burst of lines in one commit per frame", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); let hits = 0; ctrl.subscribe(() => hits++); for (let i = 0; i < 500; i++) created[0].emitMessage(`mod ${i}`); expect(hits).toBe(0); expect(frames.queued).toBe(1); frames.run(); expect(hits).toBe(1); expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual( Array.from({ length: 500 }, (_, i) => `mod ${i}`), ); created[0].emitMessage("next"); expect(frames.queued).toBe(1); frames.run(); expect(hits).toBe(2); expect(ctrl.getSnapshot().lines.at(-1)?.seq).toBe(500); }); it("keeps the queue bounded while no frame runs, as in a background tab", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 3, frame: frames.schedule }); ctrl.open(); for (let i = 0; i < 100; i++) created[0].emitMessage(`line${i}`); expect((ctrl as unknown as { pending: unknown[] }).pending.length).toBeLessThan(6); frames.run(); expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(["line97", "line98", "line99"]); }); it("shows queued lines together with a status change", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); let hits = 0; ctrl.subscribe(() => hits++); created[0].emitMessage("Stopping server"); created[0].emitMessage("Saving chunks"); created[0].emitError(2); expect(hits).toBe(1); expect(ctrl.getSnapshot().status).toBe("ended"); expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(["Stopping server", "Saving chunks"]); expect(frames.queued).toBe(0); }); it("drops the queued frame on close", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitMessage("late"); ctrl.close(); expect(frames.queued).toBe(0); frames.run(); expect(ctrl.getSnapshot().lines).toHaveLength(0); }); it("strips colour codes before classifying and keeps them as segments", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitMessage("[12:00:00] [Server thread/§cERROR§r]: §eplugin§r failed"); created[0].emitMessage("[12:00:01] [Server thread/INFO]: plain"); frames.run(); const [colored, plain] = ctrl.getSnapshot().lines; expect(colored.text).toBe("[12:00:00] [Server thread/ERROR]: plugin failed"); expect(colored.level).toBe("error"); expect(colored.segments?.find((s) => s.text === "plugin")?.color).toBe("#ffff55"); expect(plain.segments).toBeUndefined(); }); }); Loading
panel/dev/mockApi.ts +10 −0 Changes for panel/dev/mockApi.ts: 10 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1629,6 +1629,10 @@ function streamBuildLogs( Connection: "keep-alive", }); res.write(": connected\n\n"); // A modpack boot floods the console; the burst exercises the frame batching. for (let n = 0; n < 400; n++) { res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\n\n`); } const timer = setInterval(() => { if (i < lines.length) { Loading Loading @@ -2129,6 +2133,8 @@ function streamConsole( "[12:00:02] [Server thread/WARN]: Mock world uses in-memory state only", "[12:00:03] [Server thread/INFO]: Preparing spawn area: 100%", "[12:00:04] [Server thread/INFO]: Done (4.123s)! For help, type \"help\"", "[12:00:05] [Server thread/INFO]: [Essentials] §aReady§r — §6§lwelcome§r to §x§5§5§c§c§f§fFelis", "[12:00:06] [Server thread/WARN]: \x1b[33mTPS dropped to 18.2\x1b[0m", ]; let i = 0; Loading @@ -2138,6 +2144,10 @@ function streamConsole( Connection: "keep-alive", }); res.write(": connected\n\n"); // A modpack boot floods the console; the burst exercises the frame batching. for (let n = 0; n < 400; n++) { res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\n\n`); } const timer = setInterval(() => { res.write(`data: ${lines[i % lines.length]}\n\n`); Loading
panel/src/components/LogConsole.test.tsx 0 → 100644 +106 −0 Changes for panel/src/components/LogConsole.test.tsx: 106 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import i18next from "i18next"; import { LogConsole } from "./LogConsole"; const tier = vi.hoisted(() => ({ revalidate: () => Promise.resolve() })); vi.mock("@/lib/tier", () => ({ useTier: () => tier })); // sameChunk is wrapped so a test can see which chunks React skipped. const spies = vi.hoisted(() => ({ sameChunk: null as unknown as ReturnType<typeof vi.fn> })); vi.mock("@/lib/logchunks", async (importActual) => { const actual = await importActual<typeof import("@/lib/logchunks")>(); spies.sameChunk = vi.fn(actual.sameChunk); return { ...actual, sameChunk: spies.sameChunk }; }); // FakeEventSource replaces the browser one: jsdom has none. class FakeEventSource { static last: FakeEventSource | null = null; onopen: ((ev: unknown) => void) | null = null; onmessage: ((ev: { data: string }) => void) | null = null; onerror: ((ev: unknown) => void) | null = null; readyState = 0; constructor() { FakeEventSource.last = this; } close() { this.readyState = 2; } addEventListener() {} } // send delivers lines and waits past the frame that commits them. async function send(...lines: string[]) { await act(async () => { for (const line of lines) FakeEventSource.last!.onmessage?.({ data: line }); await new Promise((resolve) => setTimeout(resolve, 40)); }); } const range = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => `line ${from + i}`); const lineRows = (container: HTMLElement) => container.querySelectorAll(".whitespace-pre-wrap"); beforeEach(() => { vi.stubGlobal("EventSource", FakeEventSource); spies.sameChunk.mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("LogConsole", () => { it("renders colour codes as styled text and never as markup", async () => { const { container } = render(<LogConsole url="/c" />); await send("§c<b>boom</b> §lloud§r plain"); const red = screen.getByText("<b>boom</b>", { exact: false }); expect(red.tagName).toBe("SPAN"); expect(red.style.color).toBe("rgb(255, 85, 85)"); expect(screen.getByText("loud").style.fontWeight).toBe("700"); expect(container.querySelector("b")).toBeNull(); expect(container.textContent).not.toContain("§"); }); 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)); expect(screen.getByText("line 149")).toBeTruthy(); await send(...range(150, 160)); expect(screen.getByText("line 159")).toBeTruthy(); expect(lineRows(container)).toHaveLength(160); fireEvent.click(screen.getByRole("button", { name: i18next.t("servers:log_clear_btn") })); await send("after clear"); expect(screen.getByText("after clear")).toBeTruthy(); expect(screen.queryByText("line 159")).toBeNull(); expect(lineRows(container)).toHaveLength(1); }); it("drops the oldest lines from the first chunk as the buffer trims", async () => { const { container } = render(<LogConsole url="/c" />); await send(...range(0, 2000)); expect(screen.getByText("line 0")).toBeTruthy(); await send(...range(2000, 2050)); expect(screen.queryByText("line 0")).toBeNull(); expect(screen.queryByText("line 49")).toBeNull(); expect(screen.getByText("line 50")).toBeTruthy(); expect(lineRows(container)).toHaveLength(2000); }); it("re-renders only the chunk a new line lands in", async () => { render(<LogConsole url="/c" />); await send(...range(0, 250)); spies.sameChunk.mockClear(); await send("line 250"); expect(screen.getByText("line 250")).toBeTruthy(); const verdicts = spies.sameChunk.mock.results.map((r) => r.value); expect(verdicts.filter((same) => same === true)).toHaveLength(2); expect(verdicts.filter((same) => same === false)).toHaveLength(1); }); });
panel/src/components/LogConsole.tsx +39 −8 Changes for panel/src/components/LogConsole.tsx: 39 added lines, 8 removed lines. Original line number Diff line number Diff line import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { ArrowDown, RotateCw, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useLogStream } from "@/lib/useLogStream"; import type { LogLevel, StreamStatus } from "@/lib/logstream"; import { chunkLines, sameChunk } from "@/lib/logchunks"; import type { LogLevel, LogLine, StreamStatus } from "@/lib/logstream"; import type { 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 Loading @@ -19,6 +21,37 @@ const LEVEL_CLASS: Record<LogLevel, string> = { const PIN_THRESHOLD_PX = 24; function segmentStyle(s: Segment): CSSProperties | undefined { const decoration = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" "); if (!s.color && !s.bold && !s.italic && !decoration) return undefined; return { color: s.color, fontWeight: s.bold ? 700 : undefined, fontStyle: s.italic ? "italic" : undefined, textDecorationLine: decoration || undefined, }; } // 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[] }) { return ( <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"} </div> ))} </div> ); }, sameChunk); function StatusIndicator({ status }: { status: StreamStatus }) { const { t } = useTranslation("servers"); const map: Record<StreamStatus, { dot: string; label: string; text: string }> = { Loading @@ -45,11 +78,13 @@ function StatusIndicator({ status }: { status: StreamStatus }) { * * UX follows modern log viewers: it follows the tail, but if you scroll up to * read history it stops yanking you down and offers a "Jump to latest" pill; * scrolling back to the bottom re-pins. The buffer is bounded by the controller. * scrolling back to the bottom re-pins. The buffer is bounded by the controller, * which also batches lines to one update per frame. */ export function LogConsole({ url, className }: { url: string; className?: string }) { const { t } = useTranslation("servers"); const { lines, status, clear, reconnect } = useLogStream(url); const chunks = useMemo(() => chunkLines(lines), [lines]); const scrollRef = useRef<HTMLDivElement>(null); const [pinned, setPinned] = useState(true); Loading Loading @@ -113,11 +148,7 @@ export function LogConsole({ url, className }: { url: string; className?: string : t("log_waiting")} </p> ) : ( lines.map((line) => ( <div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}> {line.text || "\u00A0"} </div> )) chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />) )} </div> Loading
panel/src/lib/logchunks.ts 0 → 100644 +28 −0 Changes for panel/src/lib/logchunks.ts: 28 added lines, 0 removed lines. Original line number Diff line number Diff line import type { LogLine } from "./logstream"; // Lines render in chunks of CHUNK_LINES, grouped by seq so a line never moves // between chunks. A full chunk never changes again, so on each frame React // re-renders only the tail chunk (and the head one while the ring buffer // trims), whatever the buffer holds. Chunks off screen skip layout and paint // through content-visibility. The DOM keeps every line, unlike a virtualised // list, so find-in-page and selecting across lines to copy both keep working. export const CHUNK_LINES = 100; /** chunkLines groups a buffer into chunks keyed by seq / CHUNK_LINES. */ export function chunkLines(lines: LogLine[]): { key: number; lines: LogLine[] }[] { const chunks: { key: number; lines: LogLine[] }[] = []; for (const line of lines) { const key = Math.floor(line.seq / CHUNK_LINES); const last = chunks[chunks.length - 1]; if (last?.key === key) last.lines.push(line); else chunks.push({ key, lines: [line] }); } return chunks; } /** sameChunk: lines are immutable and a chunk's seqs are consecutive, so the * same first and last line mean the same content. The first changes when the * ring buffer trims the chunk, the last when a line lands in it. */ export function sameChunk(a: { lines: LogLine[] }, b: { lines: LogLine[] }): boolean { return a.lines[0] === b.lines[0] && a.lines[a.lines.length - 1] === b.lines[b.lines.length - 1]; }
panel/src/lib/logstream.test.ts +130 −22 Changes for panel/src/lib/logstream.test.ts: 130 added lines, 22 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import { classifyLogLine, type EventSourceFactory, type EventSourceLike, type FrameScheduler, } from "./logstream"; // FakeEventSource drives the controller without a browser: Node has no Loading Loading @@ -50,14 +51,34 @@ class FakeEventSource implements EventSourceLike { } } function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[] } { // ManualFrames stands in for requestAnimationFrame: queued callbacks run only // when the test says a frame has passed. class ManualFrames { private queue: Array<() => void> = []; schedule: FrameScheduler = (cb) => { this.queue.push(cb); return () => { this.queue = this.queue.filter((f) => f !== cb); }; }; get queued(): number { return this.queue.length; } run(): void { const due = this.queue; this.queue = []; due.forEach((f) => f()); } } function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[]; frames: ManualFrames } { const created: FakeEventSource[] = []; const factory: EventSourceFactory = (url) => { const es = new FakeEventSource(url); created.push(es); return es; }; return { factory, created }; return { factory, created, frames: new ManualFrames() }; } describe("classifyLogLine", () => { Loading @@ -84,8 +105,8 @@ describe("classifyLogLine", () => { describe("LogStreamController", () => { it("transitions connecting -> open and appends classified lines", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule }); ctrl.open(); expect(created).toHaveLength(1); expect(created[0].url).toBe("/api/v1/servers/s/console"); Loading @@ -95,6 +116,7 @@ describe("LogStreamController", () => { expect(ctrl.getSnapshot().status).toBe("open"); created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up"); frames.run(); const { lines } = ctrl.getSnapshot(); expect(lines).toHaveLength(1); expect(lines[0]).toMatchObject({ seq: 0, level: "warn" }); Loading @@ -102,12 +124,13 @@ describe("LogStreamController", () => { }); it("bounds the ring buffer, dropping the oldest lines", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2 }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2, frame: frames.schedule }); ctrl.open(); created[0].emitMessage("line0"); created[0].emitMessage("line1"); created[0].emitMessage("line2"); frames.run(); const { lines } = ctrl.getSnapshot(); expect(lines).toHaveLength(2); // Oldest dropped; the survivors keep their monotonic seq (1 then 2). Loading @@ -116,34 +139,38 @@ describe("LogStreamController", () => { }); it("returns a stable snapshot reference between mutations", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); // status already 'connecting' -> no commit, snapshot stable const s1 = ctrl.getSnapshot(); expect(ctrl.getSnapshot()).toBe(s1); created[0].emitMessage("x"); expect(ctrl.getSnapshot()).toBe(s1); // queued until the frame frames.run(); expect(ctrl.getSnapshot()).not.toBe(s1); }); it("notifies subscribers on mutation and stops after unsubscribe", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); let hits = 0; const unsub = ctrl.subscribe(() => { hits++; }); created[0].emitOpen(); // commit created[0].emitMessage("a"); // commit created[0].emitMessage("a"); frames.run(); // commit expect(hits).toBe(2); unsub(); created[0].emitMessage("b"); frames.run(); expect(hits).toBe(2); // no further notifications }); it("maps a transient drop to reconnecting and a fatal close to ended", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); created[0].emitError(0); // readyState CONNECTING -> auto-retrying Loading @@ -153,8 +180,8 @@ describe("LogStreamController", () => { }); it("ignores events after close (teardown linchpin)", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); ctrl.close(); Loading @@ -167,8 +194,8 @@ describe("LogStreamController", () => { }); it("reconnect closes the old stream and dials a fresh one", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitError(2); // ended ctrl.reconnect(); Loading @@ -180,27 +207,108 @@ describe("LogStreamController", () => { }); it("clear empties the buffer without disturbing status", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); created[0].emitMessage("a"); frames.run(); created[0].emitMessage("b"); // still queued ctrl.clear(); frames.run(); expect(ctrl.getSnapshot().lines).toHaveLength(0); expect(ctrl.getSnapshot().status).toBe("open"); }); it("ends for good when the server revokes the stream", () => { const { factory, created } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory }); const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule }); ctrl.open(); const es = created[0]; es.emitOpen(); es.emitMessage("[12:00:00] [Server thread/INFO]: hello"); es.emitEvent("revoked"); es.emitEvent("revoked"); // no frame ran: the status change carries the line expect(ctrl.getSnapshot().status).toBe("ended"); expect(es.closed).toBe(true); expect(ctrl.getSnapshot().lines).toHaveLength(1); expect(created).toHaveLength(1); }); it("delivers a burst of lines in one commit per frame", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); let hits = 0; ctrl.subscribe(() => hits++); for (let i = 0; i < 500; i++) created[0].emitMessage(`mod ${i}`); expect(hits).toBe(0); expect(frames.queued).toBe(1); frames.run(); expect(hits).toBe(1); expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual( Array.from({ length: 500 }, (_, i) => `mod ${i}`), ); created[0].emitMessage("next"); expect(frames.queued).toBe(1); frames.run(); expect(hits).toBe(2); expect(ctrl.getSnapshot().lines.at(-1)?.seq).toBe(500); }); it("keeps the queue bounded while no frame runs, as in a background tab", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 3, frame: frames.schedule }); ctrl.open(); for (let i = 0; i < 100; i++) created[0].emitMessage(`line${i}`); expect((ctrl as unknown as { pending: unknown[] }).pending.length).toBeLessThan(6); frames.run(); expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(["line97", "line98", "line99"]); }); it("shows queued lines together with a status change", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitOpen(); let hits = 0; ctrl.subscribe(() => hits++); created[0].emitMessage("Stopping server"); created[0].emitMessage("Saving chunks"); created[0].emitError(2); expect(hits).toBe(1); expect(ctrl.getSnapshot().status).toBe("ended"); expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(["Stopping server", "Saving chunks"]); expect(frames.queued).toBe(0); }); it("drops the queued frame on close", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitMessage("late"); ctrl.close(); expect(frames.queued).toBe(0); frames.run(); expect(ctrl.getSnapshot().lines).toHaveLength(0); }); it("strips colour codes before classifying and keeps them as segments", () => { const { factory, created, frames } = makeFactory(); const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule }); ctrl.open(); created[0].emitMessage("[12:00:00] [Server thread/§cERROR§r]: §eplugin§r failed"); created[0].emitMessage("[12:00:01] [Server thread/INFO]: plain"); frames.run(); const [colored, plain] = ctrl.getSnapshot().lines; expect(colored.text).toBe("[12:00:00] [Server thread/ERROR]: plugin failed"); expect(colored.level).toBe("error"); expect(colored.segments?.find((s) => s.text === "plugin")?.color).toBe("#ffff55"); expect(plain.segments).toBeUndefined(); }); });