perf(panel): 日志控制台按帧批量提交、分块渲染只重绘变动块,§ 与 ANSI 颜色码解析为样式文本
This commit is contained in:
8 files changed
+680
-39
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user