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 }; +}