fix(ui): polish Lens runs loading, reload, and time range menu (#44789)

* fix(ui): polish Lens runs loading, reload, and time range menu

Port the dashboard-only parts of a0a275e486, 1325389624, 5e7b0afd5c, 03bec959bf, 93e6ccff8d, 3d35d9f920, dc1a2b5b60, 1d0397f1c0, 773bfef066, f3387221ea and 3d049cd4a8 from litellm_lens_server_search onto main

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep the Lens timeline on the shown runs' window during a reload

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): drop a redundant fixture comment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-05 21:27:00 -07:00 • committed by GitHub
parent d6c4e918d8
commit c3e0156979
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 428 additions and 320 deletions

View file

@ -471,7 +471,7 @@
visibility: hidden;
}
@keyframes lens-sweep {
@keyframes timeline-sweep {
from {
left: -6rem;
}

View file

@ -1,8 +1,9 @@
"use client";
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react";
import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react";
import { LoadingState } from "@/components/shared/LoadingState";
import { StateMessage } from "@/components/shared/StateMessage";
import { Button, buttonVariants } from "@/components/ui/button";
import { StateMessage } from "../ui/StateMessage";
import { ApiError } from "@/lib/http/client";
@ -63,9 +64,7 @@ export function InvestigationError({ message, refresh }: { message: string; refr
export function InvestigationsLoading() {
return (
<StateMessage
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
<LoadingState
title="Loading investigations…"
description="Fetching your investigations and their latest findings."
/>

View file

@ -508,6 +508,27 @@ it("allows retrying a failed trace readiness check without treating it as an emp
expect(traceCheck).toHaveBeenCalledTimes(2);
});
it("shows a loading state until the investigation list arrives", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
let resolveList: (list: unknown) => void = () => {};
proxy.get.mockImplementation((path) => {
if (path === "/lens")
return new Promise((resolve) => {
resolveList = resolve;
});
if (path === "/lens/agents") return Promise.resolve([]);
return Promise.resolve({ traces: true, requests: false, data: [] });
});
renderWithProviders(<InvestigationsView />);
expect(await screen.findByRole("status", { name: "Loading investigations…" })).toBeVisible();
expect(screen.queryByRole("region", { name: "Get Lens running" })).not.toBeInTheDocument();
act(() => resolveList({ lenses: [], workers: [], tracing_enabled: true }));
expect(await screen.findByRole("region", { name: "Get Lens running" })).toBeVisible();
expect(screen.queryByRole("status", { name: "Loading investigations…" })).not.toBeInTheDocument();
});
it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();

View file

@ -144,6 +144,25 @@ describe("RunView", () => {
expect(screen.getByLabelText("Keyboard shortcuts")).toHaveTextContent("↑/↓ step←/→ foldEsc close");
});
it("shows a loading state until the first trace arrives", async () => {
let resolveTrace: (trace: Trace) => void = () => {};
vi.mocked(agentTraceCall).mockImplementation(
() =>
new Promise<Trace>((resolve) => {
resolveTrace = resolve;
}),
);
renderWithProviders(
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} embedded />,
);
expect(screen.getByRole("status", { name: "Loading trace…" })).toBeVisible();
expect(screen.queryByTestId("run-view")).not.toBeInTheDocument();
act(() => resolveTrace(research));
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument();
});
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
const user = userEvent.setup();
let resolveSwarm: (trace: Trace) => void = () => {};
@ -161,7 +180,7 @@ describe("RunView", () => {
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
await user.keyboard("{ArrowDown}");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);

View file

@ -5,9 +5,9 @@ import { ArrowLeft } from "lucide-react";
import { Suspense, useDeferredValue, useEffect, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { LoadingState } from "@/components/shared/LoadingState";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { useTracesApi } from "../../api";
@ -32,22 +32,8 @@ function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
}
function RunLoading({ embedded }: { embedded: boolean }) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
function RunLoading() {
return <LoadingState title="Loading trace…" description="Fetching this run and its steps." />;
}
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
@ -85,7 +71,7 @@ export function RunView(props: RunViewProps) {
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
)}
>
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
<Suspense fallback={<RunLoading />}>
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
</Suspense>
</ErrorBoundary>

View file

@ -1,13 +1,9 @@
"use client";
import moment from "moment";
import { useMemo, useState } from "react";
import { useRelativeRange } from "@/components/shared/timeRange/useRelativeRange";
import { AgentTracesSection } from "./AgentTracesSection";
import { useTracesLive } from "../api";
import { useRangeHoursRouting } from "../routing";
const TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss";
export default function AgentTracesPage({
accessToken,
@ -22,43 +18,17 @@ export default function AgentTracesPage({
canMintTracingKey?: boolean;
canViewFindings?: boolean;
}) {
const sourceLive = 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());
};
const time = useRelativeRange(useTracesLive());
return (
<div className="flex min-h-0 flex-1 flex-col">
<AgentTracesSection
accessToken={accessToken}
isActive={isActive}
startTime={startTime}
endTime={endTime}
isCustomDate={!isLiveTail}
isLiveTail={isLiveTail}
range={time.range}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
canViewFindings={canViewFindings}
timeControls={{ rangeHours, onRangeHoursChange: changeRange, onLiveChange: changeLive }}
timeControls={time}
/>
</div>
);

View file

@ -9,7 +9,9 @@ import { renderWithProviders, testQueryClient } from "../../../../../tests/test-
import traceList from "../__fixtures__/trace_list.json";
import AgentTracesPage from "./AgentTracesPage";
import { filterRuns } from "./runSearch/runQuery";
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
import type { RelativeRangeState } from "@/components/shared/timeRange/useRelativeRange";
import { AgentTracesSection } from "./AgentTracesSection";
import type { TraceFindingCount, TracePage, TraceSummary } from "../types";
vi.mock("../../../networking", () => ({
@ -37,35 +39,22 @@ const runs = (traceList as TracePage).data as TraceSummary[];
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const ROLLING_DAY = { hours: 24, anchorMs: null };
const PINNED_DAY = { hours: 24, anchorMs: Date.parse("2026-10-01T00:00Z") };
const renderSection = (canViewFindings = true) =>
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
canViewFindings={canViewFindings}
/>,
<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} canViewFindings={canViewFindings} />,
);
// 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?: RelativeRangeState) =>
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
timeControls={timeControls}
/>,
<AgentTracesSection accessToken="sk-test" isActive range={PINNED_DAY} timeControls={timeControls} />,
);
const bucketRunCounts = () =>
screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-runs")));
screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-total")));
describe("AgentTracesSection", () => {
afterEach(() => {
@ -156,16 +145,7 @@ describe("AgentTracesSection", () => {
])("stops live polling after HTTP %s and explains how to recover", async (status, message) => {
vi.useFakeTimers();
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Private token details", Number(status), {}));
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail
/>,
);
renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
@ -509,17 +489,10 @@ describe("AgentTracesSection", () => {
it("opens full screen from a shared link and drops it from the URL on close", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: `?trace=${runs[0].trace_id}&fullscreen=true`, onUrlUpdate },
);
renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />, {
searchParams: `?trace=${runs[0].trace_id}&fullscreen=true`,
onUrlUpdate,
});
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
expect(drawer).toHaveStyle({ width: "100%" });
fireEvent.click(screen.getByRole("button", { name: "Close trace (Esc)" }));
@ -532,17 +505,10 @@ describe("AgentTracesSection", () => {
const startMs = Date.parse(runs[0].start_time);
const inWindow = runs.filter((run) => Math.abs(Date.parse(run.start_time) - startMs) <= 1);
const onUrlUpdate = vi.fn();
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
/>,
{ searchParams: `?from=${startMs - 1}&to=${startMs + 1}`, onUrlUpdate },
);
renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={PINNED_DAY} />, {
searchParams: `?from=${startMs - 1}&to=${startMs + 1}`,
onUrlUpdate,
});
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(inWindow.length));
expect(inWindow.length).toBeLessThan(runs.length);
fireEvent.click(screen.getByRole("button", { name: "Clear time zoom" }));
@ -553,17 +519,10 @@ describe("AgentTracesSection", () => {
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);
const onUrlUpdate = vi.fn();
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
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 },
);
renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />, {
searchParams: "?trace=older-than-the-list&trace_ref=ref-9",
onUrlUpdate,
});
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
expect(within(drawer).getByTestId("run-view")).toHaveTextContent("run older-than-the-list");
expect(screen.getByRole("button", { name: "Next trace (J)" })).toBeDisabled();
@ -587,17 +546,10 @@ describe("AgentTracesSection", () => {
const mixed = runs.map((run, index) => (index === 1 ? { ...run, status: "error" as const } : run));
vi.mocked(agentTraceListCall).mockResolvedValue({ data: mixed, next_cursor: null });
const failed = filterRuns(mixed, "status:error");
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?q=status:error", onUrlUpdate },
);
renderWithProviders(<AgentTracesSection accessToken="sk-test" isActive range={ROLLING_DAY} />, {
searchParams: "?q=status:error",
onUrlUpdate,
});
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length);
expect(failed.length).toBeLessThan(runs.length);
const search = screen.getByRole("combobox", { name: "Search runs" });
@ -615,7 +567,7 @@ describe("AgentTracesSection", () => {
renderWindowed();
await screen.findAllByTestId("agent-trace-row");
expect(screen.getByTestId("traces-timeline")).toBeInTheDocument();
expect(screen.getByTestId("timeline")).toBeInTheDocument();
const counts = bucketRunCounts();
expect(counts).toHaveLength(60);
expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length);
@ -651,7 +603,7 @@ describe("AgentTracesSection", () => {
drag(screen.getByTestId("timeline-selection"), 0, 59);
expect(rowCount()).toBe(0);
fireEvent.keyDown(screen.getByTestId("traces-timeline"), { key: "Escape" });
fireEvent.keyDown(screen.getByTestId("timeline"), { key: "Escape" });
expect(screen.queryByTestId("timeline-selection")).not.toBeInTheDocument();
expect(rowCount()).toBe(runs.length);
});
@ -693,7 +645,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 - 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 () => {
@ -727,6 +679,48 @@ describe("AgentTracesPage", () => {
await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1"));
});
it("keeps the shown runs behind a search spinner while a newly picked range loads", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderWithProviders(<AgentTracesPage accessToken="sk-test" />);
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument();
let resolveWeek: (page: TracePage) => void = () => {};
vi.mocked(agentTraceListCall).mockImplementation(
() =>
new Promise<TracePage>((resolve) => {
resolveWeek = resolve;
}),
);
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" }));
expect(await screen.findByRole("status", { name: "Loading results" })).toBeInTheDocument();
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.queryByText("Loading runs…")).not.toBeInTheDocument();
act(() => resolveWeek({ ...(traceList as TracePage), data: runs.slice(0, 1) }));
await waitFor(() => expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument());
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
});
it("keeps the timeline on the shown runs' window while a narrower range loads", async () => {
vi.useFakeTimers({ toFake: ["Date"], now: Date.parse("2026-09-30T12:00Z") });
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { searchParams: "?hours=24" });
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(runs.length);
const sum = () => bucketRunCounts().reduce((a, b) => a + b, 0);
expect(sum()).toBe(runs.length);
vi.mocked(agentTraceListCall).mockImplementation(() => new Promise<TracePage>(() => {}));
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
expect(await screen.findByRole("status", { name: "Loading results" })).toBeInTheDocument();
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(sum()).toBe(runs.length);
});
it("asks the proxy for the last 24 hours by default", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderWithProviders(<AgentTracesPage accessToken="sk-test" />);

View file

@ -12,22 +12,17 @@ import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { useTraceFindings } from "./useTraceFindings";
import {
type TraceRef,
traceKey,
traceRefOf,
useOpenTraceRouting,
useRunFilterRouting,
useZoomRouting,
} from "../routing";
import { type TraceRef, traceKey, traceRefOf, useOpenTraceRouting, useRunFilterRouting } from "../routing";
import type { TraceSummary } from "../types";
import { RunView } from "../detail/run/RunView";
import { TimeRangeControls } from "@/components/shared/timeline/TimeRangeControls";
import { useZoomRouting } from "@/components/shared/timeRange/routing";
import { type RelativeRange, type TimeWindow, 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 type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
import { useTracesLive } from "../api";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth";
@ -37,21 +32,12 @@ const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[]
return t >= range.startMs && t < range.endMs;
});
export interface TimeControls {
rangeHours: number;
onRangeHoursChange: (hours: number) => void;
onLiveChange: (live: boolean) => void;
}
interface AgentTracesSectionProps {
accessToken: string;
isActive: boolean;
startTime: string;
endTime: string;
isCustomDate: boolean;
isLiveTail: boolean;
range: RelativeRange;
/** Page-owned time range + live state; when given, the toolbar shows the range / Live control group. */
timeControls?: TimeControls;
timeControls?: RelativeRangeState;
readOnly?: boolean;
canMintTracingKey?: boolean;
canViewFindings?: boolean;
@ -88,10 +74,7 @@ function TraceHistoryError({ history }: { history: ReturnType<typeof useTraceAva
export function AgentTracesSection({
accessToken,
isActive,
startTime,
endTime,
isCustomDate,
isLiveTail,
range,
timeControls,
readOnly = false,
canMintTracingKey = false,
@ -103,7 +86,7 @@ export function AgentTracesSection({
const [showSetup, setShowSetup] = useState(false);
const [zoom, setZoom] = useZoomRouting();
const [rangeChanged, setRangeChanged] = useState(false);
const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive };
const traceQuery = { accessToken, range, enabled: isActive };
const traces = useAgentTraces(traceQuery);
const setup = useTracingSetup(traces, isActive, rangeChanged);
const checkHistory = setup.isEmpty && !rangeChanged;
@ -114,12 +97,11 @@ export function AgentTracesSection({
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.
const endMs = isCustomDate ? moment(endTime).valueOf() : moment().endOf("minute").valueOf();
const range = useMemo(
() => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }),
[startTime, endTime, isCustomDate, endMs],
);
// 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 pickedWindow = useMemo(() => timeWindow(range, minuteEndMs), [range, minuteEndMs]);
// While the previous range's rows stay on screen, describe them with their own window.
const window = traces.isPlaceholder && traces.window ? traces.window : pickedWindow;
const filtered = useMemo(
() => filterRuns(traces.traces, query, { agent, status }),
[traces.traces, query, agent, status],
@ -194,7 +176,13 @@ export function AgentTracesSection({
/>
)}
</Inspector.Panel>
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces} range={zoom ?? range}>
<RunsToolbar
query={query}
onQueryChange={setQuery}
runs={traces.traces}
range={zoom ?? window}
busy={traces.isPlaceholder}
>
<TracingSetupAction available={traces.traces.length > 0} live={live} onSetup={() => setShowSetup(true)} />
<Button
variant="outline"
@ -209,18 +197,17 @@ export function AgentTracesSection({
{timeControls && (
<div className="h-8 min-w-0 overflow-hidden rounded-md border [&>div]:h-full [&>div]:border-l-0">
<TimeRangeControls
fixedRange={zoom ?? (isLiveTail ? null : range)}
rangeHours={timeControls.rangeHours}
onRangeHoursChange={(hours) => changeRange(hours, timeControls.onRangeHoursChange)}
live={isLiveTail}
range={range}
zoom={zoom}
onHoursChange={(hours) => changeRange(hours, timeControls.setHours)}
showLive={live}
onLiveChange={timeControls.onLiveChange}
onLiveChange={timeControls.setLive}
/>
</div>
)}
</RunsToolbar>
<TraceCounts runs={filtered} />
<TracesTimeline runs={filtered} range={range} selection={zoom} onSelect={setZoom} />
<TracesTimeline runs={filtered} range={window} selection={zoom} onSelect={setZoom} />
<AgentTracesTable
traces={runs}
findings={findings}
@ -229,6 +216,7 @@ export function AgentTracesSection({
error={traces.error}
hasMore={traces.hasMore}
isFetching={traces.isFetching}
isPlaceholder={traces.isPlaceholder}
onRetry={traces.hasMore ? traces.loadMore : traces.refetch}
onLoadMore={traces.loadMore}
rangeEmpty={traces.traces.length === 0}

View file

@ -10,6 +10,7 @@ import { usePersistedColumnVisibility } from "@/components/shared/DataTable/useP
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";
@ -27,6 +28,8 @@ interface AgentTracesTableProps {
error: Error | null;
hasMore: boolean;
isFetching?: boolean;
/** Rows from the previous range shown while the new one loads: blurred, and never paged further. */
isPlaceholder?: boolean;
onRetry?: () => void;
onLoadMore: () => void;
rangeEmpty?: boolean;
@ -211,6 +214,8 @@ function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetU
);
}
const bodyClassName = (blurred?: boolean) => cn("transition-[filter]", blurred && "blur-[1.5px]");
/** Devtool-dense runs list: one row per agent run, newest first. */
export function AgentTracesTable({
traces,
@ -220,6 +225,7 @@ export function AgentTracesTable({
error,
hasMore,
isFetching = false,
isPlaceholder,
onRetry,
onLoadMore,
rangeEmpty = false,
@ -227,7 +233,7 @@ export function AgentTracesTable({
}: AgentTracesTableProps) {
const settled = !isLoading && !error;
const isEmpty = settled && !hasMore && traces.length === 0;
const canContinue = settled && hasMore;
const canContinue = settled && hasMore && !isPlaceholder;
const autoContinue = canContinue && traces.length > 0;
const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces");
const tableOptions: TableOptions<TraceSummary> = {
@ -247,6 +253,7 @@ export function AgentTracesTable({
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
className={bodyClassName(isPlaceholder)}
rowHeight={() => ROW_HEIGHT}
after={
<>

View file

@ -3,13 +3,14 @@
import moment from "moment";
import { useMemo } from "react";
import { Timeline, TIMELINE_BUCKETS, type Bucket, type TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { Timeline, TIMELINE_BUCKETS, type TimeBucket } from "@/components/shared/timeline/Timeline";
import type { TraceSummary } from "../types";
import { traceAgentNames } from "../utils";
/** Run counts per equal-width time bucket across the window; runs outside it are dropped. */
export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = TIMELINE_BUCKETS): Bucket[] {
export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = TIMELINE_BUCKETS): TimeBucket[] {
const width = (range.endMs - range.startMs) / buckets;
const placed = runs.map((run) => ({
index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width),
@ -36,7 +37,9 @@ interface TracesTimelineProps {
}
/** Lens adapter: buckets the currently loaded runs and hands them to the shared timeline. */
const RUN_NOUN = { singular: "run", plural: "runs" };
export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimelineProps) {
const buckets = useMemo(() => bucketRuns(runs, range), [runs, range]);
return <Timeline buckets={buckets} selection={selection} onSelect={onSelect} />;
return <Timeline buckets={buckets} selection={selection} onSelect={onSelect} noun={RUN_NOUN} />;
}

View file

@ -3,7 +3,7 @@
import { useMemo } from "react";
import type { TraceSummary } from "../../types";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { SearchBox } from "@/components/shared/search/SearchBox";
import { itemValues } from "@/components/shared/search/valueSource";
@ -19,10 +19,11 @@ interface RunSearchProps {
runs: readonly TraceSummary[];
/** The range the list shows; the copied query bounds itself to it. */
range?: TimeWindow;
busy?: boolean;
}
/** 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) {
export function RunSearch({ value, onChange, runs, range, busy = false }: RunSearchProps) {
const command = useMemo(() => runQueryCommand(range), [range]);
return (
<SearchBox.Root
@ -36,6 +37,7 @@ export function RunSearch({ value, onChange, runs, range }: RunSearchProps) {
<SearchBox.Input
className="h-8 overflow-hidden rounded-md px-3 whitespace-nowrap"
placeholder="Search input or trace ID"
busy={busy}
/>
<SearchBox.Suggestions>
<SearchBox.CopyCommand title={COPY_HINT} command={command} />

View file

@ -1,7 +1,7 @@
"use client";
import type { TraceSummary } from "../../types";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import {
Combobox,
@ -22,11 +22,13 @@ interface RunsToolbarProps {
runs: readonly TraceSummary[];
/** The range the list shows, for the copied query. */
range?: TimeWindow;
/** The list is reloading for a new range; the search box shows a spinner. */
busy?: boolean;
/** 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, busy, children }: RunsToolbarProps) {
const { agent, status, setAgent, setStatus } = useRunFilterRouting();
const agents = [...new Set([...runs.flatMap(traceAgentNames), ...(agent ? [agent] : [])])].sort();
const statuses = [
@ -36,7 +38,7 @@ export function RunsToolbar({ query, onQueryChange, runs, range, children }: Run
];
return (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b bg-card p-2">
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} />
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} busy={busy} />
<Combobox items={agents} value={agent || null} onValueChange={(name) => setAgent(name ?? "")} autoHighlight>
<ComboboxInput
aria-label="Filter traces by agent"

View file

@ -1,5 +1,5 @@
import { getProxyBaseUrl } from "@/components/networking";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import type { TraceQueryBody } from "../../types";
import { isNegatedOp, valueMatcher } from "@/components/shared/search/language";

View file

@ -1,30 +1,7 @@
import { describe, expect, it } from "vitest";
import { traceWindowStartMs } from "./useAgentTraces";
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", () => {
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");

View file

@ -1,15 +1,19 @@
import { useTracesApi } from "../api";
import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import moment from "moment";
import { keepPreviousData, useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { useMemo } from "react";
import {
isLive,
LIVE_TAIL_INTERVAL_MS,
type RelativeRange,
type TimeWindow,
timeWindow,
} from "@/components/shared/timeRange/timeRange";
import { ApiError } from "@/lib/http/client";
import type { TracePage, TraceSummary } from "../types";
import type { TraceWindow } from "../api";
const LIVE_TAIL_INTERVAL_MS = 15000;
interface LoadedTracePage extends TracePage {
window: TraceWindow;
}
@ -36,10 +40,7 @@ const displayError = (error: Error | null): Error | null => {
interface UseAgentTracesOptions {
accessToken: string;
startTime: string;
endTime: string;
isCustomDate: boolean;
isLiveTail: boolean;
range: RelativeRange;
enabled: boolean;
}
@ -47,6 +48,10 @@ export interface AgentTracesResult {
traces: TraceSummary[];
isLoading: boolean;
isFetching: boolean;
/** The previous range's rows, still shown while the newly picked range loads. */
isPlaceholder: boolean;
/** The window the shown rows were fetched for; lags the picked range while a placeholder is shown. */
window: TimeWindow | null;
/** Set when the proxy answered 501: tracing isn't configured. */
notEnabledDetail: string | null;
error: Error | null;
@ -55,41 +60,27 @@ export interface AgentTracesResult {
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, cursor-paginated as the runs list scrolls.
* Preset ranges roll on refresh; subsequent pages keep the first page's window.
*/
export function useAgentTraces({
accessToken,
startTime,
endTime,
isCustomDate,
isLiveTail,
enabled,
}: UseAgentTracesOptions): AgentTracesResult {
export function useAgentTraces({ accessToken, range, enabled }: UseAgentTracesOptions): AgentTracesResult {
const traces = useTracesApi(accessToken);
const fetchPage = async (pageParam: unknown): Promise<LoadedTracePage> => {
const nowMs = Date.now();
const window = (pageParam as TraceWindow | null) ?? {
startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs),
endMs: isCustomDate ? moment(endTime).valueOf() : nowMs,
};
const window = (pageParam as TraceWindow | null) ?? timeWindow(range, Date.now());
return { ...(await traces.list(window)), window };
};
const queryOptions: Parameters<typeof useInfiniteQuery<LoadedTracePage, Error>>[0] = {
queryKey: ["agentTraces", accessToken, startTime, endTime, isCustomDate],
queryKey: ["agentTraces", accessToken, range.hours, range.anchorMs],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: null,
getNextPageParam: (lastPage) =>
lastPage.next_cursor ? { ...lastPage.window, cursor: lastPage.next_cursor } : undefined,
enabled,
placeholderData: keepPreviousData,
staleTime: LIVE_TAIL_INTERVAL_MS,
retry: (failureCount, error) => !requiresUserAction(error) && failureCount < 1,
refetchInterval: (q) => (isLiveTail && !requiresUserAction(q.state.error) ? LIVE_TAIL_INTERVAL_MS : false),
refetchInterval: (q) => (isLive(range) && !requiresUserAction(q.state.error) ? LIVE_TAIL_INTERVAL_MS : false),
refetchOnWindowFocus: (q) => !requiresUserAction(q.state.error),
refetchOnReconnect: (q) => !requiresUserAction(q.state.error),
refetchIntervalInBackground: false,
@ -103,6 +94,8 @@ export function useAgentTraces({
traces: loaded,
isLoading: query.isLoading,
isFetching: query.isFetching,
isPlaceholder: query.isPlaceholderData,
window: query.data?.pages[0]?.window ?? null,
notEnabledDetail: notEnabled ? query.error?.message || "Agent tracing is not enabled" : null,
error: notEnabled ? null : displayError(query.error),
hasMore: query.hasNextPage,

View file

@ -1,16 +1,7 @@
import {
parseAsBoolean,
parseAsInteger,
parseAsNumberLiteral,
parseAsString,
parseAsStringLiteral,
useQueryState,
useQueryStates,
} from "nuqs";
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { useCallback, useState } from "react";
import { RANGE_PRESETS } from "@/components/shared/timeline/TimeRangeControls";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { TIME_RANGE_PARSERS } from "@/components/shared/timeRange/routing";
import type { TraceSummary } from "./types";
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 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 {
traceId: string;
traceRef?: string;
@ -72,9 +58,7 @@ export const RUN_FILTER_PARSERS = {
q: parseAsString.withDefault(""),
agent: parseAsString.withDefault(""),
status: parseAsStringLiteral(["all", "ok", "error"]).withDefault("all"),
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
from: parseAsInteger,
to: parseAsInteger,
...TIME_RANGE_PARSERS,
};
export interface OpenTraceRouting {
@ -163,19 +147,3 @@ export function useRunFilterRouting() {
setStatus: (status: "all" | "ok" | "error") => void setParams({ status }),
};
}
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

@ -93,9 +93,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 +111,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>

View file

@ -0,0 +1,17 @@
import { Loader2 } from "lucide-react";
import { StateMessage, type StateMessageProps } from "./StateMessage";
type LoadingStateProps = Omit<StateMessageProps, "role" | "icon" | "tone">;
export function LoadingState({ title, ...props }: LoadingStateProps) {
return (
<StateMessage
role="status"
aria-label={title}
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title={title}
{...props}
/>
);
}

View file

@ -117,6 +117,24 @@ afterEach(() => {
});
describe("SearchBox", () => {
it("swaps the search icon for a loading status while results for the query load", () => {
const search = (busy: boolean) => (
<SearchBox.Root
language={NOTE_QUERY}
values={itemValues(NOTE_INDEX, notes)}
value=""
onValueChange={vi.fn()}
label="Search notes"
>
<SearchBox.Input placeholder="Search notes" busy={busy} />
</SearchBox.Root>
);
const { rerender } = render(search(true));
expect(screen.getByRole("status", { name: "Loading results" })).toBeVisible();
rerender(search(false));
expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument();
});
it("builds a filter from the keyboard: field, then value", async () => {
const user = userEvent.setup();
render(<Harness />);

View file

@ -4,7 +4,7 @@ import "prosemirror-view/style/prosemirror.css";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import { Check, Copy, CornerDownLeft, type LucideIcon, Search } from "lucide-react";
import { Check, Copy, CornerDownLeft, Loader2, type LucideIcon, Search } from "lucide-react";
import { Schema } from "prosemirror-model";
import { EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
@ -232,10 +232,12 @@ function Root<F extends string>({
export type SearchBoxInputProps = ComponentProps<"div"> & {
placeholder: string;
/** Results for the current query are loading: the search icon becomes a spinner. */
busy?: boolean;
};
/** The bordered field holding the editor; shows `placeholder` while the query is empty. */
function Input({ placeholder, className, ...props }: SearchBoxInputProps) {
function Input({ placeholder, busy = false, className, ...props }: SearchBoxInputProps) {
const { text } = useSearchBox();
return (
<div
@ -246,7 +248,15 @@ function Input({ placeholder, className, ...props }: SearchBoxInputProps) {
)}
{...props}
>
<Search className="size-3.5 shrink-0 text-muted-foreground" />
{busy ? (
<Loader2
role="status"
aria-label="Loading results"
className="size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none"
/>
) : (
<Search className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="relative flex min-w-0 flex-1">
<ProseMirrorDoc />
{!text && (

View file

@ -12,61 +12,55 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import type { TimeWindow } from "./Timeline";
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;
import { isLive, presetLabel, RANGE_PRESETS, type RelativeRange, type TimeWindow, timeWindow } from "./timeRange";
const RANGE_LABEL_FORMAT = "MMM D, h:mm A";
const fixedRangeLabel = (range: TimeWindow): string =>
`${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`;
const presetLabel = (hours: number): string =>
RANGE_PRESETS.find((preset) => preset.hours === hours)?.label ?? `Last ${hours} hours`;
/** A zoom or a paused range names its actual bounds; a live range names its preset. */
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";
interface TimeRangeControlsProps {
fixedRange: TimeWindow | null;
rangeHours: number;
onRangeHoursChange: (hours: number) => void;
live: boolean;
range: RelativeRange;
zoom: TimeWindow | null;
onHoursChange: (hours: number) => void;
showLive?: boolean;
onLiveChange: (live: boolean) => void;
}
/** Joined control group: the time range (opens presets) and Live. */
export function TimeRangeControls({
fixedRange,
rangeHours,
onRangeHoursChange,
live,
range,
zoom,
onHoursChange,
showLive = true,
onLiveChange,
}: TimeRangeControlsProps) {
const label = fixedRange ? fixedRangeLabel(fixedRange) : presetLabel(rangeHours);
const live = isLive(range);
const label = rangeLabel(range, zoom);
return (
<div className="flex min-w-0 max-w-full items-stretch divide-x divide-border border-l border-border">
<div className="flex min-w-0 max-w-full items-stretch border-l border-border">
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Time range"
className={cn(SEGMENT, "min-w-0 text-foreground hover:bg-muted/60")}
className={cn(SEGMENT, "min-w-0 justify-between text-foreground hover:bg-muted/60 sm:min-w-40")}
title={label}
data-testid="time-range-trigger"
>
<span className="truncate tabular-nums">{label}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-44">
<DropdownMenuContent align="end" sideOffset={1} className="rounded-t-none">
<DropdownMenuRadioGroup
value={String(rangeHours)}
onValueChange={(value: string) => onRangeHoursChange(Number(value))}
value={String(range.hours)}
onValueChange={(value: string) => onHoursChange(Number(value))}
>
{RANGE_PRESETS.map((preset) => (
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-sm">
@ -83,7 +77,7 @@ export function TimeRangeControls({
onClick={() => onLiveChange(!live)}
className={cn(
SEGMENT,
"shrink-0",
"shrink-0 border-l border-border",
live ? "bg-info/10 text-info hover:bg-info/15" : "text-muted-foreground hover:text-foreground",
)}
>

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());
},
};
}

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import { bandForWindow, dragUpdate, formatSpan, timelineTicks, type Bucket } from "./Timeline";
import { bandForWindow, dragUpdate, formatSpan, timelineTicks, type TimeBucket } from "./Timeline";
const HOUR = 3600 * 1000;
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
const emptyBuckets = (count: number): Bucket[] =>
const emptyBuckets = (count: number): TimeBucket[] =>
Array.from({ length: count }, (_, i) => ({
startMs: START + i * HOUR,
endMs: START + (i + 1) * HOUR,

View file

@ -9,6 +9,8 @@ import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotFi
import type { DotBand, DotColumn } from "@/components/shared/dotField/dots";
import { cn } from "@/lib/cva.config";
import type { TimeWindow } from "../timeRange/timeRange";
export const TIMELINE_BUCKETS = 60;
const TICKS = 6;
const MINUTE_MS = 60 * 1000;
@ -16,15 +18,12 @@ const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const EDGE_FORMAT = "MMM DD, HH:mm";
export interface TimeWindow {
export interface TimeBucket extends DotColumn {
startMs: number;
endMs: number;
}
export interface Bucket extends DotColumn {
startMs: number;
endMs: number;
}
export type ItemNoun = { readonly singular: string; readonly plural: string };
/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
export function formatSpan(ms: number): string {
@ -49,13 +48,15 @@ const tickShift = (t: number): string => {
};
interface TimelineProps {
buckets: readonly Bucket[];
buckets: readonly TimeBucket[];
selection: TimeWindow | null;
onSelect: (selection: TimeWindow | null) => void;
/** What one counted item is called in the hover tooltip. */
noun: ItemNoun;
}
function BucketBar({ bucket }: { bucket: Bucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.total} />;
function BucketBar({ bucket }: { bucket: TimeBucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-total={bucket.total} />;
}
function NowEdge() {
@ -63,7 +64,7 @@ function NowEdge() {
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-24 bg-gradient-to-r from-transparent via-[#3b5bfd]/[0.08] to-transparent motion-safe:animate-[lens-sweep_6s_linear_infinite] motion-reduce:hidden"
className="pointer-events-none absolute inset-y-0 w-24 bg-gradient-to-r from-transparent via-[#3b5bfd]/[0.08] to-transparent motion-safe:animate-[timeline-sweep_6s_linear_infinite] motion-reduce:hidden"
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0">
<span className="absolute inset-y-0 right-0 w-px bg-[#3b5bfd]/50" />
@ -73,7 +74,17 @@ function NowEdge() {
);
}
function BucketTooltip({ bucket, index, bucketCount }: { bucket: Bucket; index: number; bucketCount: number }) {
function BucketTooltip({
bucket,
index,
bucketCount,
noun,
}: {
bucket: TimeBucket;
index: number;
bucketCount: number;
noun: ItemNoun;
}) {
return (
<div
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
@ -84,7 +95,7 @@ function BucketTooltip({ bucket, index, bucketCount }: { bucket: Bucket; index:
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
</div>
<div>
{bucket.total} {bucket.total === 1 ? "run" : "runs"}
{bucket.total} {bucket.total === 1 ? noun.singular : noun.plural}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
</div>
<div className="text-info">drag to zoom</div>
@ -118,7 +129,7 @@ export function dragUpdate(drag: DragState, at: number, buckets = TIMELINE_BUCKE
}
/** Bucket band covered by a selected window, or null when nothing is selected. */
export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null {
export function bandForWindow(buckets: readonly TimeBucket[], selection: TimeWindow | null): Band | null {
if (selection === null) return null;
const inside = buckets.flatMap((b, i) => (b.startMs >= selection.startMs && b.endMs <= selection.endMs ? [i] : []));
return inside.length > 0 ? { lo: inside[0], hi: inside[inside.length - 1] } : null;
@ -216,7 +227,7 @@ function TickAxis({ range, width }: { range: TimeWindow; width: number }) {
}
/** Histogram over the window covered by `buckets`. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
export function Timeline({ buckets, selection, onSelect, noun }: TimelineProps) {
const bucketCount = buckets.length;
const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
@ -278,7 +289,7 @@ export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
return (
<div
className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none"
data-testid="traces-timeline"
data-testid="timeline"
tabIndex={0}
onKeyDown={onKeyDown}
>
@ -322,7 +333,9 @@ export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
<div className="mt-1">
<TickAxis range={range} width={stripWidth} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} bucketCount={bucketCount} />}
{hover !== null && !drag && (
<BucketTooltip bucket={buckets[hover]} index={hover} bucketCount={bucketCount} noun={noun} />
)}
{selection && (
<button
type="button"