mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
f06fd1ae9a
commit
4de05f4ddd
@ -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)}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user