mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
1d0397f1c0
commit
3d049cd4a8
18 changed files with 229 additions and 297 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue