mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): sort Lens runs by column on the server
Time, Steps, Duration and Failed headers now set a run order that the list sends to /v1/traces as sort_by and sort_dir, next to the existing window, search and cursor. The order is its own read axis: TracesApi.list takes selection, order and page bags, the hook restarts paging from the first page when the order changes, and the order lives in the URL beside q, so links keep it. Cost and Agents stay unsortable because the server computes them after the page is cut. While a new order loads the previous rows stay visible and dimmed under a progress line, auto-paging pauses, and the list scrolls back to the top. Headers carry aria-sort. The copied trace query and the demo follow the same order. The proxy ignores the new query parameters until the matching backend change lands, so until then every order still arrives newest first.
This commit is contained in:
parent
93e6ccff8d
commit
5bb6a79a9b
20 changed files with 598 additions and 99 deletions
|
|
@ -3,6 +3,7 @@ import { createLensDemo, demoHistogram } from "./createLensDemo";
|
|||
import { createLensDemoData } from "./fixtures";
|
||||
import { evidenceTarget } from "../../model/findings";
|
||||
import type { TraceSummary } from "../../traces/types";
|
||||
import { NEWEST } from "@/components/lens/traces/list/runOrder";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
|
||||
|
|
@ -102,8 +103,14 @@ describe("Lens demo data", () => {
|
|||
const network = vi.spyOn(globalThis, "fetch");
|
||||
const now = Date.now();
|
||||
const services = createLensDemo(now);
|
||||
const all = await services.traces.list({ startMs: 0, endMs: now + 1, q: "" });
|
||||
const recent = await services.traces.list({ startMs: now - 3600_000, endMs: now + 1, q: "" });
|
||||
const listWithin = (startMs: number) =>
|
||||
services.traces.list({
|
||||
selection: { window: { startMs, endMs: now + 1 }, q: "" },
|
||||
order: NEWEST,
|
||||
page: { cursor: null },
|
||||
});
|
||||
const all = await listWithin(0);
|
||||
const recent = await listWithin(now - 3600_000);
|
||||
expect(recent.data.length).toBeGreaterThan(0);
|
||||
expect(recent.data.length).toBeLessThan(all.data.length);
|
||||
expect(recent.data.every((trace) => Date.parse(trace.start_time) >= now - 3600_000)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ApiError } from "@/lib/http/client";
|
||||
import type { TracesApi } from "@/components/lens/traces/api";
|
||||
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
||||
import { orderRuns } from "@/components/lens/traces/list/runOrder";
|
||||
import { filterRuns, RUN_INDEX } from "@/components/lens/traces/list/runSearch/runQuery";
|
||||
import type { TraceHistogram, TraceSummary } from "@/components/lens/traces/types";
|
||||
import { traceAgentNames } from "@/components/lens/traces/utils";
|
||||
|
|
@ -89,8 +90,11 @@ function demoTracesApi(data: LensDemoData): TracesApi {
|
|||
const step = spanId ? found?.details.find((span) => span.span_id === spanId) : found;
|
||||
return { text: JSON.stringify(step, null, 2), copied: spanId ? "Step copied" : "Trace copied" };
|
||||
},
|
||||
list: async ({ startMs, endMs, q }) => ({ data: matching({ startMs, endMs }, q), next_cursor: null }),
|
||||
histogram: async (range, q, buckets) => demoHistogram(matching(range, q), range, buckets),
|
||||
list: async ({ selection, order }) => ({
|
||||
data: orderRuns(matching(selection.window, selection.q), order),
|
||||
next_cursor: null,
|
||||
}),
|
||||
histogram: async ({ window, q }, buckets) => demoHistogram(matching(window, q), window, buckets),
|
||||
values: async (field, contains, range) => {
|
||||
const read = field in RUN_INDEX.read ? RUN_INDEX.read[field as keyof typeof RUN_INDEX.read] : null;
|
||||
if (!read) return [];
|
||||
|
|
|
|||
|
|
@ -10,15 +10,25 @@ import {
|
|||
getProxyBaseUrl,
|
||||
} from "../../networking";
|
||||
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
||||
import type { RunOrder } from "./list/runOrder";
|
||||
import type { RunField, SpanDetail, SpanErrorPage, Trace, TraceHistogram, TracePage } from "./types";
|
||||
|
||||
export interface TraceWindow extends TimeWindow {
|
||||
readonly cursor?: string | null;
|
||||
/** Which runs: the window and the `q` search the server applies before counting or paging. */
|
||||
export interface RunSelection {
|
||||
readonly window: TimeWindow;
|
||||
readonly q: string;
|
||||
}
|
||||
|
||||
/** One page of the runs list: the window, the `q` search the server applies, and where to resume. */
|
||||
export interface RunListRequest extends TraceWindow {
|
||||
readonly q: string;
|
||||
/** Where in the ordered sequence a page starts; `null` is the first page. */
|
||||
export interface RunPage {
|
||||
readonly cursor: string | null;
|
||||
}
|
||||
|
||||
/** One page of the runs list, as the server's independent read axes. */
|
||||
export interface RunListRequest {
|
||||
readonly selection: RunSelection;
|
||||
readonly order: RunOrder;
|
||||
readonly page: RunPage;
|
||||
}
|
||||
|
||||
export interface TraceHandoff {
|
||||
|
|
@ -33,7 +43,7 @@ export interface TracesApi {
|
|||
readonly live: boolean;
|
||||
handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff;
|
||||
list(request: RunListRequest): Promise<TracePage>;
|
||||
histogram(range: TimeWindow, q: string, buckets: number): Promise<TraceHistogram>;
|
||||
histogram(selection: RunSelection, buckets: number): Promise<TraceHistogram>;
|
||||
values(field: RunField, contains: string, range: TimeWindow): Promise<readonly string[]>;
|
||||
anyRecorded(): Promise<boolean>;
|
||||
trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>;
|
||||
|
|
@ -60,11 +70,11 @@ export function liveTracesApi(accessToken: string): TracesApi {
|
|||
text: agentHandoffText(traceId, spanId, traceRef),
|
||||
copied: "Command copied",
|
||||
}),
|
||||
list: (request) => agentTraceListCall({ accessToken, ...request }),
|
||||
histogram: (range, q, buckets) =>
|
||||
list: (request) => agentTraceListCall(accessToken, request),
|
||||
histogram: ({ window, q }, buckets) =>
|
||||
apiClient.get<TraceHistogram>("/v1/traces/histogram", {
|
||||
accessToken,
|
||||
query: { start_ms: range.startMs, end_ms: range.endMs, q: q || undefined, buckets },
|
||||
query: { start_ms: window.startMs, end_ms: window.endMs, q: q || undefined, buckets },
|
||||
}),
|
||||
values: async (field, contains, range) => {
|
||||
const found = await apiClient.get<{ values: string[] }>(`/v1/traces/values/${field}`, {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { renderWithProviders, testQueryClient } from "../../../../../tests/test-
|
|||
import traceList from "../__fixtures__/trace_list.json";
|
||||
import { LensPreviewContext } from "../../ui/LensPreviewButton";
|
||||
import AgentTracesPage from "./AgentTracesPage";
|
||||
import { previewText } from "../utils";
|
||||
import { NEWEST, orderRuns, type RunOrder } from "./runOrder";
|
||||
import { filterRuns } from "./runSearch/runQuery";
|
||||
import { demoHistogram } from "../../data/demo/createLensDemo";
|
||||
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
|
||||
|
|
@ -51,8 +53,8 @@ const serve = (data: readonly TraceSummary[]) => {
|
|||
data.filter((run) => startedWithin(run, startMs, endMs)),
|
||||
q,
|
||||
);
|
||||
vi.mocked(agentTraceListCall).mockImplementation(async ({ startMs, endMs, q }) => ({
|
||||
data: matching(startMs, endMs, q),
|
||||
vi.mocked(agentTraceListCall).mockImplementation(async (_token, { selection, order }) => ({
|
||||
data: orderRuns(matching(selection.window.startMs, selection.window.endMs, selection.q), order),
|
||||
next_cursor: null,
|
||||
}));
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path: string, options?: { query?: Record<string, unknown> }) => {
|
||||
|
|
@ -66,6 +68,9 @@ const serve = (data: readonly TraceSummary[]) => {
|
|||
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
|
||||
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
|
||||
|
||||
const listRequest = (index: number) => vi.mocked(agentTraceListCall).mock.calls.at(index)?.[1];
|
||||
const lastWindow = () => listRequest(-1)?.selection.window;
|
||||
|
||||
/** A day that rolls with now, as the page opens by default. */
|
||||
const ROLLING_DAY = { hours: 24, anchorMs: null };
|
||||
// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone.
|
||||
|
|
@ -121,7 +126,7 @@ describe("AgentTracesSection", () => {
|
|||
expect(agentTraceListCall).toHaveBeenCalledOnce();
|
||||
act(() => mockAllIsIntersecting(true));
|
||||
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(2));
|
||||
expect(vi.mocked(agentTraceListCall).mock.calls[1][0]).toMatchObject({ cursor: "next" });
|
||||
expect(listRequest(1)).toMatchObject({ page: { cursor: "next" } });
|
||||
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
|
||||
expect(agentTraceListCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
|
@ -132,7 +137,7 @@ describe("AgentTracesSection", () => {
|
|||
vi.mocked(agentTraceListCall).mockResolvedValueOnce({ data: runs.slice(0, 1), next_cursor: "next" });
|
||||
renderSection();
|
||||
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
|
||||
const first = vi.mocked(agentTraceListCall).mock.calls[0][0];
|
||||
const first = listRequest(0);
|
||||
now.mockReturnValue(Date.parse("2026-10-01T01:00Z"));
|
||||
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Please try again", 403, {}));
|
||||
act(() => mockAllIsIntersecting(true));
|
||||
|
|
@ -140,7 +145,7 @@ describe("AgentTracesSection", () => {
|
|||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
|
||||
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
|
||||
expect(agentTraceListCall).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(agentTraceListCall).mock.calls[1][0]).toEqual({ ...first, cursor: "next" });
|
||||
expect(listRequest(1)).toEqual({ ...first, page: { cursor: "next" } });
|
||||
vi.mocked(agentTraceListCall).mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: null });
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(2));
|
||||
|
|
@ -595,7 +600,7 @@ describe("AgentTracesPage", () => {
|
|||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" }));
|
||||
expect(trigger).toHaveTextContent("Last 7 days");
|
||||
await waitFor(() => {
|
||||
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
|
||||
const last = lastWindow();
|
||||
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
|
||||
});
|
||||
|
||||
|
|
@ -604,7 +609,7 @@ describe("AgentTracesPage", () => {
|
|||
expect(trigger).toHaveTextContent(/ to /);
|
||||
expect(trigger).not.toHaveTextContent("Last 7 days");
|
||||
|
||||
await waitFor(() => expect(vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0].endMs).toBe(pausedAt));
|
||||
await waitFor(() => expect(lastWindow()?.endMs).toBe(pausedAt));
|
||||
});
|
||||
|
||||
it("keeps the time controls on an empty range the user picked, instead of showing onboarding", async () => {
|
||||
|
|
@ -626,24 +631,63 @@ describe("AgentTracesPage", () => {
|
|||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { searchParams: "?hours=168", onUrlUpdate });
|
||||
await screen.findByTestId("runs-table");
|
||||
const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0];
|
||||
const { startMs, endMs } = listRequest(0)!.selection.window;
|
||||
expect(endMs - startMs).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
|
||||
await waitFor(() => {
|
||||
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
|
||||
const last = lastWindow();
|
||||
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeLessThanOrEqual(3600 * 1000 + 60_000);
|
||||
});
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1"));
|
||||
});
|
||||
|
||||
it("re-sorts on the server from the first page, keeps the rows meanwhile, and remembers the order in the URL", async () => {
|
||||
pinNowToFixtures();
|
||||
serve(runs);
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { onUrlUpdate });
|
||||
const rowsBefore = await screen.findAllByTestId("agent-trace-row");
|
||||
expect(listRequest(0)).toMatchObject({ order: { key: "start_ms", descending: true }, page: { cursor: null } });
|
||||
const firstInput = (order: RunOrder) => previewText(orderRuns(runs, order)[0].input_preview).split("\n")[0];
|
||||
// A first click sorts descending; pick a key whose top run is not already the newest one.
|
||||
const key = (["duration_ms", "span_count", "error_count"] as const).find(
|
||||
(candidate) => firstInput({ key: candidate, descending: true }) !== firstInput(NEWEST),
|
||||
);
|
||||
if (key === undefined) throw new Error("fixtures need a run that leads a non-time order");
|
||||
const topAfter = firstInput({ key, descending: true });
|
||||
expect(rowsBefore[0]).not.toHaveTextContent(topAfter);
|
||||
|
||||
let release = () => {};
|
||||
vi.mocked(agentTraceListCall).mockImplementationOnce(
|
||||
(_token, { selection, order }) =>
|
||||
new Promise((resolve) => {
|
||||
release = () =>
|
||||
resolve({
|
||||
data: orderRuns(filterRuns(runs, selection.q), order),
|
||||
next_cursor: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(`sort-header-${key}`));
|
||||
expect(await screen.findByRole("progressbar", { name: "Updating rows" })).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(rowsBefore.length);
|
||||
expect(listRequest(-1)).toMatchObject({ order: { key, descending: true }, page: { cursor: null } });
|
||||
|
||||
act(release);
|
||||
await waitFor(() => expect(screen.queryByRole("progressbar")).not.toBeInTheDocument());
|
||||
expect(screen.getAllByTestId("agent-trace-row")[0]).toHaveTextContent(topAfter);
|
||||
expect(lastUrl(onUrlUpdate).get("sort_by")).toBe(key);
|
||||
expect(lastUrl(onUrlUpdate).has("sort_dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("asks the proxy for the last 24 hours by default", async () => {
|
||||
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
|
||||
renderWithProviders(<AgentTracesPage accessToken="sk-test" />);
|
||||
await screen.findByTestId("runs-table");
|
||||
|
||||
const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0];
|
||||
const { startMs, endMs } = listRequest(0)!.selection.window;
|
||||
expect(endMs - startMs).toBeGreaterThanOrEqual(24 * 3600 * 1000 - 60_000);
|
||||
expect(endMs - startMs).toBeLessThan(24 * 3600 * 1000 + 120_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ import { Inspector } from "@/components/shared/Inspector";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { AgentTracesTable } from "./AgentTracesTable";
|
||||
import { type TraceRef, traceKey, traceRefOf, useOpenTraceRouting, useRunFilterRouting } from "../routing";
|
||||
import {
|
||||
type TraceRef,
|
||||
traceKey,
|
||||
traceRefOf,
|
||||
useOpenTraceRouting,
|
||||
useRunFilterRouting,
|
||||
useRunOrderRouting,
|
||||
} from "../routing";
|
||||
import type { TraceSummary } from "../types";
|
||||
import { RunView } from "../detail/run/RunView";
|
||||
import { useZoomRouting } from "@/components/shared/timeRange/routing";
|
||||
|
|
@ -75,10 +82,11 @@ export function AgentTracesSection({
|
|||
const live = useTracesLive();
|
||||
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
|
||||
const { query, setQuery } = useRunFilterRouting();
|
||||
const [order, setOrder] = useRunOrderRouting();
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [zoom, setZoom] = useZoomRouting();
|
||||
const [rangeChanged, setRangeChanged] = useState(false);
|
||||
const traceQuery = { accessToken, range, enabled: isActive, q: query, zoom };
|
||||
const traceQuery = { accessToken, range, enabled: isActive, q: query, zoom, order };
|
||||
const traces = useAgentTraces(traceQuery);
|
||||
const narrowed = rangeChanged || zoom !== null || query.trim() !== "";
|
||||
const setup = useTracingSetup(traces, isActive, narrowed);
|
||||
|
|
@ -93,7 +101,7 @@ export function AgentTracesSection({
|
|||
// A live range ends "now" (the list query uses Date.now() too); round to the minute so the histogram is stable.
|
||||
const minuteEndMs = moment().endOf("minute").valueOf();
|
||||
const window = useMemo(() => timeWindow(range, minuteEndMs), [range, minuteEndMs]);
|
||||
const histogram = useTraceHistogram(accessToken, window, query, isActive);
|
||||
const histogram = useTraceHistogram(accessToken, { window, q: query }, isActive);
|
||||
const runs = traces.traces;
|
||||
const runRefs = useMemo(() => runs.map(traceRefOf), [runs]);
|
||||
|
||||
|
|
@ -163,7 +171,7 @@ export function AgentTracesSection({
|
|||
/>
|
||||
)}
|
||||
</Inspector.Panel>
|
||||
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces} range={zoom ?? window}>
|
||||
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces} range={zoom ?? window} order={order}>
|
||||
{timeControls && (
|
||||
<TimeRangeControls
|
||||
range={range}
|
||||
|
|
@ -188,6 +196,9 @@ export function AgentTracesSection({
|
|||
error={traces.error}
|
||||
hasMore={traces.hasMore}
|
||||
isFetching={traces.isFetching}
|
||||
isPlaceholder={traces.isPlaceholder}
|
||||
order={order}
|
||||
onOrderChange={setOrder}
|
||||
onRetry={traces.hasMore ? traces.loadMore : traces.refetch}
|
||||
onLoadMore={traces.loadMore}
|
||||
rangeEmpty={traces.traces.length === 0}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
|
|||
import { Inspector } from "@/components/shared/Inspector";
|
||||
import traceList from "../__fixtures__/trace_list.json";
|
||||
import { AgentTracesTable } from "./AgentTracesTable";
|
||||
import { NEWEST, type RunOrder } from "./runOrder";
|
||||
import { traceKey } from "../routing";
|
||||
import type { TracePage, TraceSummary } from "../types";
|
||||
|
||||
|
|
@ -152,3 +153,92 @@ describe("AgentTracesTable column picker", () => {
|
|||
expect(headers()).toHaveLength(before - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable sorting", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
const runs = (traceList as TracePage).data as TraceSummary[];
|
||||
const sortable = (order: RunOrder, onOrderChange = vi.fn()) =>
|
||||
inList(
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
order={order}
|
||||
onOrderChange={onOrderChange}
|
||||
onLoadMore={vi.fn()}
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const header = (id: string) => screen.getByTestId(`sort-header-${id}`);
|
||||
|
||||
it("asks the server for a column descending first, flips it on the next click, and never clears the order", () => {
|
||||
const onOrderChange = vi.fn();
|
||||
const view = renderWithProviders(sortable(NEWEST, onOrderChange));
|
||||
fireEvent.click(header("duration_ms"));
|
||||
expect(onOrderChange).toHaveBeenLastCalledWith({ key: "duration_ms", descending: true });
|
||||
|
||||
view.rerender(sortable({ key: "duration_ms", descending: true }, onOrderChange));
|
||||
fireEvent.click(header("duration_ms"));
|
||||
expect(onOrderChange).toHaveBeenLastCalledWith({ key: "duration_ms", descending: false });
|
||||
|
||||
view.rerender(sortable({ key: "duration_ms", descending: false }, onOrderChange));
|
||||
fireEvent.click(header("duration_ms"));
|
||||
expect(onOrderChange).toHaveBeenLastCalledWith({ key: "duration_ms", descending: true });
|
||||
expect(onOrderChange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("exposes the active order on the column header and offers no sort on values the server cannot order by", () => {
|
||||
renderWithProviders(sortable({ key: "error_count", descending: false }));
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
const byName = (name: RegExp) => headers.find((cell) => name.test(cell.textContent ?? ""));
|
||||
expect(byName(/Failed/)).toHaveAttribute("aria-sort", "ascending");
|
||||
expect(byName(/Time/)).toHaveAttribute("aria-sort", "none");
|
||||
expect(byName(/Cost/)).not.toHaveAttribute("aria-sort");
|
||||
expect(within(byName(/Cost/) as HTMLElement).queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(within(byName(/Agents/) as HTMLElement).queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sort-header-cost")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plain headings when the order cannot be changed", () => {
|
||||
renderWithProviders(
|
||||
inList(
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(screen.queryByTestId("sort-header-start_ms")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("columnheader").some((cell) => cell.hasAttribute("aria-sort"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the previous rows visible and pauses paging while a new order loads", () => {
|
||||
const onLoadMore = vi.fn();
|
||||
renderWithProviders(
|
||||
inList(
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore
|
||||
isFetching
|
||||
isPlaceholder
|
||||
order={NEWEST}
|
||||
onOrderChange={vi.fn()}
|
||||
onLoadMore={onLoadMore}
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
|
||||
expect(screen.getByRole("progressbar", { name: "Updating rows" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
expect(onLoadMore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { getCoreRowModel, useReactTable, type ColumnDef, type TableOptions } from "@tanstack/react-table";
|
||||
import { ArrowDown, ChevronRight } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type HeaderContext,
|
||||
type TableOptions,
|
||||
} from "@tanstack/react-table";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable/DataTableSortHeader";
|
||||
import { DataTableViewOptions } from "@/components/shared/DataTable/DataTableViewOptions";
|
||||
import { usePersistedColumnVisibility } from "@/components/shared/DataTable/usePersistedColumnVisibility";
|
||||
import { InspectorTable, useInspectorTable } from "@/components/shared/InspectorTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
|
||||
|
||||
import { SpanIcon } from "../ui/SpanIcon";
|
||||
|
|
@ -18,6 +26,7 @@ import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
|
|||
import type { TraceSummary } from "../types";
|
||||
import { traceRefOf } from "../routing";
|
||||
import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils";
|
||||
import { fromSorting, NEWEST, type RunOrder, toSorting } from "./runOrder";
|
||||
|
||||
interface AgentTracesTableProps {
|
||||
traces: TraceSummary[];
|
||||
|
|
@ -25,6 +34,11 @@ interface AgentTracesTableProps {
|
|||
error: Error | null;
|
||||
hasMore: boolean;
|
||||
isFetching?: boolean;
|
||||
/** Rows belong to the previous order, search or window while this one loads. */
|
||||
isPlaceholder?: boolean;
|
||||
/** The order rows arrive in; with `onOrderChange`, the sortable headers change it on the server. */
|
||||
order?: RunOrder;
|
||||
onOrderChange?: (order: RunOrder) => void;
|
||||
onRetry?: () => void;
|
||||
onLoadMore: () => void;
|
||||
rangeEmpty?: boolean;
|
||||
|
|
@ -79,19 +93,32 @@ function InputCell({ run }: { run: TraceSummary }) {
|
|||
);
|
||||
}
|
||||
|
||||
const sortHeader = (title: ReactNode) =>
|
||||
function SortHeaderCell({ column }: HeaderContext<TraceSummary, unknown>) {
|
||||
return (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={title}
|
||||
className={cn("w-full font-normal uppercase", column.columnDef.meta?.numeric && "justify-end")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TIME_TITLE = (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
Time
|
||||
<span className="normal-case tracking-normal text-muted-foreground/70">{localTimeZoneAbbreviation()}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
||||
{
|
||||
id: "time",
|
||||
id: "start_ms",
|
||||
accessorKey: "start_time",
|
||||
enableSorting: true,
|
||||
size: 170,
|
||||
enableHiding: false,
|
||||
header: () => (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
Time <ArrowDown className="size-2.5" />
|
||||
<span className="font-normal normal-case tracking-normal text-muted-foreground/70">
|
||||
{localTimeZoneAbbreviation()}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
header: sortHeader(TIME_TITLE),
|
||||
cell: ({ row }) => (
|
||||
<span title={formatActivityTimestamp(row.original.start_time)}>
|
||||
{formatRunTimestamp(row.original.start_time)}
|
||||
|
|
@ -127,18 +154,22 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
meta: { numeric: true, className: MUTED_NUM },
|
||||
},
|
||||
{
|
||||
id: "steps",
|
||||
id: "span_count",
|
||||
accessorKey: "span_count",
|
||||
enableSorting: true,
|
||||
size: 74,
|
||||
header: "Steps",
|
||||
header: sortHeader("Steps"),
|
||||
cell: ({ row }) => row.original.span_count.toLocaleString(),
|
||||
meta: { numeric: true, className: MUTED_NUM },
|
||||
meta: { title: "Steps", numeric: true, className: MUTED_NUM },
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
id: "duration_ms",
|
||||
accessorKey: "duration_ms",
|
||||
enableSorting: true,
|
||||
size: 86,
|
||||
header: "Duration",
|
||||
header: sortHeader("Duration"),
|
||||
cell: ({ row }) => fmtMs(row.original.duration_ms),
|
||||
meta: { numeric: true, className: NUM },
|
||||
meta: { title: "Duration", numeric: true, className: NUM },
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
|
|
@ -148,16 +179,18 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
meta: { numeric: true, className: NUM },
|
||||
},
|
||||
{
|
||||
id: "failed",
|
||||
id: "error_count",
|
||||
accessorKey: "error_count",
|
||||
enableSorting: true,
|
||||
size: 72,
|
||||
header: "Failed",
|
||||
header: sortHeader("Failed"),
|
||||
cell: ({ row }) =>
|
||||
row.original.error_count > 0 ? (
|
||||
<StatusMark status="error" count={row.original.error_count} />
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/60">0</span>
|
||||
),
|
||||
meta: { numeric: true },
|
||||
meta: { title: "Failed", numeric: true },
|
||||
},
|
||||
{
|
||||
id: "open",
|
||||
|
|
@ -184,6 +217,15 @@ function LoadMoreRows({ isFetching, onLoadMore }: { isFetching: boolean; onLoadM
|
|||
));
|
||||
}
|
||||
|
||||
/** A new order starts at its first row, wherever the previous order had been scrolled to. */
|
||||
function ScrollToTop({ order }: { order: RunOrder }) {
|
||||
const { scroller } = useInspectorTable();
|
||||
useEffect(() => {
|
||||
scroller?.scrollTo({ top: 0 });
|
||||
}, [scroller, order.key, order.descending]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) {
|
||||
if (!rangeEmpty)
|
||||
return (
|
||||
|
|
@ -205,13 +247,16 @@ function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetU
|
|||
);
|
||||
}
|
||||
|
||||
/** Devtool-dense runs list: one row per agent run, newest first. */
|
||||
/** Devtool-dense runs list: one row per agent run, in the server's order. */
|
||||
export function AgentTracesTable({
|
||||
traces,
|
||||
isLoading,
|
||||
error,
|
||||
hasMore,
|
||||
isFetching = false,
|
||||
isPlaceholder = false,
|
||||
order = NEWEST,
|
||||
onOrderChange,
|
||||
onRetry,
|
||||
onLoadMore,
|
||||
rangeEmpty = false,
|
||||
|
|
@ -219,24 +264,33 @@ export function AgentTracesTable({
|
|||
}: AgentTracesTableProps) {
|
||||
const settled = !isLoading && !error;
|
||||
const isEmpty = settled && !hasMore && traces.length === 0;
|
||||
const autoContinue = settled && hasMore && traces.length > 0;
|
||||
const autoContinue = settled && hasMore && traces.length > 0 && !isPlaceholder;
|
||||
const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces");
|
||||
const sorting = useMemo(() => toSorting(order), [order]);
|
||||
const tableOptions: TableOptions<TraceSummary> = {
|
||||
data: traces,
|
||||
columns: RUN_COLUMNS,
|
||||
getRowId: runKey,
|
||||
autoResetAll: false,
|
||||
state: { columnVisibility },
|
||||
manualSorting: true,
|
||||
enableSorting: onOrderChange !== undefined,
|
||||
enableMultiSort: false,
|
||||
enableSortingRemoval: false,
|
||||
sortDescFirst: true,
|
||||
state: { columnVisibility, sorting },
|
||||
onColumnVisibilityChange,
|
||||
onSortingChange: (updater) => onOrderChange?.(fromSorting(updater, order)),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
};
|
||||
const table = useReactTable(tableOptions);
|
||||
return (
|
||||
<InspectorTable.Root table={table} data-testid="runs-table">
|
||||
<ScrollToTop order={order} />
|
||||
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
|
||||
<InspectorTable.Header />
|
||||
<InspectorTable.Header busy={isPlaceholder} />
|
||||
<InspectorTable.Body<TraceSummary>
|
||||
rowHeight={() => ROW_HEIGHT}
|
||||
className={cn(isPlaceholder && "opacity-60 transition-opacity motion-reduce:transition-none")}
|
||||
after={
|
||||
<>
|
||||
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import traceList from "../__fixtures__/trace_list.json";
|
||||
import { fromSorting, NEWEST, orderRuns, toSorting, type RunOrder } from "./runOrder";
|
||||
import type { TracePage, TraceSummary } from "../types";
|
||||
|
||||
const template = (traceList as TracePage).data[0] as TraceSummary;
|
||||
const run = (overrides: Partial<TraceSummary>): TraceSummary => ({ ...template, ...overrides });
|
||||
|
||||
const RUNS: TraceSummary[] = [
|
||||
run({
|
||||
trace_id: "a",
|
||||
trace_ref: "ref-a",
|
||||
start_time: "2026-09-30T06:00:00Z",
|
||||
duration_ms: 500,
|
||||
span_count: 3,
|
||||
error_count: 0,
|
||||
}),
|
||||
run({
|
||||
trace_id: "b",
|
||||
trace_ref: "ref-b",
|
||||
start_time: "2026-09-30T07:00:00Z",
|
||||
duration_ms: 500,
|
||||
span_count: 9,
|
||||
error_count: 2,
|
||||
}),
|
||||
run({
|
||||
trace_id: "c",
|
||||
trace_ref: "ref-c",
|
||||
start_time: "2026-09-30T05:00:00Z",
|
||||
duration_ms: 50,
|
||||
span_count: 1,
|
||||
error_count: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const ids = (runs: readonly TraceSummary[]) => runs.map((item) => item.trace_id);
|
||||
|
||||
describe("orderRuns", () => {
|
||||
it.each<{ order: RunOrder; expected: string[] }>([
|
||||
{ order: NEWEST, expected: ["b", "a", "c"] },
|
||||
{ order: { key: "start_ms", descending: false }, expected: ["c", "a", "b"] },
|
||||
{ order: { key: "span_count", descending: true }, expected: ["b", "a", "c"] },
|
||||
{ order: { key: "error_count", descending: false }, expected: ["a", "c", "b"] },
|
||||
])("orders by $order.key with descending=$order.descending", ({ order, expected }) => {
|
||||
expect(ids(orderRuns(RUNS, order))).toEqual(expected);
|
||||
});
|
||||
|
||||
it("breaks a tie on the trace reference in the same direction, so pages never overlap", () => {
|
||||
expect(ids(orderRuns(RUNS, { key: "duration_ms", descending: true }))).toEqual(["b", "a", "c"]);
|
||||
expect(ids(orderRuns(RUNS, { key: "duration_ms", descending: false }))).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("leaves the input untouched", () => {
|
||||
const before = ids(RUNS);
|
||||
orderRuns(RUNS, { key: "span_count", descending: false });
|
||||
expect(ids(RUNS)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorting state round trip", () => {
|
||||
it("maps an order to one sorting entry and back", () => {
|
||||
const order: RunOrder = { key: "duration_ms", descending: false };
|
||||
expect(toSorting(order)).toEqual([{ id: "duration_ms", desc: false }]);
|
||||
expect(fromSorting(toSorting(order), NEWEST)).toEqual(order);
|
||||
});
|
||||
|
||||
it("applies a functional updater against the current order", () => {
|
||||
const flipped = fromSorting((previous) => previous.map((entry) => ({ ...entry, desc: !entry.desc })), NEWEST);
|
||||
expect(flipped).toEqual({ key: "start_ms", descending: false });
|
||||
});
|
||||
|
||||
it("keeps the current order when the state is empty or names a column the server cannot sort", () => {
|
||||
expect(fromSorting([], NEWEST)).toEqual(NEWEST);
|
||||
expect(fromSorting([{ id: "cost", desc: true }], NEWEST)).toEqual(NEWEST);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { functionalUpdate, type SortingState, type Updater } from "@tanstack/react-table";
|
||||
|
||||
import type { TraceSummary } from "../types";
|
||||
|
||||
export const RUN_SORT_KEYS = ["start_ms", "duration_ms", "span_count", "error_count"] as const;
|
||||
export type RunSortKey = (typeof RUN_SORT_KEYS)[number];
|
||||
|
||||
export const SORT_DIRS = ["asc", "desc"] as const;
|
||||
export type SortDir = (typeof SORT_DIRS)[number];
|
||||
|
||||
/** The sequence a runs page is cut from: one server-sorted key, with the trace reference as the tie-break. */
|
||||
export interface RunOrder {
|
||||
readonly key: RunSortKey;
|
||||
readonly descending: boolean;
|
||||
}
|
||||
|
||||
export const NEWEST: RunOrder = { key: "start_ms", descending: true };
|
||||
|
||||
export const sortDir = (order: RunOrder): SortDir => (order.descending ? "desc" : "asc");
|
||||
|
||||
const SORT_VALUE: Record<RunSortKey, (run: TraceSummary) => number> = {
|
||||
start_ms: (run) => Date.parse(run.start_time),
|
||||
duration_ms: (run) => run.duration_ms,
|
||||
span_count: (run) => run.span_count,
|
||||
error_count: (run) => run.error_count,
|
||||
};
|
||||
|
||||
const compareText = (left: string, right: string): number => {
|
||||
if (left === right) return 0;
|
||||
return left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
const reference = (run: TraceSummary): string => run.trace_ref || run.trace_id;
|
||||
|
||||
/** Runs in `order`, as the server pages them: by the key, then by trace reference the same way. */
|
||||
export function orderRuns(runs: readonly TraceSummary[], order: RunOrder): TraceSummary[] {
|
||||
const value = SORT_VALUE[order.key];
|
||||
const sign = order.descending ? -1 : 1;
|
||||
return [...runs].sort((left, right) => {
|
||||
const byKey = value(left) - value(right);
|
||||
return sign * (byKey !== 0 ? byKey : compareText(reference(left), reference(right)));
|
||||
});
|
||||
}
|
||||
|
||||
export const toSorting = (order: RunOrder): SortingState => [{ id: order.key, desc: order.descending }];
|
||||
|
||||
const isSortKey = (id: string): id is RunSortKey => RUN_SORT_KEYS.includes(id as RunSortKey);
|
||||
|
||||
/** The order TanStack's sorting state asks for; an empty or unknown state keeps `current`. */
|
||||
export function fromSorting(updater: Updater<SortingState>, current: RunOrder): RunOrder {
|
||||
const [first] = functionalUpdate(updater, toSorting(current));
|
||||
return first !== undefined && isSortKey(first.id) ? { key: first.id, descending: first.desc } : current;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
|||
|
||||
import { SearchBox } from "@/components/shared/search/SearchBox";
|
||||
import { itemValues } from "@/components/shared/search/valueSource";
|
||||
import { NEWEST, type RunOrder } from "../runOrder";
|
||||
import { RUN_INDEX, RUN_QUERY } from "./runQuery";
|
||||
import { runQueryCommand } from "./runSql";
|
||||
|
||||
|
|
@ -17,13 +18,14 @@ interface RunSearchProps {
|
|||
onChange: (value: string) => void;
|
||||
/** Loaded runs, the source of value suggestions. */
|
||||
runs: readonly TraceSummary[];
|
||||
/** The range the list shows; the copied query bounds itself to it. */
|
||||
/** The range and order the list shows; the copied query follows both. */
|
||||
range?: TimeWindow;
|
||||
order?: RunOrder;
|
||||
}
|
||||
|
||||
/** The runs list query box: free text plus `key:value` filters over run fields, copyable as a trace query. */
|
||||
export function RunSearch({ value, onChange, runs, range }: RunSearchProps) {
|
||||
const command = useMemo(() => runQueryCommand(range), [range]);
|
||||
export function RunSearch({ value, onChange, runs, range, order = NEWEST }: RunSearchProps) {
|
||||
const command = useMemo(() => runQueryCommand(range, order), [range, order]);
|
||||
return (
|
||||
<SearchBox.Root
|
||||
language={RUN_QUERY}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,24 @@
|
|||
import type { TraceSummary } from "../../types";
|
||||
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
||||
|
||||
import type { RunOrder } from "../runOrder";
|
||||
import { RunSearch } from "./RunSearch";
|
||||
|
||||
interface RunsToolbarProps {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
runs: readonly TraceSummary[];
|
||||
/** The range the list shows, for the copied query. */
|
||||
/** The range and order the list shows, for the copied query. */
|
||||
range?: TimeWindow;
|
||||
order?: RunOrder;
|
||||
/** Extra controls (time range, live tail) rendered on the right. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RunsToolbar({ query, onQueryChange, runs, range, children }: RunsToolbarProps) {
|
||||
export function RunsToolbar({ query, onQueryChange, runs, range, order, children }: RunsToolbarProps) {
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-card">
|
||||
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} />
|
||||
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} order={order} />
|
||||
{children && <div className="flex shrink-0 items-stretch">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { parseQuery } from "@/components/shared/search/language";
|
|||
import { toSearchQuery } from "@/components/shared/search/searchQuery";
|
||||
import { RUN_QUERY } from "./runQuery";
|
||||
import { runPredicates, runQueryCommand, runQuerySql, traceQueryCommand } from "./runSql";
|
||||
import type { RunOrder } from "../runOrder";
|
||||
|
||||
const query = (text: string) => toSearchQuery(parseQuery(RUN_QUERY, text));
|
||||
const predicates = (text: string) => runPredicates(query(text));
|
||||
|
|
@ -52,13 +53,14 @@ describe("runQuerySql", () => {
|
|||
expect(sql).toBe(
|
||||
[
|
||||
"SELECT TraceId AS trace_id, any(RootName) AS name, any(RootInput) AS input, sum(ErrorCount) AS errors,",
|
||||
" groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models",
|
||||
" groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models,",
|
||||
" sum(SpanCount) AS steps, dateDiff('millisecond', min(StartTs), max(EndTs)) AS duration_ms",
|
||||
"FROM agent_traces_by_key",
|
||||
"GROUP BY TraceId",
|
||||
`HAVING min(StartTs) >= fromUnixTimestamp64Milli(${RANGE.startMs}) AND min(StartTs) < fromUnixTimestamp64Milli(${RANGE.endMs})`,
|
||||
" AND arrayExists(x -> x ILIKE 'researcher', agents)",
|
||||
" AND errors > 0",
|
||||
"ORDER BY min(StartTs) DESC",
|
||||
"ORDER BY min(StartTs) DESC, trace_id DESC",
|
||||
"LIMIT 100",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
@ -67,6 +69,16 @@ describe("runQuerySql", () => {
|
|||
it("falls back to the last day without a range", () => {
|
||||
expect(runQuerySql(query(""))).toContain("HAVING min(StartTs) >= now() - INTERVAL 1 DAY\nORDER BY");
|
||||
});
|
||||
|
||||
it.each<{ order: RunOrder; clause: string }>([
|
||||
{ order: { key: "duration_ms", descending: false }, clause: "ORDER BY duration_ms ASC, trace_id ASC" },
|
||||
{ order: { key: "span_count", descending: true }, clause: "ORDER BY steps DESC, trace_id DESC" },
|
||||
{ order: { key: "error_count", descending: true }, clause: "ORDER BY errors DESC, trace_id DESC" },
|
||||
{ order: { key: "start_ms", descending: false }, clause: "ORDER BY min(StartTs) ASC, trace_id ASC" },
|
||||
])("orders the copied rows like the list, $clause", ({ order, clause }) => {
|
||||
expect(runQuerySql(query(""), RANGE, order)).toContain(`\n${clause}\nLIMIT 100`);
|
||||
expect(runQueryCommand(RANGE, order)(query(""))).toContain(clause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("traceQueryCommand", () => {
|
||||
|
|
|
|||
|
|
@ -3,13 +3,28 @@ import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
|||
|
||||
import { isNegatedOp, valueMatcher } from "@/components/shared/search/language";
|
||||
import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery";
|
||||
import { NEWEST, type RunOrder, type RunSortKey } from "../runOrder";
|
||||
import type { RunField } from "./runQuery";
|
||||
|
||||
const RUN_ROWS = `SELECT TraceId AS trace_id, any(RootName) AS name, any(RootInput) AS input, sum(ErrorCount) AS errors,
|
||||
groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models
|
||||
groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models,
|
||||
sum(SpanCount) AS steps, dateDiff('millisecond', min(StartTs), max(EndTs)) AS duration_ms
|
||||
FROM agent_traces_by_key
|
||||
GROUP BY TraceId`;
|
||||
|
||||
const ORDER_COLUMNS: Record<RunSortKey, string> = {
|
||||
start_ms: "min(StartTs)",
|
||||
duration_ms: "duration_ms",
|
||||
span_count: "steps",
|
||||
error_count: "errors",
|
||||
};
|
||||
|
||||
/** The list's order with the same tie-break the server pages by. */
|
||||
const orderBy = (order: RunOrder): string => {
|
||||
const direction = order.descending ? "DESC" : "ASC";
|
||||
return `ORDER BY ${ORDER_COLUMNS[order.key]} ${direction}, trace_id ${direction}`;
|
||||
};
|
||||
|
||||
const sqlString = (value: string): string => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
||||
|
||||
/** LIKE's own metacharacters match literally. */
|
||||
|
|
@ -60,10 +75,10 @@ export const runPredicates = (query: SearchQuery<RunField>): string[] => [
|
|||
...query.filters.map(filterPredicate),
|
||||
];
|
||||
|
||||
/** The runs list as a trace query: one row per trace from the per-key rollup, filtered like the list. */
|
||||
export function runQuerySql(query: SearchQuery<RunField>, range?: TimeWindow): string {
|
||||
/** The runs list as a trace query: one row per trace from the per-key rollup, filtered and ordered like the list. */
|
||||
export function runQuerySql(query: SearchQuery<RunField>, range?: TimeWindow, order: RunOrder = NEWEST): string {
|
||||
const having = [timeBound(range), ...runPredicates(query)].join("\n AND ");
|
||||
return `${RUN_ROWS}\nHAVING ${having}\nORDER BY min(StartTs) DESC\nLIMIT 100`;
|
||||
return `${RUN_ROWS}\nHAVING ${having}\n${orderBy(order)}\nLIMIT 100`;
|
||||
}
|
||||
|
||||
/** Runs `sql` through the trace query API; the quoted heredoc keeps the SQL's own quotes intact. */
|
||||
|
|
@ -76,6 +91,6 @@ export const traceQueryCommand = (sql: string): string =>
|
|||
].join("\n");
|
||||
|
||||
export const runQueryCommand =
|
||||
(range?: TimeWindow) =>
|
||||
(range?: TimeWindow, order: RunOrder = NEWEST) =>
|
||||
(query: SearchQuery<RunField>): string =>
|
||||
traceQueryCommand(runQuerySql(query, range));
|
||||
traceQueryCommand(runQuerySql(query, range, order));
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ import {
|
|||
} from "@/components/shared/timeRange/timeRange";
|
||||
|
||||
import type { TracePage, TraceSummary } from "../types";
|
||||
import type { TraceWindow } from "../api";
|
||||
import type { RunOrder } from "./runOrder";
|
||||
|
||||
interface LoadedTracePage extends TracePage {
|
||||
window: TraceWindow;
|
||||
window: TimeWindow;
|
||||
}
|
||||
|
||||
interface PagePosition {
|
||||
readonly window: TimeWindow;
|
||||
readonly cursor: string;
|
||||
}
|
||||
|
||||
export const TRACING_NOT_ENABLED_STATUS = 501;
|
||||
|
|
@ -46,12 +51,15 @@ interface UseAgentTracesOptions {
|
|||
q: string;
|
||||
/** A window inside the range that replaces it for the list. */
|
||||
zoom: TimeWindow | null;
|
||||
order: RunOrder;
|
||||
}
|
||||
|
||||
export interface AgentTracesResult {
|
||||
traces: TraceSummary[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
/** Rows belong to the previous order, search or window while this one loads. */
|
||||
isPlaceholder: boolean;
|
||||
/** Set when the proxy answered 501: tracing isn't configured. */
|
||||
notEnabledDetail: string | null;
|
||||
error: Error | null;
|
||||
|
|
@ -64,20 +72,30 @@ export interface AgentTracesResult {
|
|||
* GET /v1/traces for the range (or the zoom inside it) and search, cursor-paginated as the runs list scrolls.
|
||||
* A live range rolls on refresh; subsequent pages keep the first page's window.
|
||||
*/
|
||||
export function useAgentTraces({ accessToken, range, enabled, q, zoom }: UseAgentTracesOptions): AgentTracesResult {
|
||||
export function useAgentTraces({
|
||||
accessToken,
|
||||
range,
|
||||
enabled,
|
||||
q,
|
||||
zoom,
|
||||
order,
|
||||
}: UseAgentTracesOptions): AgentTracesResult {
|
||||
const traces = useTracesApi(accessToken);
|
||||
const isLiveTail = isLive(range);
|
||||
const fetchPage = async (pageParam: unknown): Promise<LoadedTracePage> => {
|
||||
const window = (pageParam as TraceWindow | null) ?? zoom ?? timeWindow(range, Date.now());
|
||||
return { ...(await traces.list({ ...window, q })), window };
|
||||
const position = pageParam as PagePosition | null;
|
||||
const window = position?.window ?? zoom ?? timeWindow(range, Date.now());
|
||||
const selection = { window, q };
|
||||
const page = { cursor: position?.cursor ?? null };
|
||||
return { ...(await traces.list({ selection, order, page })), window };
|
||||
};
|
||||
const queryOptions: Parameters<typeof useInfiniteQuery<LoadedTracePage, Error>>[0] = {
|
||||
queryKey: ["agentTraces", traces.scope, range.hours, range.anchorMs, q, zoom],
|
||||
queryKey: ["agentTraces", traces.scope, range.hours, range.anchorMs, q, zoom, order.key, order.descending],
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam }) => fetchPage(pageParam),
|
||||
initialPageParam: null,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.next_cursor ? { ...lastPage.window, cursor: lastPage.next_cursor } : undefined,
|
||||
getNextPageParam: (lastPage): PagePosition | undefined =>
|
||||
lastPage.next_cursor ? { window: lastPage.window, cursor: lastPage.next_cursor } : undefined,
|
||||
enabled,
|
||||
staleTime: LIVE_TAIL_INTERVAL_MS,
|
||||
retry: (failureCount, error) => !requiresUserAction(error) && failureCount < 1,
|
||||
|
|
@ -95,6 +113,7 @@ export function useAgentTraces({ accessToken, range, enabled, q, zoom }: UseAgen
|
|||
traces: loaded,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isPlaceholder: query.isPlaceholderData,
|
||||
notEnabledDetail: notEnabled ? query.error?.message || "Agent tracing is not enabled" : null,
|
||||
error: notEnabled ? null : displayError(query.error),
|
||||
hasMore: query.hasNextPage,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
||||
import { useTracesApi } from "../api";
|
||||
import { type RunSelection, useTracesApi } from "../api";
|
||||
import type { TraceHistogram } from "../types";
|
||||
import { BUCKETS, type TimeBucket } from "@/components/shared/timeline/Timeline";
|
||||
|
||||
|
|
@ -35,17 +35,17 @@ export interface TraceHistogramResult {
|
|||
/** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */
|
||||
export function useTraceHistogram(
|
||||
accessToken: string,
|
||||
range: TimeWindow,
|
||||
q: string,
|
||||
selection: RunSelection,
|
||||
enabled: boolean,
|
||||
): TraceHistogramResult {
|
||||
const traces = useTracesApi(accessToken);
|
||||
const { window, q } = selection;
|
||||
const histogram = useQuery({
|
||||
queryKey: ["agentTraceHistogram", traces.scope, range.startMs, range.endMs, q],
|
||||
queryFn: () => traces.histogram(range, q, BUCKETS),
|
||||
queryKey: ["agentTraceHistogram", traces.scope, window.startMs, window.endMs, q],
|
||||
queryFn: () => traces.histogram(selection, BUCKETS),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
select: toBuckets,
|
||||
});
|
||||
return { buckets: histogram.data ?? emptyBuckets(range), isLoading: histogram.isLoading };
|
||||
return { buckets: histogram.data ?? emptyBuckets(window), isLoading: histogram.isLoading };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NEWEST } from "./list/runOrder";
|
||||
import { useRunOrderRouting } from "./routing";
|
||||
|
||||
const renderRouting = (searchParams: string) => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
const hook = renderHook(() => useRunOrderRouting(), { wrapper });
|
||||
const lastUrl = () => new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString ?? "");
|
||||
return { ...hook, lastUrl, onUrlUpdate };
|
||||
};
|
||||
|
||||
describe("useRunOrderRouting", () => {
|
||||
it("opens newest first when the URL names no order", () => {
|
||||
const { result } = renderRouting("?q=agent:research");
|
||||
expect(result.current[0]).toEqual(NEWEST);
|
||||
});
|
||||
|
||||
it("reads the order the URL names", () => {
|
||||
const { result } = renderRouting("?sort_by=duration_ms&sort_dir=asc");
|
||||
expect(result.current[0]).toEqual({ key: "duration_ms", descending: false });
|
||||
});
|
||||
|
||||
it("falls back to newest first on an order the server cannot page by", () => {
|
||||
const { result } = renderRouting("?sort_by=cost&sort_dir=sideways");
|
||||
expect(result.current[0]).toEqual(NEWEST);
|
||||
});
|
||||
|
||||
it("writes a chosen order to the URL, leaving out whatever matches the default, and drops it back out", async () => {
|
||||
const { result, lastUrl } = renderRouting("");
|
||||
await act(async () => result.current[1]({ key: "error_count", descending: false }));
|
||||
expect(result.current[0]).toEqual({ key: "error_count", descending: false });
|
||||
await waitFor(() => expect(lastUrl().get("sort_by")).toBe("error_count"));
|
||||
expect(lastUrl().get("sort_dir")).toBe("asc");
|
||||
|
||||
await act(async () => result.current[1]({ key: "error_count", descending: true }));
|
||||
await waitFor(() => expect(lastUrl().has("sort_dir")).toBe(false));
|
||||
expect(lastUrl().get("sort_by")).toBe("error_count");
|
||||
|
||||
await act(async () => result.current[1](NEWEST));
|
||||
expect(result.current[0]).toEqual(NEWEST);
|
||||
await waitFor(() => expect(lastUrl().has("sort_by")).toBe(false));
|
||||
expect(lastUrl().has("sort_dir")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { TIME_RANGE_PARSERS } from "@/components/shared/timeRange/routing";
|
||||
import { NEWEST, RUN_SORT_KEYS, type RunOrder, SORT_DIRS, sortDir } from "./list/runOrder";
|
||||
import type { TraceSummary } from "./types";
|
||||
|
||||
export const TRACE_VIEWS = ["steps", "conversation"] as const;
|
||||
|
|
@ -48,6 +49,11 @@ export const RUN_FILTER_PARSERS = {
|
|||
...TIME_RANGE_PARSERS,
|
||||
};
|
||||
|
||||
export const RUN_ORDER_PARSERS = {
|
||||
sort_by: parseAsStringLiteral(RUN_SORT_KEYS).withDefault(NEWEST.key),
|
||||
sort_dir: parseAsStringLiteral(SORT_DIRS).withDefault(sortDir(NEWEST)),
|
||||
};
|
||||
|
||||
export interface OpenTraceRouting {
|
||||
trace: TraceRef | null;
|
||||
openTrace: (ref: TraceRef | null) => void;
|
||||
|
|
@ -127,3 +133,14 @@ export function useRunFilterRouting(): { query: string; setQuery: (query: string
|
|||
const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q);
|
||||
return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) };
|
||||
}
|
||||
|
||||
/** The order the runs list is cut in. Newest first is the default and stays out of the URL. */
|
||||
export function useRunOrderRouting(): [RunOrder, (order: RunOrder) => void] {
|
||||
const [{ sort_by, sort_dir }, setParams] = useQueryStates(RUN_ORDER_PARSERS);
|
||||
const order = useMemo<RunOrder>(() => ({ key: sort_by, descending: sort_dir === "desc" }), [sort_by, sort_dir]);
|
||||
const setOrder = useCallback(
|
||||
(next: RunOrder) => void setParams({ sort_by: next.key, sort_dir: sortDir(next) }),
|
||||
[setParams],
|
||||
);
|
||||
return [order, setOrder];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity
|
|||
import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets";
|
||||
import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab";
|
||||
import type { RoutingDecision } from "./logs/detail/RoutingDecisionCard";
|
||||
import type { RunListRequest } from "./lens/traces/api";
|
||||
import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./lens/traces/types";
|
||||
import {
|
||||
createApiClient,
|
||||
|
|
@ -1957,20 +1958,18 @@ export const uiSpendLogsCall = async ({
|
|||
* Agent tracing. All three respond 501 `{detail}` when `general_settings.tracing` is not
|
||||
* configured; callers can detect that through the thrown `ApiError`'s `status`.
|
||||
*/
|
||||
export const agentTraceListCall = async ({
|
||||
accessToken,
|
||||
startMs,
|
||||
endMs,
|
||||
q,
|
||||
cursor,
|
||||
}: {
|
||||
accessToken: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
q: string;
|
||||
cursor?: string | null;
|
||||
}): Promise<TracePage> => {
|
||||
const query = { start_ms: startMs, end_ms: endMs, q: q || undefined, cursor: cursor ?? undefined };
|
||||
export const agentTraceListCall = async (
|
||||
accessToken: string,
|
||||
{ selection, order, page }: RunListRequest,
|
||||
): Promise<TracePage> => {
|
||||
const query = {
|
||||
start_ms: selection.window.startMs,
|
||||
end_ms: selection.window.endMs,
|
||||
q: selection.q || undefined,
|
||||
sort_by: order.key,
|
||||
sort_dir: order.descending ? "desc" : "asc",
|
||||
cursor: page.cursor ?? undefined,
|
||||
};
|
||||
return apiClient.get<TracePage>(`/v1/traces`, { accessToken, query });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import "@/components/shared/DataTable/columnMeta";
|
||||
|
||||
import { flexRender, type Row as TanStackRow, type Table as TanStackTable } from "@tanstack/react-table";
|
||||
import { type Column, flexRender, type Row as TanStackRow, type Table as TanStackTable } from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { createContext, Fragment, useContext, useState, type ComponentProps, type ReactNode } from "react";
|
||||
|
|
@ -63,7 +63,21 @@ function Grid({ className, children, ...props }: ComponentProps<"table">) {
|
|||
);
|
||||
}
|
||||
|
||||
function Header({ hidden = false }: { readonly hidden?: boolean }) {
|
||||
const ARIA_SORT = { asc: "ascending", desc: "descending" } as const;
|
||||
|
||||
function ariaSort<T>(column: Column<T>): "ascending" | "descending" | "none" | undefined {
|
||||
if (!column.getCanSort()) return undefined;
|
||||
const sorted = column.getIsSorted();
|
||||
return sorted === false ? "none" : ARIA_SORT[sorted];
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
readonly hidden?: boolean;
|
||||
/** Shows a progress line under the headings while the rows below are being replaced. */
|
||||
readonly busy?: boolean;
|
||||
}
|
||||
|
||||
function Header({ hidden = false, busy = false }: HeaderProps) {
|
||||
const { table } = useInspectorTable();
|
||||
return (
|
||||
<TableHeader
|
||||
|
|
@ -74,6 +88,7 @@ function Header({ hidden = false }: { readonly hidden?: boolean }) {
|
|||
{group.headers.map(({ id, column, isPlaceholder, getContext }) => (
|
||||
<TableHead
|
||||
key={id}
|
||||
aria-sort={ariaSort(column)}
|
||||
className={cn(
|
||||
"h-auto px-3 text-muted-foreground",
|
||||
column.columnDef.meta?.numeric && NUMERIC,
|
||||
|
|
@ -85,6 +100,17 @@ function Header({ hidden = false }: { readonly hidden?: boolean }) {
|
|||
))}
|
||||
</tr>
|
||||
))}
|
||||
{busy && (
|
||||
<tr className="h-0.5">
|
||||
<th colSpan={table.getVisibleLeafColumns().length} className="p-0">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Updating rows"
|
||||
className="h-0.5 w-full animate-pulse bg-primary/70 motion-reduce:animate-none"
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
</TableHeader>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,9 +119,10 @@ interface BodyProps<T> {
|
|||
readonly rowHeight: (row: TanStackRow<T>) => number;
|
||||
readonly children: (row: TanStackRow<T>) => ReactNode;
|
||||
readonly after?: ReactNode;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
function Body<T>({ rowHeight, children, after }: BodyProps<T>) {
|
||||
function Body<T>({ rowHeight, children, after, className }: BodyProps<T>) {
|
||||
const { table, scroller } = useInspectorTable<T>();
|
||||
const rows = table.getRowModel().rows;
|
||||
const virtualizerOptions = {
|
||||
|
|
@ -110,7 +137,7 @@ function Body<T>({ rowHeight, children, after }: BodyProps<T>) {
|
|||
const padTop = items[0]?.start ?? 0;
|
||||
const padBottom = virtualizer.getTotalSize() - (items.at(-1)?.end ?? 0);
|
||||
return (
|
||||
<TableBody>
|
||||
<TableBody className={className}>
|
||||
{padTop > 0 && <tr aria-hidden style={{ height: padTop }} />}
|
||||
{items.map(({ key, index }) => (
|
||||
<Fragment key={key}>{children(rows[index])}</Fragment>
|
||||
|
|
|
|||
|
|
@ -184,6 +184,9 @@ if (typeof window !== "undefined") {
|
|||
if (!Element.prototype.getAnimations) {
|
||||
Element.prototype.getAnimations = () => [];
|
||||
}
|
||||
if (!Element.prototype.scrollTo) {
|
||||
Element.prototype.scrollTo = () => {};
|
||||
}
|
||||
|
||||
// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests
|
||||
if (!URL.revokeObjectURL) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue