diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts
index bcb1f6b..6ede726 100644
--- a/panel/dev/mockApi.ts
+++ b/panel/dev/mockApi.ts
@@ -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) {
@@ -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;
@@ -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`);
diff --git a/panel/src/components/LogConsole.test.tsx b/panel/src/components/LogConsole.test.tsx
new file mode 100644
index 0000000..e96b90d
--- /dev/null
+++ b/panel/src/components/LogConsole.test.tsx
@@ -0,0 +1,106 @@
+// @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 }));
+vi.mock("@/lib/logchunks", async (importActual) => {
+ const actual = await importActual();
+ 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();
+ await send("§cboom §lloud§r plain");
+
+ const red = screen.getByText("boom", { 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();
+ 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();
+ 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();
+ 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);
+ });
+});
diff --git a/panel/src/components/LogConsole.tsx b/panel/src/components/LogConsole.tsx
index a3ccb97..a3a4f05 100644
--- a/panel/src/components/LogConsole.tsx
+++ b/panel/src/components/LogConsole.tsx
@@ -1,10 +1,12 @@
-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
@@ -19,6 +21,37 @@ const LEVEL_CLASS: Record = {
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 (
+
+ {lines.map((line) => (
+
+ {line.segments
+ ? line.segments.map((s, i) => (
+
+ {s.text}
+
+ ))
+ : line.text || "\u00A0"}
+
+ ))}
+
+ );
+}, sameChunk);
+
function StatusIndicator({ status }: { status: StreamStatus }) {
const { t } = useTranslation("servers");
const map: Record = {
@@ -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(null);
const [pinned, setPinned] = useState(true);
@@ -113,11 +148,7 @@ export function LogConsole({ url, className }: { url: string; className?: string
: t("log_waiting")}
) : (
- lines.map((line) => (
-
- {line.text || "\u00A0"}
-
- ))
+ chunks.map((chunk) => )
)}
diff --git a/panel/src/lib/logchunks.ts b/panel/src/lib/logchunks.ts
new file mode 100644
index 0000000..f99c27b
--- /dev/null
+++ b/panel/src/lib/logchunks.ts
@@ -0,0 +1,28 @@
+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];
+}
diff --git a/panel/src/lib/logstream.test.ts b/panel/src/lib/logstream.test.ts
index c01638c..6c113d8 100644
--- a/panel/src/lib/logstream.test.ts
+++ b/panel/src/lib/logstream.test.ts
@@ -4,6 +4,7 @@ import {
classifyLogLine,
type EventSourceFactory,
type EventSourceLike,
+ type FrameScheduler,
} from "./logstream";
// FakeEventSource drives the controller without a browser: Node has no
@@ -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", () => {
@@ -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");
@@ -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" });
@@ -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).
@@ -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
@@ -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();
@@ -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();
@@ -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();
+ });
});
diff --git a/panel/src/lib/logstream.ts b/panel/src/lib/logstream.ts
index d5a63f7..e9acaa0 100644
--- a/panel/src/lib/logstream.ts
+++ b/panel/src/lib/logstream.ts
@@ -7,9 +7,12 @@
//
// This module is deliberately framework-agnostic: the EventSource is injected
// (see EventSourceFactory) so the lifecycle — status transitions, the bounded
-// ring buffer, reconnect, teardown — is unit-tested under Node against a fake,
-// not left to a "tsc + build" green that only proves it compiles. The thin React
-// binding lives in useLogStream.ts; the viewport in components/LogConsole.tsx.
+// ring buffer, frame batching, reconnect, teardown — is unit-tested under Node
+// against a fake, not left to a "tsc + build" green that only proves it
+// compiles. The thin React binding lives in useLogStream.ts; the viewport in
+// components/LogConsole.tsx.
+
+import { parseFormatting, type Segment } from "./mcformat";
/** LogLevel is the coarse severity used only for tinting a line. */
export type LogLevel = "error" | "warn" | "info" | "debug" | "plain";
@@ -29,8 +32,11 @@ export type StreamStatus = "connecting" | "open" | "reconnecting" | "ended";
/** A single rendered log line. seq is a monotonic id for stable React keys. */
export interface LogLine {
seq: number;
+ /** The line without § or ANSI codes. */
text: string;
level: LogLevel;
+ /** Styled runs when the line carried colour codes (see mcformat.ts). */
+ segments?: Segment[];
}
/** Immutable snapshot consumed by useSyncExternalStore. */
@@ -65,11 +71,25 @@ export interface EventSourceLike {
/** Builds an EventSource for a URL (production: the browser ctor; tests: a fake). */
export type EventSourceFactory = (url: string) => EventSourceLike;
+/** Runs cb once before the next paint and returns a cancel (tests drive it by hand). */
+export type FrameScheduler = (cb: () => void) => () => void;
+
+const nextFrame: FrameScheduler = (cb) => {
+ if (typeof requestAnimationFrame === "function") {
+ const id = requestAnimationFrame(cb);
+ return () => cancelAnimationFrame(id);
+ }
+ const id = setTimeout(cb, 16);
+ return () => clearTimeout(id);
+};
+
export interface LogStreamOptions {
url: string;
factory: EventSourceFactory;
/** Ring-buffer bound; older lines are dropped past this (default 2000). */
maxLines?: number;
+ /** Defers a batch commit; requestAnimationFrame in the browser. */
+ frame?: FrameScheduler;
}
const DEFAULT_MAX_LINES = 2000;
@@ -114,6 +134,9 @@ export function classifyLogLine(line: string): LogLevel {
*
* - status transitions driven by EventSource events,
* - the bounded ring buffer (memory stays capped under a long live follow),
+ * - frame batching: a modpack boot logs hundreds of lines a second, so lines
+ * queue up and reach subscribers at most once per frame, one copy of the
+ * buffer per frame instead of one per line,
* - reconnect (manual, after a fatal `ended`),
* - teardown (close()) — the linchpin that the React effect's cleanup calls so
* a StrictMode dev double-mount, or a route change, never leaks a stream.
@@ -121,11 +144,15 @@ export function classifyLogLine(line: string): LogLevel {
export class LogStreamController {
private es: EventSourceLike | null = null;
private lines: LogLine[] = [];
+ // Lines that arrived since the last frame.
+ private pending: LogLine[] = [];
+ private cancelFrame: (() => void) | null = null;
private status: StreamStatus = "connecting";
private seq = 0;
private readonly url: string;
private readonly factory: EventSourceFactory;
private readonly maxLines: number;
+ private readonly frame: FrameScheduler;
private readonly listeners = new Set<() => void>();
// snapshot is rebuilt only inside commit(), so getSnapshot returns a stable
// reference between mutations — required by useSyncExternalStore to avoid an
@@ -136,6 +163,7 @@ export class LogStreamController {
this.url = opts.url;
this.factory = opts.factory;
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
+ this.frame = opts.frame ?? nextFrame;
this.snapshot = { lines: this.lines, status: this.status };
}
@@ -161,12 +189,14 @@ export class LogStreamController {
/** clear empties the buffer without touching the stream. */
clear(): void {
+ this.dropPending();
this.lines = [];
this.commit();
}
/** close tears the stream down for good. Idempotent. */
close(): void {
+ this.dropPending();
this.disconnect();
}
@@ -208,17 +238,44 @@ export class LogStreamController {
}
}
- private pushLine(text: string): void {
- const next = this.lines.concat({ seq: this.seq++, text, level: classifyLogLine(text) });
- if (next.length > this.maxLines) {
- next.splice(0, next.length - this.maxLines);
+ private pushLine(raw: string): void {
+ const { text, segments } = parseFormatting(raw);
+ const line: LogLine = { seq: this.seq++, text, level: classifyLogLine(text) };
+ if (segments) line.segments = segments;
+ this.pending.push(line);
+ // A background tab gets no frames, so the queue is bounded too. It trims
+ // only once it holds twice the buffer, keeping each push O(1) on average.
+ if (this.pending.length >= this.maxLines * 2) {
+ this.pending = this.pending.slice(-this.maxLines);
}
- this.lines = next;
- this.commit();
+ this.cancelFrame ??= this.frame(() => {
+ if (this.flushPending()) this.commit();
+ });
+ }
+
+ // flushPending moves the queued lines into the buffer and reports whether
+ // there were any. It also forgets the scheduled frame, whether it is the one
+ // running now or one a status change got ahead of.
+ private flushPending(): boolean {
+ this.cancelFrame?.();
+ this.cancelFrame = null;
+ if (this.pending.length === 0) return false;
+ const next = this.lines.concat(this.pending);
+ this.pending = [];
+ this.lines = next.length > this.maxLines ? next.slice(-this.maxLines) : next;
+ return true;
+ }
+
+ private dropPending(): void {
+ this.cancelFrame?.();
+ this.cancelFrame = null;
+ this.pending = [];
}
private setStatus(s: StreamStatus): void {
if (this.status === s) return;
+ // Lines that came before the change are shown with it, never after.
+ this.flushPending();
this.status = s;
this.commit();
}
diff --git a/panel/src/lib/mcformat.test.ts b/panel/src/lib/mcformat.test.ts
new file mode 100644
index 0000000..b94e3db
--- /dev/null
+++ b/panel/src/lib/mcformat.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect } from "vitest";
+import { parseFormatting } from "./mcformat";
+
+describe("parseFormatting", () => {
+ it("returns a plain line untouched, without segments", () => {
+ expect(parseFormatting("[12:00:00] [Server thread/INFO]: Done")).toEqual({
+ text: "[12:00:00] [Server thread/INFO]: Done",
+ });
+ });
+
+ it("colours text after a § colour code and resets on §r", () => {
+ expect(parseFormatting("§aReady§r now")).toEqual({
+ text: "Ready now",
+ segments: [
+ { text: "Ready", color: "#55ff55" },
+ { text: " now" },
+ ],
+ });
+ });
+
+ it("reads codes in either case and lifts black so it shows on the console", () => {
+ expect(parseFormatting("§Cred§0dark").segments).toEqual([
+ { text: "red", color: "#ff5555" },
+ { text: "dark", color: "#71717a" },
+ ]);
+ });
+
+ it("stacks formatting codes until a colour code clears them", () => {
+ expect(parseFormatting("§l§nTitle§6gold").segments).toEqual([
+ { text: "Title", bold: true, underline: true },
+ { text: "gold", color: "#ffaa00" },
+ ]);
+ expect(parseFormatting("§o§mold§r").segments).toEqual([{ text: "old", italic: true, strike: true }]);
+ });
+
+ it("keeps the colour when formatting is added after it", () => {
+ expect(parseFormatting("§b§lBold aqua").segments).toEqual([
+ { text: "Bold aqua", color: "#55ffff", bold: true },
+ ]);
+ });
+
+ it("reads the §x hex form", () => {
+ expect(parseFormatting("§x§F§f§8§8§0§0orange").segments).toEqual([
+ { text: "orange", color: "#ff8800" },
+ ]);
+ });
+
+ it("drops a broken §x, scrambled text and unknown codes without styling", () => {
+ expect(parseFormatting("§xhi")).toEqual({ text: "hi" });
+ expect(parseFormatting("§kmagic§z!")).toEqual({ text: "magic!" });
+ });
+
+ it("reads what follows a short §x as ordinary codes", () => {
+ expect(parseFormatting("§x§f§fhi").segments).toEqual([{ text: "hi", color: "#ffffff" }]);
+ });
+
+ it("drops a § at the end of a line", () => {
+ expect(parseFormatting("tail§")).toEqual({ text: "tail" });
+ });
+
+ it("omits segments when codes only reset", () => {
+ expect(parseFormatting("§rplain§r")).toEqual({ text: "plain" });
+ });
+
+ it("maps ANSI SGR colours, bright colours and resets", () => {
+ expect(parseFormatting("\x1b[31merror\x1b[0m ok \x1b[1;92mgreat\x1b[m done")).toEqual({
+ text: "error ok great done",
+ segments: [
+ { text: "error", color: "#cd3131" },
+ { text: " ok " },
+ { text: "great", color: "#23d18b", bold: true },
+ { text: " done" },
+ ],
+ });
+ });
+
+ it("maps 256-colour and true-colour escapes", () => {
+ expect(parseFormatting("\x1b[38;5;196ma\x1b[38;5;67mb\x1b[38;5;244mc\x1b[38;2;1;2;3md").segments).toEqual([
+ { text: "a", color: "#ff0000" },
+ { text: "b", color: "#5f87af" },
+ { text: "c", color: "#808080" },
+ { text: "d", color: "#010203" },
+ ]);
+ });
+
+ it("skips background colours along with their arguments", () => {
+ expect(parseFormatting("\x1b[48;5;1;33mx")).toEqual({
+ text: "x",
+ segments: [{ text: "x", color: "#e5e510" }],
+ });
+ expect(parseFormatting("\x1b[33;48;2;9;9;9mx").segments).toEqual([{ text: "x", color: "#e5e510" }]);
+ });
+
+ it("turns single attributes off", () => {
+ expect(parseFormatting("\x1b[1;4;36mon\x1b[22moff\x1b[24;39mplain").segments).toEqual([
+ { text: "on", bold: true, underline: true, color: "#11a8cd" },
+ { text: "off", underline: true, color: "#11a8cd" },
+ { text: "plain" },
+ ]);
+ });
+
+ it("drops cursor and erase escapes, and a lone ESC", () => {
+ expect(parseFormatting("\x1b[2K\x1b[1Gline\x1b")).toEqual({ text: "line" });
+ });
+
+ it("never lets markup through as anything but text", () => {
+ const out = parseFormatting('§c
');
+ expect(out.text).toBe('
');
+ expect(out.segments).toEqual([{ text: '
', color: "#ff5555" }]);
+ });
+});
diff --git a/panel/src/lib/mcformat.ts b/panel/src/lib/mcformat.ts
new file mode 100644
index 0000000..68f32c6
--- /dev/null
+++ b/panel/src/lib/mcformat.ts
@@ -0,0 +1,190 @@
+// Console formatting codes. Minecraft servers and plugins colour text with §
+// codes (§a green, §l bold, §r reset, and the §x§r§r§g§g§b§b hex form), and a
+// server started with a colour terminal writes ANSI SGR escapes instead. Shown
+// raw, both turn a log into noise. parseFormatting splits a line into styled
+// segments once, when the line arrives; the console renders each segment as a
+// React text node inside a styled span, so nothing from the log ever reaches
+// the page as markup.
+
+export interface SegmentStyle {
+ /** A CSS colour, e.g. "#55ff55". */
+ color?: string;
+ bold?: boolean;
+ italic?: boolean;
+ underline?: boolean;
+ strike?: boolean;
+}
+
+export interface Segment extends SegmentStyle {
+ text: string;
+}
+
+export interface Formatted {
+ /** The line with every code removed: what the level check and copy see. */
+ text: string;
+ /** Styled runs of text, absent when the line carries no styling. */
+ segments?: Segment[];
+}
+
+// The Java Edition palette, with the two darkest colours lifted so they stay
+// readable on the console's black background.
+const MC_COLORS: Record = {
+ "0": "#71717a",
+ "1": "#5c7cfa",
+ "2": "#00aa00",
+ "3": "#00aaaa",
+ "4": "#aa0000",
+ "5": "#aa00aa",
+ "6": "#ffaa00",
+ "7": "#aaaaaa",
+ "8": "#555555",
+ "9": "#5555ff",
+ a: "#55ff55",
+ b: "#55ffff",
+ c: "#ff5555",
+ d: "#ff55ff",
+ e: "#ffff55",
+ f: "#ffffff",
+};
+
+// ANSI 30–37 and 90–97, in the same order as SGR numbers them; black is lifted
+// like §0.
+const ANSI_COLORS = [
+ "#71717a", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5",
+ "#8a8a8a", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff",
+];
+
+const hex2 = (n: number) => n.toString(16).padStart(2, "0");
+const rgb = (r: number, g: number, b: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}`;
+
+// ansi256 maps a 256-colour index: the 16 base colours, a 6×6×6 cube, then 24
+// greys.
+function ansi256(n: number): string | undefined {
+ if (!Number.isInteger(n) || n < 0 || n > 255) return undefined;
+ if (n < 16) return ANSI_COLORS[n];
+ if (n < 232) {
+ const c = n - 16;
+ const level = (v: number) => (v === 0 ? 0 : 55 + v * 40);
+ return rgb(level(Math.floor(c / 36)), level(Math.floor(c / 6) % 6), level(c % 6));
+ }
+ const grey = 8 + (n - 232) * 10;
+ return rgb(grey, grey, grey);
+}
+
+const byte = (s: string | undefined) => {
+ const n = Number(s);
+ return Number.isInteger(n) && n >= 0 && n <= 255 ? n : undefined;
+};
+
+// applySgr folds one SGR parameter list into the running style. Background
+// colours and anything else a log has no use for are skipped.
+function applySgr(style: SegmentStyle, params: string): SegmentStyle {
+ const p = params === "" ? ["0"] : params.split(";");
+ let s = { ...style };
+ for (let i = 0; i < p.length; i++) {
+ const n = Number(p[i] || "0");
+ if (n === 0) s = {};
+ else if (n === 1) s.bold = true;
+ else if (n === 3) s.italic = true;
+ else if (n === 4) s.underline = true;
+ else if (n === 9) s.strike = true;
+ else if (n === 22) s.bold = undefined;
+ else if (n === 23) s.italic = undefined;
+ else if (n === 24) s.underline = undefined;
+ else if (n === 29) s.strike = undefined;
+ else if (n >= 30 && n <= 37) s.color = ANSI_COLORS[n - 30];
+ else if (n >= 90 && n <= 97) s.color = ANSI_COLORS[n - 90 + 8];
+ else if (n === 39) s.color = undefined;
+ else if (n === 38 || n === 48) {
+ // Extended colour: 5;n or 2;r;g;b. Its arguments are consumed either
+ // way, so they are never read as codes of their own.
+ let color: string | undefined;
+ if (p[i + 1] === "5") {
+ color = ansi256(Number(p[i + 2]));
+ i += 2;
+ } else if (p[i + 1] === "2") {
+ const [r, g, b] = [byte(p[i + 2]), byte(p[i + 3]), byte(p[i + 4])];
+ if (r !== undefined && g !== undefined && b !== undefined) color = rgb(r, g, b);
+ i += 4;
+ }
+ if (n === 38 && color) s.color = color;
+ }
+ }
+ return s;
+}
+
+const styled = (s: SegmentStyle) =>
+ s.color !== undefined || s.bold || s.italic || s.underline || s.strike;
+
+const HEX_DIGIT = /^[0-9a-f]$/i;
+
+/** parseFormatting strips § and ANSI codes from a log line and returns the
+ * styled runs they described. */
+export function parseFormatting(raw: string): Formatted {
+ if (!raw.includes("§") && !raw.includes("\x1b")) return { text: raw };
+
+ const segments: Segment[] = [];
+ let style: SegmentStyle = {};
+ let run = "";
+ let text = "";
+
+ const setStyle = (next: SegmentStyle) => {
+ if (run) {
+ segments.push({ ...style, text: run });
+ run = "";
+ }
+ style = next;
+ };
+
+ let i = 0;
+ while (i < raw.length) {
+ const ch = raw[i];
+ if (ch === "§") {
+ const code = (raw[i + 1] ?? "").toLowerCase();
+ if (code in MC_COLORS) {
+ // A colour code also clears bold, italic and the rest, as in game.
+ setStyle({ color: MC_COLORS[code] });
+ } else if (code === "l") setStyle({ ...style, bold: true });
+ else if (code === "o") setStyle({ ...style, italic: true });
+ else if (code === "n") setStyle({ ...style, underline: true });
+ else if (code === "m") setStyle({ ...style, strike: true });
+ else if (code === "r") setStyle({});
+ else if (code === "x") {
+ // §x§R§R§G§G§B§B: six § pairs, one hex digit each.
+ let hex = "";
+ for (let k = 0; k < 6; k++) {
+ const at = i + 2 + k * 2;
+ if (raw[at] !== "§" || !HEX_DIGIT.test(raw[at + 1] ?? "")) break;
+ hex += raw[at + 1];
+ }
+ if (hex.length === 6) {
+ setStyle({ color: `#${hex.toLowerCase()}` });
+ i += 14;
+ continue;
+ }
+ }
+ // §k (scrambled text) and unknown codes only disappear. A § at the very
+ // end of the line has no code to take with it.
+ i += code ? 2 : 1;
+ continue;
+ }
+ if (ch === "\x1b") {
+ // CSI: ESC [ parameters final-byte. Only SGR (final "m") styles text; the
+ // rest (cursor moves, line clears) are dropped.
+ const m = /^\x1b\[([0-9;?]*)([@-~])/.exec(raw.slice(i, i + 64));
+ if (m) {
+ if (m[2] === "m") setStyle(applySgr(style, m[1]));
+ i += m[0].length;
+ } else {
+ i += 1;
+ }
+ continue;
+ }
+ run += ch;
+ text += ch;
+ i++;
+ }
+ setStyle({});
+
+ return segments.some(styled) ? { text, segments } : { text };
+}