feat(dashboard): span tree list, now-clamped timeline, zoom/pan

- Trace list is now a hierarchical span tree: every span (e.g.
  $session-replay-segment) is directly reachable and selectable —
  selecting focuses it in the waterfall. First 3 levels expand by
  default, deeper levels collapse behind carets. The type filter prunes
  the tree to matching subtrees with match highlighting and forced
  ancestor expansion.
- Timeline scale clamps at 'now': intervals reaching into the future
  ($refresh-token runs to its expiry a year out) no longer compress the
  real activity into a sliver; future ends render as a fading stub with
  an 'elapsed →' duration and end-date tooltip.
- Waterfall zoom/pan: cmd/ctrl+scroll zooms around the cursor,
  horizontal scroll pans while zoomed, reset button in the ruler;
  ew-resize cursor on timeline tracks as affordance. View window math
  (zoomViewWindow/panViewWindow) is pure and unit-tested.
- Trace start now includes attached event times (events can predate
  their span's started_at since events and replay chunks batch
  independently), so nothing renders off-scale at full view.
This commit is contained in:
mantrakp04 2026-07-02 15:57:13 -07:00
parent f06fd1ae9a
commit 4de05f4ddd
5 changed files with 445 additions and 86 deletions

View File

@ -1,6 +1,6 @@
"use client";
import { Badge, Button, Input, Typography } from "@/components/ui";
import { Button, Input, Typography } from "@/components/ui";
import { cn } from "@/lib/utils";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { ArrowClockwiseIcon, SpinnerGapIcon } from "@phosphor-icons/react";
@ -17,16 +17,17 @@ import {
parseClickHouseDate,
type RowData,
} from "../shared";
import { SpanTreeList } from "./span-tree";
import {
buildTraces,
formatDuration,
isSystemSpanType,
rerootTrace,
subtreeMatches,
type EventInput,
type SpanInput,
type Trace,
} from "./trace-utils";
import { TraceWaterfall, spanColorClass, type TraceBreadcrumb } from "./waterfall";
import { TraceWaterfall, type TraceBreadcrumb } from "./waterfall";
const SPANS_QUERY = `
SELECT id, span_type, span_started_at, span_ended_at, parent_span_ids, data,
@ -102,15 +103,6 @@ function parseEventRow(row: Record<string, unknown>): EventInput | null {
};
}
function traceMatchesSearch(trace: Trace, needle: string): boolean {
const walk = (node: Trace["root"]): boolean => {
if (node.span.spanType.toLowerCase().includes(needle)) return true;
if (node.events.some((event) => event.eventType.toLowerCase().includes(needle))) return true;
return node.children.some(walk);
};
return walk(trace.root);
}
function Segmented<T extends string | number>({ value, onChange, options }: {
value: T,
onChange: (value: T) => void,
@ -158,6 +150,9 @@ export default function PageClient() {
const [spans, setSpans] = useState<SpanInput[]>([]);
const [events, setEvents] = useState<EventInput[]>([]);
// "Now" reference for the waterfall/list: fixed at load time so intervals
// reaching into the future (e.g. $refresh-token expiry) render as ongoing.
const [nowMs, setNowMs] = useState<number>(() => Date.now());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -185,6 +180,7 @@ export default function PageClient() {
if (seq !== requestSeqRef.current) return;
setSpans(spansResponse.result.map(parseSpanRow).filter((span): span is SpanInput => span != null));
setEvents(eventsResponse.result.map(parseEventRow).filter((event): event is EventInput => event != null));
setNowMs(Date.now());
} catch (e) {
if (seq !== requestSeqRef.current) return;
setError(e instanceof Error ? e.message : String(e));
@ -211,11 +207,11 @@ export default function PageClient() {
const { traces } = useMemo(() => buildTraces(scopedSpans, scopedEvents), [scopedSpans, scopedEvents]);
const searchNeedle = search.trim().toLowerCase();
const filteredTraces = useMemo(() => {
const needle = search.trim().toLowerCase();
if (needle === "") return traces;
return traces.filter((trace) => traceMatchesSearch(trace, needle));
}, [traces, search]);
if (searchNeedle === "") return traces;
return traces.filter((trace) => subtreeMatches(trace.root, searchNeedle));
}, [traces, searchNeedle]);
const selectedTrace = useMemo<Trace | null>(
() => filteredTraces.find((trace) => trace.root.span.id === selectedRootId)
@ -332,37 +328,18 @@ export default function PageClient() {
)}
</EmptyState>
)}
{!loading && error == null && filteredTraces.map((trace) => {
const isSelected = selectedTrace != null && trace.root.span.id === selectedTrace.root.span.id;
const open = trace.endMs == null;
return (
<button
key={trace.root.span.id}
className={cn(
"w-full text-left px-3 py-2 border-b border-border/30 hover:bg-muted/30 transition-colors hover:transition-none",
isSelected && "bg-muted/50 hover:bg-muted/50",
)}
onClick={() => {
setSelectedRootId(trace.root.span.id);
setFocusedSpanId(null);
}}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className={cn("h-2 w-2 rounded-[3px] shrink-0", spanColorClass(trace.root.span.spanType))} />
<span className="font-mono text-xs font-semibold truncate">{trace.root.span.spanType}</span>
{open ? (
<Badge variant="secondary" className="ml-auto text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400 shrink-0">open</Badge>
) : (
<span className="ml-auto font-mono text-[11px] text-muted-foreground shrink-0">{formatDuration((trace.endMs ?? trace.latestMs) - trace.startMs)}</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
<span className="truncate">{new Date(trace.startMs).toLocaleString()}</span>
<span className="ml-auto shrink-0">{trace.spanCount}s · {trace.eventCount}e</span>
</div>
</button>
);
})}
{!loading && error == null && filteredTraces.length > 0 && (
<SpanTreeList
traces={filteredTraces}
nowMs={nowMs}
needle={searchNeedle}
activeSpanId={displayedTrace?.trace.root.span.id ?? null}
onSelectSpan={(rootId, spanId) => {
setSelectedRootId(rootId);
setFocusedSpanId(spanId === rootId ? null : spanId);
}}
/>
)}
</div>
</div>
@ -379,6 +356,7 @@ export default function PageClient() {
{!loading && error == null && displayedTrace != null && (
<TraceWaterfall
trace={displayedTrace.trace}
nowMs={nowMs}
breadcrumb={displayedTrace.breadcrumb}
onFocusSpan={(spanId) => setFocusedSpanId(spanId)}
onSelectSpan={(span) => openDetail(span.raw)}

View File

@ -0,0 +1,198 @@
"use client";
import { Badge } from "@/components/ui";
import { cn } from "@/lib/utils";
import { CaretRightIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { formatDuration, subtreeMatches, type Trace, type TraceNode } from "./trace-utils";
import { spanColorClass } from "./waterfall";
// Nodes shallower than this render expanded unless the user collapsed them.
const DEFAULT_EXPANDED_DEPTH = 3;
function nodeMatches(node: TraceNode, needle: string): boolean {
return node.span.spanType.toLowerCase().includes(needle)
|| node.events.some((event) => event.eventType.toLowerCase().includes(needle));
}
function DurationLabel({ startMs, endMs, nowMs }: { startMs: number, endMs: number | null, nowMs: number }) {
if (endMs == null) {
return <Badge variant="secondary" className="text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">open</Badge>;
}
if (endMs > nowMs) {
// Interval reaches into the future (e.g. $refresh-token runs until its
// expiry) — show elapsed-so-far instead of the misleading total.
return (
<span
className="font-mono text-[11px] text-muted-foreground"
title={`Ends ${new Date(endMs).toLocaleString()}`}
>
{formatDuration(nowMs - startMs)}
</span>
);
}
return <span className="font-mono text-[11px] text-muted-foreground">{formatDuration(endMs - startMs)}</span>;
}
function TreeNode({
node,
trace,
nowMs,
needle,
activeSpanId,
expandedOverrides,
onToggle,
onSelectSpan,
}: {
node: TraceNode,
trace: Trace,
nowMs: number,
needle: string,
activeSpanId: string | null,
expandedOverrides: Record<string, boolean>,
onToggle: (spanId: string) => void,
onSelectSpan: (rootId: string, spanId: string) => void,
}) {
const isSearching = needle !== "";
const visibleChildren = isSearching
? node.children.filter((child) => subtreeMatches(child, needle))
: node.children;
const hasChildren = visibleChildren.length > 0;
const expanded = expandedOverrides[node.span.id]
?? (isSearching ? hasChildren : node.depth < DEFAULT_EXPANDED_DEPTH);
const isActive = node.span.id === activeSpanId;
const isRoot = node.depth === 0;
return (
<>
<div
className={cn(
"w-full flex items-center gap-1 pr-3 py-1.5 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none cursor-pointer min-w-0",
isActive && "bg-muted/50 hover:bg-muted/50",
)}
style={{ paddingLeft: `${8 + node.depth * 12}px` }}
onClick={() => onSelectSpan(trace.root.span.id, node.span.id)}
>
{hasChildren ? (
<button
className="p-0.5 shrink-0 text-muted-foreground hover:text-foreground"
title={expanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
onToggle(node.span.id);
}}
>
<CaretRightIcon className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")} />
</button>
) : (
<span className="w-4 shrink-0" />
)}
<span className={cn("h-2 w-2 rounded-[3px] shrink-0", spanColorClass(node.span.spanType))} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn(
"font-mono text-xs truncate",
isRoot ? "font-semibold" : "font-medium",
isSearching && nodeMatches(node, needle) && "rounded bg-amber-500/15 px-0.5",
)}
>
{node.span.spanType}
</span>
<span className="ml-auto shrink-0">
{/* Root rows represent the whole trace, so they show trace
bounds a derived $refresh-token span's own start can move
forward on token rotation and would read nonsensically. */}
<DurationLabel
startMs={isRoot ? trace.startMs : node.span.startMs}
endMs={isRoot ? trace.endMs : node.span.endMs}
nowMs={nowMs}
/>
</span>
</div>
{isRoot && (
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
<span className="truncate">{new Date(trace.startMs).toLocaleString()}</span>
<span className="ml-auto shrink-0">{trace.spanCount}s · {trace.eventCount}e</span>
</div>
)}
</div>
</div>
{expanded && visibleChildren.map((child) => (
<TreeNode
key={child.span.id}
node={child}
trace={trace}
nowMs={nowMs}
needle={needle}
activeSpanId={activeSpanId}
expandedOverrides={expandedOverrides}
onToggle={onToggle}
onSelectSpan={onSelectSpan}
/>
))}
</>
);
}
/**
* Hierarchical trace list: every span in every trace is reachable and
* individually selectable (selection focuses it in the waterfall). The first
* levels render expanded; deeper levels collapse behind carets. While
* searching, only matching subtrees render and ancestors force-expand.
*/
export function SpanTreeList({
traces,
nowMs,
needle,
activeSpanId,
onSelectSpan,
}: {
traces: Trace[],
nowMs: number,
needle: string,
/** The span id currently shown as the waterfall root. */
activeSpanId: string | null,
onSelectSpan: (rootId: string, spanId: string) => void,
}) {
const [expandedOverrides, setExpandedOverrides] = useState<Record<string, boolean>>({});
const handleToggle = (spanId: string) => {
setExpandedOverrides((prev) => {
const isSearching = needle !== "";
const node = findNodeInTraces(traces, spanId);
const currentlyExpanded = prev[spanId]
?? (node == null ? false : (isSearching ? node.children.length > 0 : node.depth < DEFAULT_EXPANDED_DEPTH));
return { ...prev, [spanId]: !currentlyExpanded };
});
};
return (
<div>
{traces.map((trace) => (
<TreeNode
key={trace.root.span.id}
node={trace.root}
trace={trace}
nowMs={nowMs}
needle={needle}
activeSpanId={activeSpanId}
expandedOverrides={expandedOverrides}
onToggle={handleToggle}
onSelectSpan={onSelectSpan}
/>
))}
</div>
);
}
function findNodeInTraces(traces: Trace[], spanId: string): TraceNode | null {
const stack: TraceNode[] = traces.map((trace) => trace.root);
while (stack.length > 0) {
const node = stack.pop();
if (node == null) break;
if (node.span.id === spanId) return node;
stack.push(...node.children);
}
return null;
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildTraces, flattenTrace, formatDuration, isSystemSpanType, rerootTrace, traceNodePath, type EventInput, type SpanInput } from "./trace-utils";
import { buildTraces, flattenTrace, formatDuration, isSystemSpanType, panViewWindow, rerootTrace, subtreeMatches, traceNodePath, zoomViewWindow, type EventInput, type SpanInput } from "./trace-utils";
function span(id: string, opts: Partial<SpanInput> = {}): SpanInput {
return {
@ -81,6 +81,16 @@ describe("buildTraces", () => {
expect(traces[0].latestMs).toBe(9500);
});
it("widens startMs to include events that precede their span's start", () => {
// Events and replay chunks batch independently, so an attached event can
// predate the span row's own started_at.
const { traces } = buildTraces(
[span("root", { startMs: 5000, endMs: 9000 })],
[event("early", { atMs: 3000, parentSpanIds: ["root"] })],
);
expect(traces[0].startMs).toBe(3000);
});
it("returns events with no fetched ancestor as unattached", () => {
const { traces, unattachedEvents } = buildTraces(
[span("root")],
@ -190,8 +200,49 @@ describe("traceNodePath", () => {
});
});
describe("zoomViewWindow / panViewWindow", () => {
it("zooms in around the anchor point and keeps it fixed", () => {
const zoomed = zoomViewWindow({ start: 0, end: 1 }, 0.5, 0.5);
expect(zoomed.start).toBeCloseTo(0.25);
expect(zoomed.end).toBeCloseTo(0.75);
// Anchor at 0.5 of the view maps to the same absolute position after zoom.
const anchorAbsBefore = 0 + 0.5 * 1;
const anchorAbsAfter = zoomed.start + 0.5 * (zoomed.end - zoomed.start);
expect(anchorAbsAfter).toBeCloseTo(anchorAbsBefore);
});
it("clamps zoom-out to the full timeline and zoom position to [0, 1]", () => {
expect(zoomViewWindow({ start: 0.25, end: 0.75 }, 0.5, 10)).toEqual({ start: 0, end: 1 });
const nearEdge = zoomViewWindow({ start: 0, end: 1 }, 0, 0.5);
expect(nearEdge.start).toBe(0);
expect(nearEdge.end).toBeCloseTo(0.5);
});
it("pans by a fraction of the view width and clamps at the edges", () => {
const panned = panViewWindow({ start: 0.2, end: 0.4 }, 0.5);
expect(panned.start).toBeCloseTo(0.3);
expect(panned.end).toBeCloseTo(0.5);
expect(panViewWindow({ start: 0.8, end: 1 }, 5)).toEqual({ start: 0.8, end: 1 });
expect(panViewWindow({ start: 0, end: 0.2 }, -5)).toEqual({ start: 0, end: 0.2 });
});
});
describe("subtreeMatches", () => {
it("matches span types, event types, and descendants", () => {
const { traces } = buildTraces(
[span("a", { spanType: "checkout" }), span("b", { spanType: "payment", parentSpanIds: ["a"] })],
[event("card_declined", { parentSpanIds: ["a", "b"] })],
);
expect(subtreeMatches(traces[0].root, "payment")).toBe(true);
expect(subtreeMatches(traces[0].root, "card_")).toBe(true);
expect(subtreeMatches(traces[0].root, "refund")).toBe(false);
expect(subtreeMatches(traces[0].root.children[0], "checkout")).toBe(false);
});
});
describe("formatDuration", () => {
it("formats across magnitudes", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(0.5)).toBe("<1ms");
expect(formatDuration(42)).toBe("42ms");
expect(formatDuration(2500)).toBe("2.5s");

View File

@ -42,6 +42,37 @@ export function isSystemSpanType(spanType: string): boolean {
return spanType.startsWith("$");
}
/** True if the span, any of its events, or any descendant matches the needle (lowercase). */
export function subtreeMatches(node: TraceNode, needle: string): boolean {
if (node.span.spanType.toLowerCase().includes(needle)) return true;
if (node.events.some((event) => event.eventType.toLowerCase().includes(needle))) return true;
return node.children.some((child) => subtreeMatches(child, needle));
}
/**
* Waterfall zoom viewport as fractions of the full trace timeline
* (0 = trace start, 1 = trace end). {start: 0, end: 1} means unzoomed.
*/
export type ViewWindow = { start: number, end: number };
const MIN_VIEW_SPAN = 1e-8;
/** Zoom by `factor` (<1 zooms in) keeping the point at `anchorFrac` of the current view fixed. */
export function zoomViewWindow(view: ViewWindow, anchorFrac: number, factor: number): ViewWindow {
const span = view.end - view.start;
const newSpan = Math.min(Math.max(span * factor, MIN_VIEW_SPAN), 1);
const anchorAbs = view.start + anchorFrac * span;
const start = Math.min(Math.max(anchorAbs - anchorFrac * newSpan, 0), 1 - newSpan);
return { start, end: start + newSpan };
}
/** Shift the view by `deltaFrac` of its own width, clamped to the timeline. */
export function panViewWindow(view: ViewWindow, deltaFrac: number): ViewWindow {
const span = view.end - view.start;
const start = Math.min(Math.max(view.start + deltaFrac * span, 0), 1 - span);
return { start, end: start + span };
}
/**
* The parent a row renders under is its nearest ANCESTOR THAT WAS FETCHED, not
* its literal last parent id intermediate spans can fall outside the queried
@ -136,7 +167,12 @@ function computeTraceAggregates(root: TraceNode): Omit<Trace, "root"> {
latestMs = Math.max(latestMs, node.span.endMs);
}
eventCount += node.events.length;
for (const event of node.events) latestMs = Math.max(latestMs, event.atMs);
for (const event of node.events) {
// Events can precede their span's own start (events and replay chunks
// are batched independently) — widen the window so they stay on-scale.
startMs = Math.min(startMs, event.atMs);
latestMs = Math.max(latestMs, event.atMs);
}
stack.push(...node.children);
}
return {
@ -199,6 +235,7 @@ export function flattenTrace(trace: Trace): WaterfallRow[] {
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "—";
if (ms === 0) return "0s";
if (ms < 1) return "<1ms";
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;

View File

@ -3,8 +3,18 @@
import { Badge } from "@/components/ui";
import { cn } from "@/lib/utils";
import { CaretRightIcon, CornersOutIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { flattenTrace, formatDuration, isSystemSpanType, type EventInput, type SpanInput, type Trace } from "./trace-utils";
import { useEffect, useMemo, useRef, useState } from "react";
import {
flattenTrace,
formatDuration,
isSystemSpanType,
panViewWindow,
zoomViewWindow,
type EventInput,
type SpanInput,
type Trace,
type ViewWindow,
} from "./trace-utils";
const SPAN_COLOR_CLASSES = [
"bg-blue-500",
@ -29,17 +39,31 @@ export function spanColorClass(spanType: string): string {
const NAME_COLUMN = "minmax(200px, 260px)";
const DURATION_COLUMN = "76px";
const FULL_VIEW: ViewWindow = { start: 0, end: 1 };
export type TraceBreadcrumb = { spanId: string, spanType: string };
function TimelineGridlines() {
return (
<>
{[25, 50, 75].map((pct) => (
<span key={pct} className="absolute inset-y-0 w-px bg-border/40" style={{ left: `${pct}%` }} />
))}
</>
);
}
export function TraceWaterfall({
trace,
nowMs,
breadcrumb,
onFocusSpan,
onSelectSpan,
onSelectEvent,
}: {
trace: Trace,
/** "Current time" reference (when the data was loaded): the timeline never scales past it. */
nowMs: number,
/** Ancestors of the current view root (trace root first); empty when unfocused. */
breadcrumb: TraceBreadcrumb[],
onFocusSpan: (spanId: string) => void,
@ -48,18 +72,58 @@ export function TraceWaterfall({
}) {
const rows = useMemo(() => flattenTrace(trace), [trace]);
// Open traces render to the latest observed timestamp; open bars are drawn
// to the right edge with a fade, so the horizon only needs to be sane, not
// exact. The epsilon keeps zero-length traces from dividing by zero.
const [view, setView] = useState<ViewWindow>(FULL_VIEW);
const viewRef = useRef(view);
viewRef.current = view;
const rootSpanId = trace.root.span.id;
useEffect(() => {
setView(FULL_VIEW);
}, [rootSpanId]);
// The scale is clamped to "now": a $refresh-token's expiry a year out must
// not compress everything that actually happened into a sliver. Future
// interval ends render as a fading stub instead. The epsilon keeps
// zero-length traces from dividing by zero.
const scaleStart = trace.startMs;
const scaleEnd = Math.max(trace.endMs ?? trace.latestMs, scaleStart + 1);
const scaleSpan = scaleEnd - scaleStart;
const toPct = (ms: number) => Math.min(100, Math.max(0, ((ms - scaleStart) / scaleSpan) * 100));
const scaleEnd = Math.max(Math.min(trace.endMs ?? trace.latestMs, nowMs), scaleStart + 1);
const totalSpanMs = scaleEnd - scaleStart;
const viewStartMs = scaleStart + view.start * totalSpanMs;
const viewSpanMs = Math.max((view.end - view.start) * totalSpanMs, 1e-9);
const toPct = (ms: number) => ((ms - viewStartMs) / viewSpanMs) * 100;
const isZoomed = view.start > 0 || view.end < 1;
// cmd/ctrl+scroll zooms around the cursor; horizontal scroll pans while
// zoomed. Attached non-passively so preventDefault stops page zoom/scroll.
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (container == null) return;
const handleWheel = (e: WheelEvent) => {
const track = trackRef.current;
if (track == null) return;
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return;
const current = viewRef.current;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const anchorFrac = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
// Functional update: wheel events can arrive faster than re-renders,
// and each step must compound on the previous one.
setView((prev) => zoomViewWindow(prev, anchorFrac, Math.exp(e.deltaY * 0.01)));
} else if ((current.start > 0 || current.end < 1) && Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
e.preventDefault();
setView((prev) => panViewWindow(prev, e.deltaX / rect.width));
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, []);
const gridTemplateColumns = `${NAME_COLUMN} 1fr ${DURATION_COLUMN}`;
return (
<div className="flex flex-col min-h-0 flex-1">
<div ref={containerRef} className="flex flex-col min-h-0 flex-1">
{/* Trace header */}
<div className="flex items-center gap-1.5 px-4 py-3 border-b border-border/50 shrink-0 min-w-0">
{breadcrumb.map((crumb) => (
@ -77,6 +141,10 @@ export function TraceWaterfall({
<span className="font-mono text-sm font-semibold truncate">{trace.root.span.spanType}</span>
{trace.endMs == null ? (
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">open</Badge>
) : trace.endMs > nowMs ? (
<span className="font-mono text-xs text-muted-foreground" title={`Ends ${new Date(trace.endMs).toLocaleString()}`}>
{formatDuration(nowMs - trace.startMs)}
</span>
) : (
<span className="font-mono text-xs text-muted-foreground">{formatDuration(trace.endMs - trace.startMs)}</span>
)}
@ -88,21 +156,37 @@ export function TraceWaterfall({
{/* Time ruler */}
<div className="grid gap-3 px-4 py-1.5 border-b border-border/30 shrink-0" style={{ gridTemplateColumns }}>
<span className="font-mono text-[10px] text-muted-foreground">name</span>
<div className="relative h-4">
<div
ref={trackRef}
className="relative h-4 cursor-ew-resize"
title="⌘/Ctrl + scroll to zoom · horizontal scroll to pan"
>
{[0, 25, 50, 75, 100].map((pct) => (
<span
key={pct}
className={cn(
"absolute top-0 font-mono text-[10px] text-muted-foreground/70",
"absolute top-0 font-mono text-[10px] text-muted-foreground/70 whitespace-nowrap",
pct === 100 ? "-translate-x-full" : pct === 0 ? "" : "-translate-x-1/2",
)}
style={{ left: `${pct}%` }}
>
{formatDuration((scaleSpan * pct) / 100)}
{formatDuration((viewStartMs - scaleStart) + (viewSpanMs * pct) / 100)}
</span>
))}
</div>
<span className="font-mono text-[10px] text-muted-foreground text-right">duration</span>
<span className="font-mono text-[10px] text-muted-foreground text-right">
{isZoomed ? (
<button
className="rounded px-1 text-foreground/80 hover:text-foreground hover:bg-foreground/[0.08]"
onClick={() => setView(FULL_VIEW)}
title="Reset zoom"
>
reset
</button>
) : (
"duration"
)}
</span>
</div>
{/* Rows */}
@ -111,10 +195,16 @@ export function TraceWaterfall({
if (row.kind === "span") {
const { span } = row.node;
const open = span.endMs == null;
const runsIntoFuture = !open && (span.endMs ?? 0) > nowMs;
const isViewRoot = row.node.depth === 0;
const leftPct = toPct(span.startMs);
const rightPct = open ? 100 : toPct(span.endMs ?? scaleEnd);
const widthPct = Math.max(rightPct - leftPct, 0.5);
const barEndMs = open || runsIntoFuture ? Math.min(nowMs, scaleEnd + viewSpanMs) : (span.endMs ?? scaleEnd);
const rawLeftPct = toPct(span.startMs);
const rawRightPct = toPct(barEndMs);
const barVisible = rawRightPct > 0 && rawLeftPct < 100;
const leftPct = Math.max(rawLeftPct, 0);
const rightPct = Math.min(rawRightPct, 100);
const widthPct = Math.max(rightPct - leftPct, 0.4);
const fades = open || runsIntoFuture;
return (
<div
key={`span-${span.id}-${index}`}
@ -140,21 +230,26 @@ export function TraceWaterfall({
</button>
)}
</div>
<div className="relative h-4">
{[25, 50, 75].map((pct) => (
<span key={pct} className="absolute inset-y-0 w-px bg-border/40" style={{ left: `${pct}%` }} />
))}
<span
className={cn(
"absolute inset-y-0.5 rounded-sm",
spanColorClass(span.spanType),
open && "[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]",
)}
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "3px" }}
/>
<div className="relative h-4 cursor-ew-resize">
<TimelineGridlines />
{barVisible && (
<span
className={cn(
"absolute inset-y-0.5 rounded-sm",
spanColorClass(span.spanType),
fades && "[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]",
)}
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "3px" }}
title={runsIntoFuture ? `Ends ${new Date(span.endMs ?? 0).toLocaleString()}` : undefined}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground text-right">
{open ? "open" : formatDuration((span.endMs ?? scaleEnd) - span.startMs)}
{open
? "open"
: runsIntoFuture
? <span title={`Ends ${new Date(span.endMs ?? 0).toLocaleString()}`}>{formatDuration(nowMs - span.startMs)} </span>
: formatDuration((span.endMs ?? scaleEnd) - span.startMs)}
</span>
</div>
);
@ -172,14 +267,14 @@ export function TraceWaterfall({
<span className="h-1.5 w-1.5 rotate-45 bg-foreground/50 shrink-0" />
<span className="font-mono text-[11px] text-muted-foreground truncate">{event.eventType}</span>
</div>
<div className="relative h-4">
{[25, 50, 75].map((pct) => (
<span key={pct} className="absolute inset-y-0 w-px bg-border/40" style={{ left: `${pct}%` }} />
))}
<span
className="absolute top-1/2 h-2 w-2 -translate-y-1/2 -translate-x-1/2 rotate-45 rounded-[2px] bg-foreground/60"
style={{ left: `${leftPct}%` }}
/>
<div className="relative h-4 cursor-ew-resize">
<TimelineGridlines />
{leftPct >= 0 && leftPct <= 100 && (
<span
className="absolute top-1/2 h-2 w-2 -translate-y-1/2 -translate-x-1/2 rotate-45 rounded-[2px] bg-foreground/60"
style={{ left: `${leftPct}%` }}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground/70 text-right">
+{formatDuration(event.atMs - scaleStart)}