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

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:07:11 +08:00
1 parent 175c9721d4
commit 34ea733775
8 files changed
+680 -39

No files matched your search

+106
View File
@@ -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<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
View File
@@ -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<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>