refactor(ui): move the time range model and controls out of Lens

The preset list, the window type, the hours/from/to URL parsers and the
range + Live control group were spread over four Lens files and spoke in
moment strings. They now live in shared/timeRange as a RelativeRange
(preset hours, live or anchored) that the runs query resolves to a
window at fetch time, so the Logs page can pick up the same controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 16:30:38 -07:00
parent 1d0397f1c0
commit 3d049cd4a8
18 changed files with 229 additions and 297 deletions

View file

@ -1,5 +1,6 @@
import { ApiError } from "@/lib/http/client"; import { ApiError } from "@/lib/http/client";
import type { TimeRange, TracesApi } from "@/components/lens/traces/api"; import type { TracesApi } from "@/components/lens/traces/api";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { filterRuns, RUN_INDEX } from "@/components/lens/traces/list/runSearch/runQuery"; import { filterRuns, RUN_INDEX } from "@/components/lens/traces/list/runSearch/runQuery";
import type { TraceHistogram, TraceSummary } from "@/components/lens/traces/types"; import type { TraceHistogram, TraceSummary } from "@/components/lens/traces/types";
import { traceAgentNames } from "@/components/lens/traces/utils"; import { traceAgentNames } from "@/components/lens/traces/utils";
@ -41,13 +42,13 @@ function demoLensApi(data: LensDemoData): LensApi {
}; };
} }
const startedWithin = (run: TraceSummary, range: TimeRange): boolean => { const startedWithin = (run: TraceSummary, range: TimeWindow): boolean => {
const startMs = Date.parse(run.start_time); const startMs = Date.parse(run.start_time);
return startMs >= range.startMs && startMs < range.endMs; return startMs >= range.startMs && startMs < range.endMs;
}; };
/** Runs per equal-width slice of the window, the shape the server's histogram returns. */ /** Runs per equal-width slice of the window, the shape the server's histogram returns. */
export function demoHistogram(runs: readonly TraceSummary[], range: TimeRange, buckets: number): TraceHistogram { export function demoHistogram(runs: readonly TraceSummary[], range: TimeWindow, buckets: number): TraceHistogram {
const width = (range.endMs - range.startMs) / buckets; const width = (range.endMs - range.startMs) / buckets;
const placed = runs.map((run) => ({ const placed = runs.map((run) => ({
index: Math.floor((Date.parse(run.start_time) - range.startMs) / width), index: Math.floor((Date.parse(run.start_time) - range.startMs) / width),
@ -75,7 +76,7 @@ export function demoHistogram(runs: readonly TraceSummary[], range: TimeRange, b
function demoTracesApi(data: LensDemoData): TracesApi { function demoTracesApi(data: LensDemoData): TracesApi {
const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId); const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
const summaries = data.runs.map((item) => item.trace.summary); const summaries = data.runs.map((item) => item.trace.summary);
const matching = (range: TimeRange, q: string) => const matching = (range: TimeWindow, q: string) =>
filterRuns( filterRuns(
summaries.filter((summary) => startedWithin(summary, range)), summaries.filter((summary) => startedWithin(summary, range)),
q, q,

View file

@ -9,14 +9,10 @@ import {
apiClient, apiClient,
getProxyBaseUrl, getProxyBaseUrl,
} from "../../networking"; } from "../../networking";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import type { RunField, SpanDetail, SpanErrorPage, Trace, TraceHistogram, TracePage } from "./types"; import type { RunField, SpanDetail, SpanErrorPage, Trace, TraceHistogram, TracePage } from "./types";
export interface TimeRange { export interface TraceWindow extends TimeWindow {
readonly startMs: number;
readonly endMs: number;
}
export interface TraceWindow extends TimeRange {
readonly cursor?: string | null; readonly cursor?: string | null;
} }
@ -37,8 +33,8 @@ export interface TracesApi {
readonly live: boolean; readonly live: boolean;
handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff; handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff;
list(request: RunListRequest): Promise<TracePage>; list(request: RunListRequest): Promise<TracePage>;
histogram(range: TimeRange, q: string, buckets: number): Promise<TraceHistogram>; histogram(range: TimeWindow, q: string, buckets: number): Promise<TraceHistogram>;
values(field: RunField, contains: string, range: TimeRange): Promise<readonly string[]>; values(field: RunField, contains: string, range: TimeWindow): Promise<readonly string[]>;
anyRecorded(): Promise<boolean>; anyRecorded(): Promise<boolean>;
trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>; trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>;
span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>; span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>;

View file

@ -1,13 +1,9 @@
"use client"; "use client";
import moment from "moment"; import { useRelativeRange } from "@/components/shared/timeRange/useRelativeRange";
import { useMemo, useState } from "react";
import { AgentTracesSection } from "./AgentTracesSection"; import { AgentTracesSection } from "./AgentTracesSection";
import { useTracesLive } from "../api"; import { useTracesLive } from "../api";
import { useRangeHoursRouting } from "../routing";
const TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss";
export default function AgentTracesPage({ export default function AgentTracesPage({
accessToken, accessToken,
@ -20,42 +16,16 @@ export default function AgentTracesPage({
readOnly?: boolean; readOnly?: boolean;
canMintTracingKey?: boolean; canMintTracingKey?: boolean;
}) { }) {
const sourceLive = useTracesLive(); const time = useRelativeRange(useTracesLive());
const [rangeHours, setRangeHours] = useRangeHoursRouting();
const [live, setLive] = useState(true);
const [anchor, setAnchor] = useState(() => moment());
const { startTime, endTime } = useMemo(
() => ({
startTime: anchor.clone().subtract(rangeHours, "hours").format(TIME_FORMAT),
endTime: anchor.format(TIME_FORMAT),
}),
[anchor, rangeHours],
);
const isLiveTail = live && sourceLive;
const changeRange = (hours: number) => {
setRangeHours(hours);
setAnchor(moment());
};
const changeLive = (next: boolean) => {
setLive(next);
setAnchor(moment());
};
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<AgentTracesSection <AgentTracesSection
accessToken={accessToken} accessToken={accessToken}
isActive={isActive} isActive={isActive}
startTime={startTime} range={time.range}
endTime={endTime}
isCustomDate={!isLiveTail}
isLiveTail={isLiveTail}
readOnly={readOnly} readOnly={readOnly}
canMintTracingKey={canMintTracingKey} canMintTracingKey={canMintTracingKey}
timeControls={{ rangeHours, onRangeHoursChange: changeRange, onLiveChange: changeLive }} timeControls={time}
/> />
</div> </div>
); );

View file

@ -66,32 +66,21 @@ const serve = (data: readonly TraceSummary[]) => {
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) => const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")); new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
/** 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.
const PINNED_DAY = { hours: 24, anchorMs: Date.parse("2026-10-01T00:00Z") };
const renderSection = () => const renderSection = () =>
renderWithProviders( renderWithProviders(
<LensPreviewContext.Provider value={{ target: document.body, open: vi.fn() }}> <LensPreviewContext.Provider value={{ target: document.body, open: vi.fn() }}>
<AgentTracesSection <AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>
</LensPreviewContext.Provider>, </LensPreviewContext.Provider>,
); );
// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone.
const renderWindowed = (timeControls?: TimeControls) => const renderWindowed = (timeControls?: TimeControls) =>
renderWithProviders( renderWithProviders(
<AgentTracesSection <AgentTracesSection accessToken="sk-test" isActive range={PINNED_DAY} timeControls={timeControls} />,
accessToken="sk-test"
isActive
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
timeControls={timeControls}
/>,
); );
const bucketRunCounts = () => const bucketRunCounts = () =>
@ -164,16 +153,7 @@ describe("AgentTracesSection", () => {
])("stops live polling after HTTP %s and explains how to recover", async (status, message) => { ])("stops live polling after HTTP %s and explains how to recover", async (status, message) => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Private token details", Number(status), {})); vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Private token details", Number(status), {}));
renderWithProviders( renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />);
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail
/>,
);
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
}); });
@ -216,17 +196,7 @@ describe("AgentTracesSection", () => {
])("keeps the runs controls instead of onboarding for %s", async (_, searchParams) => { ])("keeps the runs controls instead of onboarding for %s", async (_, searchParams) => {
pinNowToFixtures(); pinNowToFixtures();
serve(runs); serve(runs);
renderWithProviders( renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={PINNED_DAY} />, { searchParams });
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
/>,
{ searchParams },
);
expect(await screen.findByTestId("runs-table")).toBeInTheDocument(); expect(await screen.findByTestId("runs-table")).toBeInTheDocument();
await waitFor(() => expect(agentTraceListCall).toHaveBeenCalled()); await waitFor(() => expect(agentTraceListCall).toHaveBeenCalled());
expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0); expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0);
@ -478,17 +448,10 @@ describe("AgentTracesSection", () => {
it("opens full screen from a shared link and drops it from the URL on close", async () => { it("opens full screen from a shared link and drops it from the URL on close", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn(); const onUrlUpdate = vi.fn();
renderWithProviders( renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />, {
<AgentTracesSection searchParams: `?trace=${runs[0].trace_id}&fullscreen=true`,
accessToken="sk-test" onUrlUpdate,
isActive });
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: `?trace=${runs[0].trace_id}&fullscreen=true`, onUrlUpdate },
);
const drawer = await screen.findByRole("complementary", { name: "Trace details" }); const drawer = await screen.findByRole("complementary", { name: "Trace details" });
expect(drawer).toHaveStyle({ width: "100%" }); expect(drawer).toHaveStyle({ width: "100%" });
fireEvent.click(screen.getByRole("button", { name: "Close trace (Esc)" })); fireEvent.click(screen.getByRole("button", { name: "Close trace (Esc)" }));
@ -501,17 +464,10 @@ describe("AgentTracesSection", () => {
const startMs = Date.parse(runs[0].start_time); const startMs = Date.parse(runs[0].start_time);
const inWindow = runs.filter((run) => Math.abs(Date.parse(run.start_time) - startMs) <= 1); const inWindow = runs.filter((run) => Math.abs(Date.parse(run.start_time) - startMs) <= 1);
const onUrlUpdate = vi.fn(); const onUrlUpdate = vi.fn();
renderWithProviders( renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={PINNED_DAY} />, {
<AgentTracesSection searchParams: `?from=${startMs - 1}&to=${startMs + 1}`,
accessToken="sk-test" onUrlUpdate,
isActive });
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
/>,
{ searchParams: `?from=${startMs - 1}&to=${startMs + 1}`, onUrlUpdate },
);
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(inWindow.length)); await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(inWindow.length));
expect(inWindow.length).toBeLessThan(runs.length); expect(inWindow.length).toBeLessThan(runs.length);
fireEvent.click(screen.getByRole("button", { name: "Clear time zoom" })); fireEvent.click(screen.getByRole("button", { name: "Clear time zoom" }));
@ -522,17 +478,10 @@ describe("AgentTracesSection", () => {
it("opens the run named by ?trace= even when it is outside the loaded list, and clears it on close", async () => { it("opens the run named by ?trace= even when it is outside the loaded list, and clears it on close", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn(); const onUrlUpdate = vi.fn();
renderWithProviders( renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />, {
<AgentTracesSection searchParams: "?trace=older-than-the-list&trace_ref=ref-9",
accessToken="sk-test" onUrlUpdate,
isActive });
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?trace=older-than-the-list&trace_ref=ref-9", onUrlUpdate },
);
const drawer = await screen.findByRole("complementary", { name: "Trace details" }); const drawer = await screen.findByRole("complementary", { name: "Trace details" });
expect(within(drawer).getByTestId("run-view")).toHaveTextContent("run older-than-the-list"); expect(within(drawer).getByTestId("run-view")).toHaveTextContent("run older-than-the-list");
expect(screen.getByRole("button", { name: "Next trace (J)" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Next trace (J)" })).toBeDisabled();
@ -555,17 +504,10 @@ describe("AgentTracesSection", () => {
serve(runs); serve(runs);
const onUrlUpdate = vi.fn(); const onUrlUpdate = vi.fn();
const failed = filterRuns(runs, "status:error"); const failed = filterRuns(runs, "status:error");
renderWithProviders( renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />, {
<AgentTracesSection searchParams: "?q=status:error",
accessToken="sk-test" onUrlUpdate,
isActive });
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?q=status:error", onUrlUpdate },
);
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length); expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length);
expect(failed.length).toBeLessThan(runs.length); expect(failed.length).toBeLessThan(runs.length);
const search = screen.getByRole("combobox", { name: "Search runs" }); const search = screen.getByRole("combobox", { name: "Search runs" });
@ -662,7 +604,7 @@ describe("AgentTracesPage", () => {
expect(trigger).toHaveTextContent(/ to /); expect(trigger).toHaveTextContent(/ to /);
expect(trigger).not.toHaveTextContent("Last 7 days"); expect(trigger).not.toHaveTextContent("Last 7 days");
await waitFor(() => expect(vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0].endMs).toBe(pausedAt - 500)); await waitFor(() => expect(vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0].endMs).toBe(pausedAt));
}); });
it("keeps the time controls on an empty range the user picked, instead of showing onboarding", async () => { it("keeps the time controls on an empty range the user picked, instead of showing onboarding", async () => {

View file

@ -8,39 +8,28 @@ import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable"; import { AgentTracesTable } from "./AgentTracesTable";
import { import { type TraceRef, traceKey, traceRefOf, useOpenTraceRouting, useRunFilterRouting } from "../routing";
type TraceRef,
traceKey,
traceRefOf,
useOpenTraceRouting,
useRunFilterRouting,
useZoomRouting,
} from "../routing";
import type { TraceSummary } from "../types"; import type { TraceSummary } from "../types";
import { RunView } from "../detail/run/RunView"; import { RunView } from "../detail/run/RunView";
import { TimeRangeControls } from "./TimeRangeControls"; import { useZoomRouting } from "@/components/shared/timeRange/routing";
import { type RelativeRange, timeWindow } from "@/components/shared/timeRange/timeRange";
import { TimeRangeControls } from "@/components/shared/timeRange/TimeRangeControls";
import type { RelativeRangeState } from "@/components/shared/timeRange/useRelativeRange";
import { TracesTimeline } from "./TracesTimeline"; import { TracesTimeline } from "./TracesTimeline";
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard"; import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
import { useTracesLive } from "../api"; import { useTracesLive } from "../api";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces"; import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
import { useTraceHistogram } from "./useTraceHistogram"; import { useTraceHistogram } from "./useTraceHistogram";
const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth"; const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth";
export interface TimeControls { export type TimeControls = Pick<RelativeRangeState, "setHours" | "setLive">;
rangeHours: number;
onRangeHoursChange: (hours: number) => void;
onLiveChange: (live: boolean) => void;
}
interface AgentTracesSectionProps { interface AgentTracesSectionProps {
accessToken: string; accessToken: string;
isActive: boolean; isActive: boolean;
startTime: string; range: RelativeRange;
endTime: string; /** Page-owned range setters; when given, the toolbar shows the range / Live control group. */
isCustomDate: boolean;
isLiveTail: boolean;
/** Page-owned time range + live state; when given, the toolbar shows the range / Live control group. */
timeControls?: TimeControls; timeControls?: TimeControls;
readOnly?: boolean; readOnly?: boolean;
canMintTracingKey?: boolean; canMintTracingKey?: boolean;
@ -77,10 +66,7 @@ function TraceHistoryError({ history }: { history: ReturnType<typeof useTraceAva
export function AgentTracesSection({ export function AgentTracesSection({
accessToken, accessToken,
isActive, isActive,
startTime, range,
endTime,
isCustomDate,
isLiveTail,
timeControls, timeControls,
readOnly = false, readOnly = false,
canMintTracingKey = false, canMintTracingKey = false,
@ -91,7 +77,7 @@ export function AgentTracesSection({
const [showSetup, setShowSetup] = useState(false); const [showSetup, setShowSetup] = useState(false);
const [zoom, setZoom] = useZoomRouting(); const [zoom, setZoom] = useZoomRouting();
const [rangeChanged, setRangeChanged] = useState(false); const [rangeChanged, setRangeChanged] = useState(false);
const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive, q: query, zoom }; const traceQuery = { accessToken, range, enabled: isActive, q: query, zoom };
const traces = useAgentTraces(traceQuery); const traces = useAgentTraces(traceQuery);
const narrowed = rangeChanged || zoom !== null || query.trim() !== ""; const narrowed = rangeChanged || zoom !== null || query.trim() !== "";
const setup = useTracingSetup(traces, isActive, narrowed); const setup = useTracingSetup(traces, isActive, narrowed);
@ -103,17 +89,14 @@ export function AgentTracesSection({
if (setup.disabledDetail == null) void history.refetch(); if (setup.disabledDetail == null) void history.refetch();
}; };
// Relative ranges end "now" (the list query uses Date.now() too); round to the minute so the histogram is stable. // A live range ends "now" (the list query uses Date.now() too); round to the minute so the histogram is stable.
const endMs = isCustomDate ? moment(endTime).valueOf() : moment().endOf("minute").valueOf(); const minuteEndMs = moment().endOf("minute").valueOf();
const range = useMemo( const window = useMemo(() => timeWindow(range, minuteEndMs), [range, minuteEndMs]);
() => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }), const histogram = useTraceHistogram(accessToken, window, query, isActive);
[startTime, endTime, isCustomDate, endMs],
);
const histogram = useTraceHistogram(accessToken, range, query, isActive);
const runs = traces.traces; const runs = traces.traces;
const runRefs = useMemo(() => runs.map(traceRefOf), [runs]); const runRefs = useMemo(() => runs.map(traceRefOf), [runs]);
const changeRange = (hours: number, apply: (hours: number) => void) => { const changeHours = (hours: number, apply: (hours: number) => void) => {
setZoom(null); setZoom(null);
setRangeChanged(true); setRangeChanged(true);
apply(hours); apply(hours);
@ -179,22 +162,21 @@ export function AgentTracesSection({
/> />
)} )}
</Inspector.Panel> </Inspector.Panel>
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces} range={zoom ?? range}> <RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces} range={zoom ?? window}>
{timeControls && ( {timeControls && (
<TimeRangeControls <TimeRangeControls
fixedRange={zoom ?? (isLiveTail ? null : range)} range={range}
rangeHours={timeControls.rangeHours} zoom={zoom}
onRangeHoursChange={(hours) => changeRange(hours, timeControls.onRangeHoursChange)} onHoursChange={(hours) => changeHours(hours, timeControls.setHours)}
live={isLiveTail}
showLive={live} showLive={live}
onLiveChange={timeControls.onLiveChange} onLiveChange={timeControls.setLive}
/> />
)} )}
</RunsToolbar> </RunsToolbar>
<TracesTimeline <TracesTimeline
buckets={histogram.buckets} buckets={histogram.buckets}
loading={histogram.isLoading} loading={histogram.isLoading}
range={range} range={window}
selection={zoom} selection={zoom}
onSelect={setZoom} onSelect={setZoom}
/> />

View file

@ -17,9 +17,7 @@ const DAY_MS = 24 * HOUR_MS;
const EDGE_FORMAT = "MMM DD, HH:mm"; const EDGE_FORMAT = "MMM DD, HH:mm";
const SKELETON_COLUMNS = skeletonColumns(BUCKETS); const SKELETON_COLUMNS = skeletonColumns(BUCKETS);
import type { TimeRange as TimeWindow } from "../api"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
export type { TimeWindow };
export interface Bucket extends DotColumn { export interface Bucket extends DotColumn {
startMs: number; startMs: number;

View file

@ -3,7 +3,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { TraceSummary } from "../../types"; import type { TraceSummary } from "../../types";
import type { TimeWindow } from "../TracesTimeline"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { SearchBox } from "@/components/shared/search/SearchBox"; import { SearchBox } from "@/components/shared/search/SearchBox";
import { itemValues } from "@/components/shared/search/valueSource"; import { itemValues } from "@/components/shared/search/valueSource";

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import type { TraceSummary } from "../../types"; import type { TraceSummary } from "../../types";
import type { TimeWindow } from "../TracesTimeline"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { RunSearch } from "./RunSearch"; import { RunSearch } from "./RunSearch";

View file

@ -1,5 +1,5 @@
import { getProxyBaseUrl } from "@/components/networking"; import { getProxyBaseUrl } from "@/components/networking";
import type { TimeWindow } from "../TracesTimeline"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { isNegatedOp, valueMatcher } from "@/components/shared/search/language"; import { isNegatedOp, valueMatcher } from "@/components/shared/search/language";
import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery"; import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery";

View file

@ -1,30 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { traceWindowStartMs } from "./useAgentTraces";
import { spanLogWindow } from "../detail/useSpanRequestLog"; import { spanLogWindow } from "../detail/useSpanRequestLog";
const HOUR = 3600 * 1000;
describe("traceWindowStartMs", () => {
it("rolls a preset range forward with now so live tail keeps a fixed-length window", () => {
const start = "2026-09-29T10:00";
const end = "2026-09-30T10:00";
const mountedAt = Date.parse("2026-09-30T10:00:00");
const tenHoursLater = mountedAt + 10 * HOUR;
expect(tenHoursLater - traceWindowStartMs(start, end, false, tenHoursLater)).toBe(24 * HOUR);
expect(traceWindowStartMs(start, end, false, tenHoursLater)).toBeGreaterThan(
traceWindowStartMs(start, end, false, mountedAt),
);
});
it("keeps a custom range pinned to what the user picked", () => {
const start = "2026-09-01T00:00";
expect(traceWindowStartMs(start, "2026-09-02T00:00", true, Date.parse("2026-09-30T00:00:00"))).toBe(
Date.parse(start),
);
});
});
describe("spanLogWindow", () => { describe("spanLogWindow", () => {
it("looks up the request log around the span's own time, not the logs tab window", () => { it("looks up the request log around the span's own time, not the logs tab window", () => {
const spanStart = Date.parse("2026-08-01T12:00:00Z"); const spanStart = Date.parse("2026-08-01T12:00:00Z");

View file

@ -1,14 +1,18 @@
import { useTracesApi } from "../api"; import { useTracesApi } from "../api";
import { keepPreviousData, useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import moment from "moment";
import { useMemo } from "react"; import { useMemo } from "react";
import { ApiError } from "@/lib/http/client"; import { ApiError } from "@/lib/http/client";
import {
isLive,
LIVE_TAIL_INTERVAL_MS,
type RelativeRange,
type TimeWindow,
timeWindow,
} from "@/components/shared/timeRange/timeRange";
import type { TracePage, TraceSummary } from "../types"; import type { TracePage, TraceSummary } from "../types";
import type { TimeRange, TraceWindow } from "../api"; import type { TraceWindow } from "../api";
const LIVE_TAIL_INTERVAL_MS = 15000;
interface LoadedTracePage extends TracePage { interface LoadedTracePage extends TracePage {
window: TraceWindow; window: TraceWindow;
@ -36,15 +40,12 @@ const displayError = (error: Error | null): Error | null => {
interface UseAgentTracesOptions { interface UseAgentTracesOptions {
accessToken: string; accessToken: string;
startTime: string; range: RelativeRange;
endTime: string;
isCustomDate: boolean;
isLiveTail: boolean;
enabled: boolean; enabled: boolean;
/** The search the server applies before paging. */ /** The search the server applies before paging. */
q: string; q: string;
/** A window inside the range that replaces it for the list. */ /** A window inside the range that replaces it for the list. */
zoom: TimeRange | null; zoom: TimeWindow | null;
} }
export interface AgentTracesResult { export interface AgentTracesResult {
@ -59,36 +60,19 @@ export interface AgentTracesResult {
refetch: () => void; refetch: () => void;
} }
/** Start of the fetch window: a preset range rolls with "now", so live tail keeps a fixed-length window. */
export const traceWindowStartMs = (startTime: string, endTime: string, isCustomDate: boolean, nowMs: number): number =>
isCustomDate ? moment(startTime).valueOf() : nowMs - (moment(endTime).valueOf() - moment(startTime).valueOf());
/** /**
* GET /v1/traces for the Logs page time range (or the zoom inside it) and search, cursor-paginated as the runs * GET /v1/traces for the range (or the zoom inside it) and search, cursor-paginated as the runs list scrolls.
* list scrolls. Preset ranges roll on refresh; subsequent pages keep the first page's window. * A live range rolls on refresh; subsequent pages keep the first page's window.
*/ */
export function useAgentTraces({ export function useAgentTraces({ accessToken, range, enabled, q, zoom }: UseAgentTracesOptions): AgentTracesResult {
accessToken,
startTime,
endTime,
isCustomDate,
isLiveTail,
enabled,
q,
zoom,
}: UseAgentTracesOptions): AgentTracesResult {
const traces = useTracesApi(accessToken); const traces = useTracesApi(accessToken);
const isLiveTail = isLive(range);
const fetchPage = async (pageParam: unknown): Promise<LoadedTracePage> => { const fetchPage = async (pageParam: unknown): Promise<LoadedTracePage> => {
const nowMs = Date.now(); const window = (pageParam as TraceWindow | null) ?? zoom ?? timeWindow(range, Date.now());
const window = (pageParam as TraceWindow | null) ??
zoom ?? {
startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs),
endMs: isCustomDate ? moment(endTime).valueOf() : nowMs,
};
return { ...(await traces.list({ ...window, q })), window }; return { ...(await traces.list({ ...window, q })), window };
}; };
const queryOptions: Parameters<typeof useInfiniteQuery<LoadedTracePage, Error>>[0] = { const queryOptions: Parameters<typeof useInfiniteQuery<LoadedTracePage, Error>>[0] = {
queryKey: ["agentTraces", traces.scope, startTime, endTime, isCustomDate, q, zoom], queryKey: ["agentTraces", traces.scope, range.hours, range.anchorMs, q, zoom],
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: ({ pageParam }) => fetchPage(pageParam), queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: null, initialPageParam: null,

View file

@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { TimeRange } from "../api"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { useTracesApi } from "../api"; import { useTracesApi } from "../api";
import type { TraceHistogram } from "../types"; import type { TraceHistogram } from "../types";
import { BUCKETS, type Bucket } from "./TracesTimeline"; import { BUCKETS, type Bucket } from "./TracesTimeline";
@ -15,7 +15,7 @@ export const toBuckets = (histogram: TraceHistogram): Bucket[] =>
series: bucket.agents.flatMap(({ agent, runs }) => Array<string>(runs).fill(agent)), series: bucket.agents.flatMap(({ agent, runs }) => Array<string>(runs).fill(agent)),
})); }));
const emptyBuckets = (range: TimeRange): Bucket[] => { const emptyBuckets = (range: TimeWindow): Bucket[] => {
const width = (range.endMs - range.startMs) / BUCKETS; const width = (range.endMs - range.startMs) / BUCKETS;
return Array.from({ length: BUCKETS }, (_, i) => ({ return Array.from({ length: BUCKETS }, (_, i) => ({
startMs: range.startMs + i * width, startMs: range.startMs + i * width,
@ -35,7 +35,7 @@ export interface TraceHistogramResult {
/** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */ /** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */
export function useTraceHistogram( export function useTraceHistogram(
accessToken: string, accessToken: string,
range: TimeRange, range: TimeWindow,
q: string, q: string,
enabled: boolean, enabled: boolean,
): TraceHistogramResult { ): TraceHistogramResult {

View file

@ -1,16 +1,7 @@
import { import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs";
parseAsBoolean,
parseAsInteger,
parseAsNumberLiteral,
parseAsString,
parseAsStringLiteral,
useQueryState,
useQueryStates,
} from "nuqs";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { RANGE_PRESETS } from "./list/TimeRangeControls"; import { TIME_RANGE_PARSERS } from "@/components/shared/timeRange/routing";
import type { TimeWindow } from "./list/TracesTimeline";
import type { TraceSummary } from "./types"; import type { TraceSummary } from "./types";
export const TRACE_VIEWS = ["steps", "conversation"] as const; export const TRACE_VIEWS = ["steps", "conversation"] as const;
@ -19,11 +10,6 @@ export type TraceView = (typeof TRACE_VIEWS)[number];
export const SPAN_TABS = ["content", "request", "attributes"] as const; export const SPAN_TABS = ["content", "request", "attributes"] as const;
export type SpanTab = (typeof SPAN_TABS)[number]; export type SpanTab = (typeof SPAN_TABS)[number];
const RANGE_HOURS = RANGE_PRESETS.map((preset) => preset.hours);
type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
export const DEFAULT_RANGE_HOURS = 24;
export interface TraceRef { export interface TraceRef {
traceId: string; traceId: string;
traceRef?: string; traceRef?: string;
@ -59,9 +45,7 @@ export const OPEN_TRACE_PARSERS = {
export const RUN_FILTER_PARSERS = { export const RUN_FILTER_PARSERS = {
q: parseAsString.withDefault(""), q: parseAsString.withDefault(""),
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS), ...TIME_RANGE_PARSERS,
from: parseAsInteger,
to: parseAsInteger,
}; };
export interface OpenTraceRouting { export interface OpenTraceRouting {
@ -143,19 +127,3 @@ export function useRunFilterRouting(): { query: string; setQuery: (query: string
const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q); const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q);
return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) }; return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) };
} }
export function useRangeHoursRouting(): [number, (hours: number) => void] {
const [hours, setHours] = useQueryState("hours", RUN_FILTER_PARSERS.hours);
const setRangeHours = useCallback((next: number) => void (isRangeHours(next) && setHours(next)), [setHours]);
return [hours, setRangeHours];
}
/** A timeline brush narrows the list to a window inside the range; it is dropped whenever the range changes. */
export function useZoomRouting(): [TimeWindow | null, (zoom: TimeWindow | null) => void] {
const [{ from, to }, setParams] = useQueryStates({ from: RUN_FILTER_PARSERS.from, to: RUN_FILTER_PARSERS.to });
const setZoom = useCallback(
(zoom: TimeWindow | null) => void setParams({ from: zoom?.startMs ?? null, to: zoom?.endMs ?? null }),
[setParams],
);
return [from !== null && to !== null && from < to ? { startMs: from, endMs: to } : null, setZoom];
}

View file

@ -12,59 +12,47 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config"; import { cn } from "@/lib/cva.config";
import type { TimeWindow } from "./TracesTimeline"; import { isLive, presetLabel, RANGE_PRESETS, type RelativeRange, type TimeWindow, timeWindow } from "./timeRange";
export const RANGE_PRESETS = [
{ hours: 1, label: "Last hour" },
{ hours: 6, label: "Last 6 hours" },
{ hours: 24, label: "Last 24 hours" },
{ hours: 168, label: "Last 7 days" },
{ hours: 720, label: "Last 30 days" },
] as const;
const RANGE_LABEL_FORMAT = "MMM D, h:mm A"; const RANGE_LABEL_FORMAT = "MMM D, h:mm A";
const fixedRangeLabel = (range: TimeWindow): string => const fixedRangeLabel = (range: TimeWindow): string =>
`${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`; `${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`;
const presetLabel = (hours: number): string => /** A zoom or a paused range names its actual bounds; a live range names its preset. */
RANGE_PRESETS.find((preset) => preset.hours === hours)?.label ?? `Last ${hours} hours`; const rangeLabel = (range: RelativeRange, zoom: TimeWindow | null): string => {
if (zoom) return fixedRangeLabel(zoom);
return range.anchorMs === null ? presetLabel(range.hours) : fixedRangeLabel(timeWindow(range, range.anchorMs));
};
const SEGMENT = "inline-flex h-full items-center gap-1.5 px-3 text-sm outline-none focus-visible:bg-accent"; const SEGMENT = "inline-flex h-full items-center gap-1.5 px-3 text-sm outline-none focus-visible:bg-accent";
interface TimeRangeControlsProps { interface TimeRangeControlsProps {
fixedRange: TimeWindow | null; range: RelativeRange;
rangeHours: number; zoom: TimeWindow | null;
onRangeHoursChange: (hours: number) => void; onHoursChange: (hours: number) => void;
live: boolean;
showLive?: boolean; showLive?: boolean;
onLiveChange: (live: boolean) => void; onLiveChange: (live: boolean) => void;
} }
/** Joined control group: the time range (opens presets) and Live. */ /** Joined control group: the time range (opens presets) and Live. */
export function TimeRangeControls({ export function TimeRangeControls({ range, zoom, onHoursChange, showLive = true, onLiveChange }: TimeRangeControlsProps) {
fixedRange, const live = isLive(range);
rangeHours,
onRangeHoursChange,
live,
showLive = true,
onLiveChange,
}: TimeRangeControlsProps) {
return ( return (
<div className="flex items-stretch divide-x divide-border border-l border-border"> <div className="flex items-stretch divide-x divide-border border-l border-border">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
aria-label="Time range" aria-label="Time range"
className={cn(SEGMENT, "min-w-40 justify-between text-foreground hover:bg-muted/60")} className={cn(SEGMENT, "text-foreground hover:bg-muted/60")}
data-testid="time-range-trigger" data-testid="time-range-trigger"
> >
<span className="tabular-nums">{fixedRange ? fixedRangeLabel(fixedRange) : presetLabel(rangeHours)}</span> <span className="tabular-nums">{rangeLabel(range, zoom)}</span>
<ChevronDown className="size-3.5 text-muted-foreground" /> <ChevronDown className="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={1} className="min-w-0 rounded-t-none"> <DropdownMenuContent align="end" className="w-auto min-w-44">
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
value={String(rangeHours)} value={String(range.hours)}
onValueChange={(value: string) => onRangeHoursChange(Number(value))} onValueChange={(value: string) => onHoursChange(Number(value))}
> >
{RANGE_PRESETS.map((preset) => ( {RANGE_PRESETS.map((preset) => (
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-sm"> <DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-sm">

View file

@ -0,0 +1,26 @@
import { parseAsInteger, parseAsNumberLiteral, useQueryState, useQueryStates } from "nuqs";
import { useCallback } from "react";
import { DEFAULT_RANGE_HOURS, isRangeHours, RANGE_HOURS, type TimeWindow } from "./timeRange";
export const TIME_RANGE_PARSERS = {
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
from: parseAsInteger,
to: parseAsInteger,
};
export function useRangeHoursRouting(): [number, (hours: number) => void] {
const [hours, setHours] = useQueryState("hours", TIME_RANGE_PARSERS.hours);
const setRangeHours = useCallback((next: number) => void (isRangeHours(next) && setHours(next)), [setHours]);
return [hours, setRangeHours];
}
/** A timeline brush narrows the list to a window inside the range; it is dropped whenever the range changes. */
export function useZoomRouting(): [TimeWindow | null, (zoom: TimeWindow | null) => void] {
const [{ from, to }, setParams] = useQueryStates({ from: TIME_RANGE_PARSERS.from, to: TIME_RANGE_PARSERS.to });
const setZoom = useCallback(
(zoom: TimeWindow | null) => void setParams({ from: zoom?.startMs ?? null, to: zoom?.endMs ?? null }),
[setParams],
);
return [from !== null && to !== null && from < to ? { startMs: from, endMs: to } : null, setZoom];
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isRangeHours, presetLabel, timeWindow } from "./timeRange";
const HOUR = 3600 * 1000;
describe("timeWindow", () => {
it("rolls a live range forward with now so live tail keeps a fixed-length window", () => {
const mountedAt = Date.parse("2026-09-30T10:00:00");
const tenHoursLater = mountedAt + 10 * HOUR;
const live = { hours: 24, anchorMs: null };
expect(timeWindow(live, tenHoursLater)).toEqual({ startMs: tenHoursLater - 24 * HOUR, endMs: tenHoursLater });
expect(timeWindow(live, tenHoursLater).startMs).toBeGreaterThan(timeWindow(live, mountedAt).startMs);
});
it("keeps a paused range pinned to its anchor whatever now is", () => {
const anchorMs = Date.parse("2026-09-02T00:00");
expect(timeWindow({ hours: 24, anchorMs }, Date.parse("2026-09-30T00:00:00"))).toEqual({
startMs: anchorMs - 24 * HOUR,
endMs: anchorMs,
});
});
});
describe("presets", () => {
it("accepts only the preset lengths from the URL and names them", () => {
expect(isRangeHours(168)).toBe(true);
expect(isRangeHours(2)).toBe(false);
expect(presetLabel(168)).toBe("Last 7 days");
expect(presetLabel(2)).toBe("Last 2 hours");
});
});

View file

@ -0,0 +1,38 @@
const HOUR_MS = 60 * 60 * 1000;
export interface TimeWindow {
readonly startMs: number;
readonly endMs: number;
}
export const RANGE_PRESETS = [
{ hours: 1, label: "Last hour" },
{ hours: 6, label: "Last 6 hours" },
{ hours: 24, label: "Last 24 hours" },
{ hours: 168, label: "Last 7 days" },
{ hours: 720, label: "Last 30 days" },
] as const;
export type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
export const RANGE_HOURS: readonly RangeHours[] = RANGE_PRESETS.map((preset) => preset.hours);
export const DEFAULT_RANGE_HOURS: RangeHours = 24;
export const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
export const presetLabel = (hours: number): string =>
RANGE_PRESETS.find((preset) => preset.hours === hours)?.label ?? `Last ${hours} hours`;
/** A preset length that ends now while live, or at the moment live was paused. */
export interface RelativeRange {
readonly hours: number;
readonly anchorMs: number | null;
}
export const isLive = (range: RelativeRange): boolean => range.anchorMs === null;
/** The window a range covers; a live range rolls with `nowMs`, a paused one ignores it. */
export const timeWindow = (range: RelativeRange, nowMs: number): TimeWindow => {
const endMs = range.anchorMs ?? nowMs;
return { startMs: endMs - range.hours * HOUR_MS, endMs };
};
export const LIVE_TAIL_INTERVAL_MS = 15000;

View file

@ -0,0 +1,30 @@
import { useMemo, useState } from "react";
import { useRangeHoursRouting } from "./routing";
import type { RelativeRange } from "./timeRange";
export interface RelativeRangeState {
readonly range: RelativeRange;
readonly setHours: (hours: number) => void;
readonly setLive: (live: boolean) => void;
}
/** The preset comes from the URL; live is page state. Any change re-anchors a paused range to now. */
export function useRelativeRange(canGoLive: boolean): RelativeRangeState {
const [hours, setRangeHours] = useRangeHoursRouting();
const [live, setLiveState] = useState(true);
const [anchorMs, setAnchorMs] = useState(() => Date.now());
const rolling = live && canGoLive;
const range = useMemo(() => ({ hours, anchorMs: rolling ? null : anchorMs }), [hours, rolling, anchorMs]);
return {
range,
setHours: (next) => {
setRangeHours(next);
setAnchorMs(Date.now());
},
setLive: (next) => {
setLiveState(next);
setAnchorMs(Date.now());
},
};
}