mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
refactor(ui): share the brushed timeline and the state messages
The histogram strip only knew about runs through its tooltip copy, and the loading and state messages only lived under Lens. Both move to shared/, the timeline takes the item noun from its caller, and the sweep keyframe drops its Lens name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
3d049cd4a8
commit
1325389624
10 changed files with 34 additions and 28 deletions
|
|
@ -461,7 +461,7 @@
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes lens-sweep {
|
||||
@keyframes timeline-sweep {
|
||||
from {
|
||||
left: -6rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { LoadingState } from "../ui/LoadingState";
|
||||
import { StateMessage } from "../ui/StateMessage";
|
||||
import { LoadingState } from "@/components/shared/LoadingState";
|
||||
import { StateMessage } from "@/components/shared/StateMessage";
|
||||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Tabs } from "@/components/ui/tabs";
|
|||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { useTracesApi } from "../../api";
|
||||
import { LoadingState } from "../../../ui/LoadingState";
|
||||
import { LoadingState } from "@/components/shared/LoadingState";
|
||||
import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure";
|
||||
import { type RunSelection, traceKey } from "../../routing";
|
||||
import type { Trace } from "../../types";
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ const renderWindowed = (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(() => {
|
||||
|
|
@ -523,10 +523,10 @@ describe("AgentTracesSection", () => {
|
|||
it("plots every loaded run on the timeline", async () => {
|
||||
serve(runs);
|
||||
renderWindowed();
|
||||
expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByTestId("timeline")).toHaveAttribute("aria-busy", "true");
|
||||
await screen.findAllByTestId("agent-trace-row");
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "false"));
|
||||
await waitFor(() => expect(screen.getByTestId("timeline")).toHaveAttribute("aria-busy", "false"));
|
||||
const counts = bucketRunCounts();
|
||||
expect(counts).toHaveLength(60);
|
||||
expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length);
|
||||
|
|
@ -562,7 +562,7 @@ describe("AgentTracesSection", () => {
|
|||
drag(screen.getByTestId("timeline-selection"), 0, 59);
|
||||
await waitFor(() => 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();
|
||||
await waitFor(() => expect(rowCount()).toBe(runs.length));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ 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 { Timeline } from "@/components/shared/timeline/Timeline";
|
||||
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
|
||||
import { useTracesLive } from "../api";
|
||||
import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
|
||||
import { useTraceHistogram } from "./useTraceHistogram";
|
||||
|
||||
const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth";
|
||||
const RUN_NOUN = { singular: "run", plural: "runs" };
|
||||
|
||||
export type TimeControls = Pick<RelativeRangeState, "setHours" | "setLive">;
|
||||
|
||||
|
|
@ -173,12 +174,13 @@ export function AgentTracesSection({
|
|||
/>
|
||||
)}
|
||||
</RunsToolbar>
|
||||
<TracesTimeline
|
||||
<Timeline
|
||||
buckets={histogram.buckets}
|
||||
loading={histogram.isLoading}
|
||||
range={window}
|
||||
selection={zoom}
|
||||
onSelect={setZoom}
|
||||
noun={RUN_NOUN}
|
||||
/>
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
||||
import { useTracesApi } from "../api";
|
||||
import type { TraceHistogram } from "../types";
|
||||
import { BUCKETS, type Bucket } from "./TracesTimeline";
|
||||
import { BUCKETS, type TimeBucket } from "@/components/shared/timeline/Timeline";
|
||||
|
||||
/** The dot field draws one series entry per successful run. */
|
||||
export const toBuckets = (histogram: TraceHistogram): Bucket[] =>
|
||||
export const toBuckets = (histogram: TraceHistogram): TimeBucket[] =>
|
||||
histogram.buckets.map((bucket) => ({
|
||||
startMs: bucket.start_ms,
|
||||
endMs: bucket.end_ms,
|
||||
|
|
@ -15,7 +15,7 @@ export const toBuckets = (histogram: TraceHistogram): Bucket[] =>
|
|||
series: bucket.agents.flatMap(({ agent, runs }) => Array<string>(runs).fill(agent)),
|
||||
}));
|
||||
|
||||
const emptyBuckets = (range: TimeWindow): Bucket[] => {
|
||||
const emptyBuckets = (range: TimeWindow): TimeBucket[] => {
|
||||
const width = (range.endMs - range.startMs) / BUCKETS;
|
||||
return Array.from({ length: BUCKETS }, (_, i) => ({
|
||||
startMs: range.startMs + i * width,
|
||||
|
|
@ -27,7 +27,7 @@ const emptyBuckets = (range: TimeWindow): Bucket[] => {
|
|||
};
|
||||
|
||||
export interface TraceHistogramResult {
|
||||
buckets: Bucket[];
|
||||
buckets: TimeBucket[];
|
||||
/** True until the first histogram for this scope arrives; a range change keeps the previous one instead. */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { bandForWindow, type Bucket, dragUpdate, formatSpan } from "./TracesTimeline";
|
||||
import { bandForWindow, dragUpdate, formatSpan } from "./Timeline";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
|
||||
|
|
@ -19,11 +19,14 @@ const SKELETON_COLUMNS = skeletonColumns(BUCKETS);
|
|||
|
||||
import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
|
||||
|
||||
export interface Bucket extends DotColumn {
|
||||
export interface TimeBucket extends DotColumn {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/** What one counted item is called in the tooltip: "run", "request". */
|
||||
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 {
|
||||
const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS));
|
||||
|
|
@ -46,17 +49,18 @@ const tickShift = (t: number): string => {
|
|||
return "translateX(-50%)";
|
||||
};
|
||||
|
||||
interface TracesTimelineProps {
|
||||
buckets: readonly Bucket[];
|
||||
interface TimelineProps {
|
||||
buckets: readonly TimeBucket[];
|
||||
range: TimeWindow;
|
||||
selection: TimeWindow | null;
|
||||
onSelect: (selection: TimeWindow | null) => void;
|
||||
noun: ItemNoun;
|
||||
/** Paint a muted placeholder profile until the first histogram arrives. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -64,7 +68,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" />
|
||||
|
|
@ -74,7 +78,7 @@ function NowEdge() {
|
|||
);
|
||||
}
|
||||
|
||||
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
|
||||
function BucketTooltip({ bucket, index, noun }: { bucket: TimeBucket; index: 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"
|
||||
|
|
@ -85,7 +89,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
|
|||
{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>
|
||||
|
|
@ -119,7 +123,7 @@ export function dragUpdate(drag: DragState, at: number, buckets = BUCKETS): Band
|
|||
}
|
||||
|
||||
/** 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;
|
||||
|
|
@ -208,8 +212,8 @@ function TickAxis({ range }: { range: TimeWindow }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
|
||||
export function TracesTimeline({ buckets, range, selection, onSelect, loading = false }: TracesTimelineProps) {
|
||||
/** Histogram over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
|
||||
export function Timeline({ buckets, range, selection, onSelect, noun, loading = false }: TimelineProps) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [draft, setDraft] = useState<Band | null>(null);
|
||||
|
|
@ -268,7 +272,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect, loading =
|
|||
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"
|
||||
aria-busy={loading}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
|
|
@ -312,7 +316,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect, loading =
|
|||
<div className="mt-1">
|
||||
<TickAxis range={range} />
|
||||
</div>
|
||||
{showTooltip && <BucketTooltip bucket={buckets[hover]} index={hover} />}
|
||||
{showTooltip && <BucketTooltip bucket={buckets[hover]} index={hover} noun={noun} />}
|
||||
{selection && (
|
||||
<button
|
||||
type="button"
|
||||
Loading…
Add table
Reference in a new issue