Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@mirrorstack-ai/web-ui-kit",
"packageManager": "pnpm@10.29.3",
"version": "0.6.2",
"version": "0.6.3",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
Expand Down
87 changes: 87 additions & 0 deletions src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { ServiceLogcat } from "./ServiceLogcat";
import type { LogEntry } from "./types";
Expand Down Expand Up @@ -65,3 +66,89 @@ export const WithRequestResponseDetail: Story = {
export const Empty: Story = {
args: { logs: [] },
};

/** Seeds the text filter box on mount — e.g. landing pre-filtered from a deep link. */
export const WithInitialQuery: Story = {
args: { logs: SAMPLE_LOGS, initialQuery: "users" },
};

/** Every user-facing string overridden — e.g. for a non-English locale. */
export const Localized: Story = {
args: {
logs: SAMPLE_LOGS,
labels: {
title: "日誌",
live: "即時",
paused: "已暫停",
floorAll: "全部",
floorWarn: "警告以上",
floorError: "錯誤",
filterAriaLabel: "依層級篩選",
filterPlaceholder: "篩選紀錄",
clearFilterAriaLabel: "清除篩選",
copyAriaLabel: "複製紀錄",
noMatchingEntries: "沒有符合的紀錄。",
request: "請求",
response: "回應",
noRequestResponseBody: "沒有請求或回應內容。",
loadOlderEntries: "載入較舊的紀錄",
entriesLabel: (filteredCount, total, query) => {
const base =
filteredCount === total ? `共 ${total} 筆` : `${total} 筆中的 ${filteredCount} 筆`;
return query ? `${base} · "${query}"` : base;
},
tailingStatus: "即時追蹤中",
pausedStatus: "已暫停",
},
},
};

// Older pages are generated on demand, seq-keyed, and appended at the OLD end
// (the bottom). Scroll near the bottom or click the row to load a page.
const PAGE_SIZE = 20;
const TOTAL_OLDER = 60;

function olderPage(before: number): LogEntry[] {
return Array.from({ length: PAGE_SIZE }, (_, i): LogEntry => {
const seq = before - PAGE_SIZE + i;
const sec = String(seq % 60).padStart(2, "0");
return {
seq,
ts: `2026-06-30T16:22:${sec}.000000000Z`,
level: seq % 13 === 0 ? "warn" : "info",
msg: `GET /api/items?page=${seq} 200 ${5 + (seq % 20)}ms`,
duration_ms: 5 + (seq % 20),
};
});
}

function LoadOlderDemo() {
const [logs, setLogs] = useState<LogEntry[]>(() =>
SAMPLE_LOGS.map((l, i) => ({ ...l, seq: TOTAL_OLDER + i })),
);
const [loadingOlder, setLoadingOlder] = useState(false);
const oldestSeq = logs[0]?.seq ?? TOTAL_OLDER;

const onLoadOlder = () => {
setLoadingOlder(true);
setTimeout(() => {
setLogs((cur) => [...olderPage(oldestSeq), ...cur]);
setLoadingOlder(false);
}, 800);
};

return (
<div className="h-96">
<ServiceLogcat
logs={logs}
onLoadOlder={onLoadOlder}
hasOlder={oldestSeq > 0}
loadingOlder={loadingOlder}
/>
</div>
);
}

export const LoadOlder: Story = {
render: () => <LoadOlderDemo />,
};
124 changes: 124 additions & 0 deletions src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ServiceLogcat } from "./ServiceLogcat";
import type { LogEntry } from "./types";

afterEach(cleanup);

// Chronological (oldest first), matching the component contract.
const LOGS: LogEntry[] = [
{ ts: "2026-06-30T16:23:40.000000000Z", level: "info", msg: "GET /api/a 200 8ms" },
{ ts: "2026-06-30T16:23:41.000000000Z", level: "warn", msg: "Rate limit approaching" },
{ ts: "2026-06-30T16:23:42.000000000Z", level: "error", msg: "GET /api/b 500" },
];

// The mono scroll surface inside the Surface card.
function scrollEl(container: HTMLElement): HTMLElement {
return container.querySelector(".overflow-y-auto") as HTMLElement;
}

// jsdom reports zero layout; stub the metrics the scroll handler reads.
function stubMetrics(el: HTMLElement, metrics: { scrollHeight: number; clientHeight: number }) {
Object.defineProperty(el, "scrollHeight", {
get: () => metrics.scrollHeight,
configurable: true,
});
Object.defineProperty(el, "clientHeight", {
get: () => metrics.clientHeight,
configurable: true,
});
}

