Unverified Commit 34ea7337 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

perf(panel): 日志控制台按帧批量提交、分块渲染只重绘变动块,§ 与 ANSI 颜色码解析为样式文本

parent 175c9721
Loading
Loading
Loading
Loading
+10 −0
Changes for panel/dev/mockApi.ts: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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`);
+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);
  });
});
+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
@@ -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 }> = {
@@ -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);

@@ -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>

+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];
}
+130 −22
Changes for panel/src/lib/logstream.test.ts: 130 added lines, 22 removed lines.
Original line number Diff line number Diff line
@@ -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();
  });
});
Loading