describe("ServiceLogcat load-older", () => {
it("fires onLoadOlder from the button row", () => {
const onLoadOlder = vi.fn();
render(<ServiceLogcat logs={LOGS} onLoadOlder={onLoadOlder} hasOlder />);
fireEvent.click(screen.getByText("Load older entries"));
expect(onLoadOlder).toHaveBeenCalledTimes(1);
});

it("renders no affordance when hasOlder is false or onLoadOlder is absent", () => {
const { rerender } = render(
<ServiceLogcat logs={LOGS} onLoadOlder={() => {}} hasOlder={false} />,
);
expect(screen.queryByText("Load older entries")).not.toBeInTheDocument();
rerender(<ServiceLogcat logs={LOGS} hasOlder />);
expect(screen.queryByText("Load older entries")).not.toBeInTheDocument();
});

it("shows the loading state and suppresses re-fire while loadingOlder", () => {
const onLoadOlder = vi.fn();
const { container } = render(
<ServiceLogcat logs={LOGS} onLoadOlder={onLoadOlder} hasOlder loadingOlder />,
);
const row = screen.getByText("Load older entries");
expect(row.closest("button")).toBeDisabled();
fireEvent.click(row);
// Near-bottom scroll must not fire either while a page is in flight.
// (Twice: the first event is consumed by the programmatic-scroll guard.)
const el = scrollEl(container);
stubMetrics(el, { scrollHeight: 1000, clientHeight: 200 });
el.scrollTop = 790;
fireEvent.scroll(el);
fireEvent.scroll(el);
expect(onLoadOlder).not.toHaveBeenCalled();
});

it("auto-fires once when scrolled near the bottom", () => {
const onLoadOlder = vi.fn();
const { container } = render(
<ServiceLogcat logs={LOGS} onLoadOlder={onLoadOlder} hasOlder />,
);
const el = scrollEl(container);
stubMetrics(el, { scrollHeight: 1000, clientHeight: 200 });
el.scrollTop = 400; // far from the bottom
fireEvent.scroll(el); // consumed by the programmatic-scroll guard
fireEvent.scroll(el);
expect(onLoadOlder).not.toHaveBeenCalled();
el.scrollTop = 790; // 10px from the bottom
fireEvent.scroll(el);
fireEvent.scroll(el); // burst of events fires once
expect(onLoadOlder).toHaveBeenCalledTimes(1);
});

it("keeps scrollTop when older rows append at the bottom, still offsets prepends", () => {
const metrics = { scrollHeight: 400, clientHeight: 200 };
const withSeq = (l: LogEntry, seq: number): LogEntry => ({ ...l, seq });
const logs = LOGS.map((l, i) => withSeq(l, 10 + i));
const { container, rerender } = render(
<ServiceLogcat logs={logs} onLoadOlder={() => {}} hasOlder />,
);
const el = scrollEl(container);
stubMetrics(el, metrics);
fireEvent.click(screen.getByText("Live")); // stop tail-follow
el.scrollTop = 150;
fireEvent.scroll(el); // unpin from the top

// Older page appended at the bottom: height grows below, scrollTop holds.
metrics.scrollHeight = 600;
const older: LogEntry[] = [
withSeq({ ts: "2026-06-30T16:22:00.000000000Z", level: "info", msg: "older line" }, 1),
];
rerender(<ServiceLogcat logs={[...older, ...logs]} onLoadOlder={() => {}} hasOlder />);
expect(el.scrollTop).toBe(150);

// Newer line prepended above: scrollTop offsets by the added height.
metrics.scrollHeight = 800;
const newer: LogEntry[] = [
withSeq({ ts: "2026-06-30T16:24:00.000000000Z", level: "info", msg: "newer line" }, 99),
];
rerender(
<ServiceLogcat logs={[...older, ...logs, ...newer]} onLoadOlder={() => {}} hasOlder />,
);
expect(el.scrollTop).toBe(350);
});

it("renders seq-keyed rows, including repeated ts+msg lines", () => {
const dup: LogEntry[] = [
{ seq: 1, ts: "2026-06-30T16:23:40.000000000Z", level: "info", msg: "retrying" },
{ seq: 2, ts: "2026-06-30T16:23:40.000000000Z", level: "info", msg: "retrying" },
];
render(<ServiceLogcat logs={dup} />);
expect(screen.getAllByText("retrying")).toHaveLength(2);
});
});
Loading
Loading