diff --git a/frontend/src/components/actors/actor-queue.tsx b/frontend/src/components/actors/actor-queue.tsx index 6d8b87147b..b10307fe76 100644 --- a/frontend/src/components/actors/actor-queue.tsx +++ b/frontend/src/components/actors/actor-queue.tsx @@ -14,6 +14,7 @@ export function ActorQueue({ actorId }: { actorId: ActorId }) { enabled: inspector.isInspectorAvailable && inspector.features.queue.supported, + refetchInterval: 1000, refetchOnWindowFocus: false, }); const queueSizeQuery = useQuery( @@ -38,8 +39,9 @@ export function ActorQueue({ actorId }: { actorId: ActorId }) { } const status = queueStatusQuery.data; - const size = - Number.isFinite(status.size) ? status.size : queueSizeQuery.data ?? 0; + const size = Number.isFinite(status.size) + ? status.size + : (queueSizeQuery.data ?? 0); return ( @@ -65,10 +67,7 @@ export function ActorQueue({ actorId }: { actorId: ActorId }) { {message.name}
- {format( - new Date(message.createdAtMs), - "p", - )} + {format(new Date(message.createdAtMs), "p")}
diff --git a/frontend/src/components/actors/actor-traces.tsx b/frontend/src/components/actors/actor-traces.tsx index 7931d4e71e..3faa1492a3 100644 --- a/frontend/src/components/actors/actor-traces.tsx +++ b/frontend/src/components/actors/actor-traces.tsx @@ -21,9 +21,13 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; import { useActorInspector } from "./actor-inspector-context"; import { ActorObjectInspector } from "./console/actor-inspector"; import type { ActorId } from "./queries"; +import { SpanSidebar } from "./traces/span-sidebar"; +import { TimelineView } from "./traces/traces-timeline"; +import type { SpanNode } from "./traces/types"; const PRESET_OPTIONS = [ { label: "5 min", ms: 5 * 60 * 1000 }, @@ -43,20 +47,11 @@ const DEFAULT_PRESET_MS = 30 * 60 * 1000; const GAP_THRESHOLD_MS = 500; const DEFAULT_LIMIT = 1000; -type SpanNode = { - span: OtlpSpan; - startNs: bigint; - endNs: bigint | null; - children: SpanNode[]; - events: OtlpSpanEvent[]; -}; - -type TraceItem = - | { type: "span"; node: SpanNode; timeNs: bigint } - | { type: "event"; event: OtlpSpanEvent; timeNs: bigint }; +type ViewType = "list" | "timeline"; export function ActorTraces({ actorId }: { actorId: ActorId }) { const inspector = useActorInspector(); + const [viewType, setViewType] = useState("list"); const [isLive, setIsLive] = useState(true); const [presetMs, setPresetMs] = useState(DEFAULT_PRESET_MS); const [customRange, setCustomRange] = useState( @@ -216,30 +211,71 @@ export function ActorTraces({ actorId }: { actorId: ActorId }) { )}` : "Select a time range"}
+
+ + value && setViewType(value as ViewType) + } + size="sm" + > + + + + + + +
-
- {traceTree.length === 0 ? ( -
- No traces found for this time range. -
- ) : ( - renderItemsWithGaps( - traceTree.map((node) => ({ - type: "span" as const, - node, - timeNs: node.startNs, - })), - 0, - nowNs, - ) - )} - {queryResult?.clamped ? ( -
- Results truncated at {DEFAULT_LIMIT} spans. + {viewType === "list" ? ( +
+ {traceTree.length === 0 ? ( +
+ No traces found for this time range. +
+ ) : ( + renderItemsWithGaps( + traceTree.map((node) => ({ + type: "span" as const, + node, + timeNs: node.startNs, + })), + 0, + nowNs, + ) + )} + {queryResult?.clamped ? ( +
+ Results truncated at {DEFAULT_LIMIT} spans. +
+ ) : null} +
+ ) : ( +
+ +
+ { + throw new Error("Function not implemented."); + }} + onSelectEvent={( + spanId: string, + eventIndex: number, + ): void => { + throw new Error("Function not implemented."); + }} + />
- ) : null} -
+
+ )}
); } @@ -482,34 +518,6 @@ function buildSpanTree(spans: OtlpSpan[]): SpanNode[] { return roots; } -function buildSpanDetails(span: OtlpSpan): Record | null { - const attributes = otlpAttributesToObject(span.attributes); - const links = span.links?.map((link) => ({ - traceId: link.traceId, - spanId: link.spanId, - traceState: link.traceState, - attributes: otlpAttributesToObject(link.attributes), - droppedAttributesCount: link.droppedAttributesCount, - })); - const details: Record = {}; - if (attributes && Object.keys(attributes).length > 0) { - details.attributes = attributes; - } - if (span.status) { - details.status = span.status; - } - if (links && links.length > 0) { - details.links = links; - } - if (span.traceState) { - details.traceState = span.traceState; - } - if (span.flags !== undefined) { - details.flags = span.flags; - } - return Object.keys(details).length > 0 ? details : null; -} - function otlpAttributesToObject( attributes?: OtlpKeyValue[], ): Record | null { @@ -603,3 +611,597 @@ function formatGap(ms: number): string { const days = Math.floor(hours / 24); return `${days} ${days === 1 ? "day" : "days"}`; } + +interface FlatSpan { + node: SpanNode; + depth: number; + row: number; +} + +function flattenSpanTree(nodes: SpanNode[]): FlatSpan[] { + const result: FlatSpan[] = []; + let row = 0; + + function traverse(node: SpanNode, depth: number) { + result.push({ node, depth, row: row++ }); + for (const child of node.children) { + traverse(child, depth + 1); + } + } + + for (const node of nodes) { + traverse(node, 0); + } + return result; +} + +function getAllSpanNodes(nodes: SpanNode[]): SpanNode[] { + const result: SpanNode[] = []; + + function traverse(node: SpanNode) { + result.push(node); + for (const child of node.children) { + traverse(child); + } + } + + for (const node of nodes) { + traverse(node); + } + return result; +} + +interface TimelineGap { + afterRow: number; + gapMs: number; + startNs: bigint; + endNs: bigint; +} + +const GAP_VISUAL_WIDTH_PERCENT = 2; + +function TracesTimelineView({ + spans, + nowNs, + clamped, + limit, +}: { + spans: SpanNode[]; + nowNs: bigint; + clamped?: boolean; + limit: number; +}) { + const [selectedSpanId, setSelectedSpanId] = useState(null); + const sidebarRef = useRef(null); + const timelineRef = useRef(null); + + const flatSpans = useMemo(() => flattenSpanTree(spans), [spans]); + const allSpans = useMemo(() => getAllSpanNodes(spans), [spans]); + + const { minTime, maxTime } = useMemo(() => { + if (allSpans.length === 0) { + return { minTime: 0n, maxTime: 0n }; + } + + const starts = allSpans.map((s) => s.startNs); + const ends = allSpans.map((s) => s.endNs ?? nowNs); + + const min = starts.reduce((a, b) => (a < b ? a : b), starts[0]); + const max = ends.reduce((a, b) => (a > b ? a : b), ends[0]); + + return { minTime: min, maxTime: max }; + }, [allSpans, nowNs]); + + const horizontalGaps = useMemo(() => { + const result: TimelineGap[] = []; + const sorted = [...allSpans].sort((a, b) => + a.startNs < b.startNs ? -1 : a.startNs > b.startNs ? 1 : 0, + ); + + let lastEndNs = minTime; + for (const span of sorted) { + const gapMs = nsToMs(span.startNs - lastEndNs); + if (gapMs > GAP_THRESHOLD_MS) { + result.push({ + afterRow: -1, + gapMs, + startNs: lastEndNs, + endNs: span.startNs, + }); + } + const spanEnd = span.endNs ?? nowNs; + if (spanEnd > lastEndNs) { + lastEndNs = spanEnd; + } + } + return result; + }, [allSpans, minTime, nowNs]); + + const compressedDuration = useMemo(() => { + const totalGap = horizontalGaps.reduce((sum, g) => sum + g.gapMs, 0); + const fullDuration = nsToMs(maxTime - minTime); + const gapVisualTime = + (horizontalGaps.length * GAP_VISUAL_WIDTH_PERCENT * fullDuration) / + 100; + const compressed = fullDuration - totalGap + gapVisualTime; + return Math.max(compressed, 1); + }, [horizontalGaps, minTime, maxTime]); + + const verticalGaps = useMemo(() => { + const result: TimelineGap[] = []; + const sorted = [...flatSpans].sort((a, b) => + a.node.startNs < b.node.startNs + ? -1 + : a.node.startNs > b.node.startNs + ? 1 + : 0, + ); + + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]; + const curr = sorted[i]; + const gapMs = nsToMs(curr.node.startNs - prev.node.startNs); + if (gapMs > GAP_THRESHOLD_MS) { + result.push({ + afterRow: prev.row, + gapMs, + startNs: prev.node.startNs, + endNs: curr.node.startNs, + }); + } + } + return result; + }, [flatSpans]); + + const rowHeight = 36; + const rowGap = 4; + const gapMarkerHeight = 24; + + const getRowTop = (row: number) => { + let top = row * (rowHeight + rowGap); + for (const gap of verticalGaps) { + if (gap.afterRow < row) { + top += gapMarkerHeight; + } + } + return top; + }; + + const totalHeight = useMemo(() => { + const baseHeight = flatSpans.length * (rowHeight + rowGap); + const gapsHeight = verticalGaps.length * gapMarkerHeight; + return baseHeight + gapsHeight; + }, [flatSpans.length, verticalGaps.length]); + + const getCompressedPosition = (timeNs: bigint): number => { + if (compressedDuration === 0) return 0; + + let position = nsToMs(timeNs - minTime); + let gapsBefore = 0; + + for (const gap of horizontalGaps) { + if (timeNs > gap.endNs) { + position -= gap.gapMs; + gapsBefore++; + } else if (timeNs > gap.startNs) { + const gapProgress = nsToMs(timeNs - gap.startNs) / gap.gapMs; + const fullDuration = nsToMs(maxTime - minTime); + position -= gap.gapMs; + position += + (gapProgress * GAP_VISUAL_WIDTH_PERCENT * fullDuration) / + 100; + gapsBefore++; + } + } + + const fullDuration = nsToMs(maxTime - minTime); + position += + (gapsBefore * GAP_VISUAL_WIDTH_PERCENT * fullDuration) / 100; + + return (position / compressedDuration) * 100; + }; + + const getSpanStyle = (node: SpanNode) => { + if (compressedDuration === 0) return { left: "0%", width: "100%" }; + + const startPercent = getCompressedPosition(node.startNs); + const endTime = node.endNs ?? nowNs; + const endPercent = getCompressedPosition(endTime); + const widthPercent = endPercent - startPercent; + + return { + left: `${startPercent}%`, + width: `${Math.max(widthPercent, 0.5)}%`, + }; + }; + + const selectedNode = useMemo(() => { + if (!selectedSpanId) return null; + return allSpans.find((s) => s.span.spanId === selectedSpanId) ?? null; + }, [allSpans, selectedSpanId]); + + const handleScroll = (e: React.UIEvent) => { + const scrollTop = e.currentTarget.scrollTop; + if (sidebarRef.current && e.currentTarget === timelineRef.current) { + sidebarRef.current.scrollTop = scrollTop; + } else if ( + timelineRef.current && + e.currentTarget === sidebarRef.current + ) { + timelineRef.current.scrollTop = scrollTop; + } + }; + + if (spans.length === 0) { + return ( +
+ No traces found for this time range. +
+ ); + } + + return ( +
+
+
+
+ + Spans + +
+
+ {flatSpans.map(({ node, depth, row }) => { + const isSelected = + selectedSpanId === node.span.spanId; + const isActive = node.endNs == null; + const durationMs = node.endNs + ? nsToMs(node.endNs - node.startNs) + : null; + + return ( + + ); + })} + {verticalGaps.map((gap) => ( +
+
+ + {formatGap(gap.gapMs)} + +
+
+ ))} +
+
+ +
+
+ +
+ {flatSpans.map(({ node, depth, row }) => { + const style = getSpanStyle(node); + const isSelected = + selectedSpanId === node.span.spanId; + const durationMs = node.endNs + ? nsToMs(node.endNs - node.startNs) + : null; + const isActive = node.endNs == null; + + return ( +
+ +
+ ); + })} + {verticalGaps.map((gap) => ( +
+
+
+ ))} + {horizontalGaps.map((gap) => { + const startPercent = getCompressedPosition( + gap.startNs, + ); + const endPercent = getCompressedPosition( + gap.endNs, + ); + return ( +
+ + {formatGap(gap.gapMs)} + +
+ ); + })} +
+
+
+
+ + {selectedNode ? ( + setSelectedSpanId(null)} + /> + ) : null} + + {clamped ? ( +
+ Results truncated at {limit} spans. +
+ ) : null} +
+ ); +} + +function TimelineScale({ + totalDuration, + gaps, + getCompressedPosition, +}: { + totalDuration: number; + gaps: TimelineGap[]; + getCompressedPosition: (timeNs: bigint) => number; +}) { + const ticks = useMemo(() => { + if (totalDuration === 0) return []; + + const numTicks = 10; + const tickInterval = totalDuration / numTicks; + const result = []; + + for (let i = 0; i <= numTicks; i++) { + const ms = tickInterval * i; + result.push({ + position: `${(i / numTicks) * 100}%`, + label: formatDuration(ms), + }); + } + + return result; + }, [totalDuration]); + + return ( +
+
+ {ticks.map((tick) => ( +
+
+ + {tick.label} + +
+ ))} + {gaps.map((gap) => { + const startPercent = getCompressedPosition(gap.startNs); + const endPercent = getCompressedPosition(gap.endNs); + return ( +
+ ); + })} +
+
+ ); +} + +function TimelineDetailsPanel({ + node, + nowNs, + onClose, +}: { + node: SpanNode; + nowNs: bigint; + onClose: () => void; +}) { + const startMs = nsToMs(node.startNs); + const endNs = node.endNs ?? nowNs; + const durationMs = nsToMs(endNs - node.startNs); + const details = buildSpanDetails(node.span); + + return ( +
+
+
+ + {node.span.name} + + {node.endNs == null && ( + + )} +
+ +
+
+
+
+
+ Start +
+
+ {format(new Date(startMs), "PPpp")} +
+
+
+
+ Duration +
+
+ {node.endNs + ? formatDuration(durationMs) + : "In progress"} +
+
+
+ {details ? ( +
+
+ Span details +
+ +
+ ) : null} +
+
+ ); +} + +function buildSpanDetails(span: OtlpSpan): Record | null { + const attributes = otlpAttributesToObject(span.attributes); + const links = span.links?.map((link) => ({ + traceId: link.traceId, + spanId: link.spanId, + traceState: link.traceState, + attributes: otlpAttributesToObject(link.attributes), + droppedAttributesCount: link.droppedAttributesCount, + })); + const details: Record = {}; + if (attributes && Object.keys(attributes).length > 0) { + details.attributes = attributes; + } + if (span.status) { + details.status = span.status; + } + if (links && links.length > 0) { + details.links = links; + } + if (span.traceState) { + details.traceState = span.traceState; + } + if (span.flags !== undefined) { + details.flags = span.flags; + } + return Object.keys(details).length > 0 ? details : null; +} diff --git a/frontend/src/components/actors/traces/span-sidebar.tsx b/frontend/src/components/actors/traces/span-sidebar.tsx new file mode 100644 index 0000000000..4c233d7d31 --- /dev/null +++ b/frontend/src/components/actors/traces/span-sidebar.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { + faChevronRight, + faCircle, + faSpinnerThird, + faWavePulse, + Icon, +} from "@rivet-gg/icons"; +import { useState } from "react"; +import { cn } from "@/components"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import type { SpanNode } from "./types"; +import { formatDuration, isSpanInProgress } from "./utils"; + +interface SpanSidebarProps { + spans: SpanNode[]; + selectedSpanId: string | null; + selectedEventIndex: number | null; + onSelectSpan: (spanId: string | null) => void; + onSelectEvent: (spanId: string, eventIndex: number) => void; +} + +export function SpanSidebar({ + spans, + selectedSpanId, + selectedEventIndex, + onSelectSpan, + onSelectEvent, +}: SpanSidebarProps) { + return ( +
+
+
+ + Spans +
+
+
+ {spans.length === 0 ? ( +
+ No spans found +
+ ) : ( +
+ {spans.map((span) => ( + + ))} +
+ )} +
+
+ ); +} + +interface SpanTreeItemProps { + span: SpanNode; + depth: number; + selectedSpanId: string | null; + selectedEventIndex: number | null; + onSelectSpan: (spanId: string | null) => void; + onSelectEvent: (spanId: string, eventIndex: number) => void; +} + +function SpanTreeItem({ + span, + depth, + selectedSpanId, + selectedEventIndex, + onSelectSpan, + onSelectEvent, +}: SpanTreeItemProps) { + const [isOpen, setIsOpen] = useState(true); + const inProgress = isSpanInProgress(span); + const hasChildren = + span.children.length > 0 || (span.events && span.events.length > 0); + const isSelected = + selectedSpanId === span.spanId && selectedEventIndex === null; + const durationMs = span.endNs + ? (span.endNs - span.startNs) / 1_000_000n + : null; + + return ( + +
+ + + + + +
+ + {hasChildren && ( + +
+ {/* Child spans */} + {span.children.map((child) => ( + + ))} + +
    + {/* Events */} + {span.events?.map((event, idx) => { + const isEventSelected = + selectedSpanId === span.spanId && + selectedEventIndex === idx; + + return ( + + ); + })} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/actors/traces/traces-timeline.tsx b/frontend/src/components/actors/traces/traces-timeline.tsx new file mode 100644 index 0000000000..ee0b856c3e --- /dev/null +++ b/frontend/src/components/actors/traces/traces-timeline.tsx @@ -0,0 +1,686 @@ +"use client"; + +import { + faMagnifyingGlassMinus, + faMagnifyingGlassPlus, + faMinusLarge, + Icon, +} from "@rivet-gg/icons"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, cn, formatDuration } from "@/components"; +import type { FlattenedSpan, SpanNode, TimeGap, TimeSegment } from "./types"; + +interface TimelineViewProps { + spans: SpanNode[]; + selectedSpanId: string | null; + selectedEventIndex: number | null; + onSelectSpan: (spanId: string | null) => void; + onSelectEvent: (spanId: string, eventIndex: number) => void; +} + +// Gap detection threshold - gaps larger than this will be collapsed +const GAP_THRESHOLD_MS = 50; // 50ms minimum gap to collapse +const GAP_FIXED_WIDTH_PX = 48; // Fixed pixel width for all gaps +const MIN_ACTIVE_SEGMENT_WIDTH_PX = 8; // Minimum pixel width for active segments + +// Flatten span tree for rendering +function flattenSpans(spans: SpanNode[], depth = 0): FlattenedSpan[] { + const result: FlattenedSpan[] = []; + let row = 0; + + function traverse(span: SpanNode, d: number) { + result.push({ span, depth: d, row: row++ }); + span.children.forEach((child) => traverse(child, d + 1)); + } + + spans.forEach((span) => traverse(span, depth)); + return result; +} + +// Get all spans flattened (for timeline calculations) +function getAllSpans(spans: SpanNode[]): SpanNode[] { + const result: SpanNode[] = []; + + function traverse(span: SpanNode) { + result.push(span); + span.children.forEach(traverse); + } + + spans.forEach(traverse); + return result; +} + +// Detect gaps in the timeline and create segments +function detectTimeSegments( + allSpans: SpanNode[], + minTime: bigint, + maxTime: bigint, +): { segments: TimeSegment[]; gaps: TimeGap[] } { + if (allSpans.length === 0) { + return { segments: [], gaps: [] }; + } + + // Collect all time intervals from spans and events + const intervals: { start: bigint; end: bigint }[] = []; + + allSpans.forEach((span) => { + intervals.push({ + start: span.startNs, + end: span.endNs ?? BigInt(Date.now()) * 1_000_000n, + }); + + // Also include events + span.events?.forEach((event) => { + const eventTime = BigInt(event.timeUnixNano); + intervals.push({ + start: eventTime, + end: eventTime, + }); + }); + }); + + // Sort intervals by start time + intervals.sort((a, b) => + a.start < b.start ? -1 : a.start > b.start ? 1 : 0, + ); + + // Merge overlapping intervals + const mergedIntervals: { start: bigint; end: bigint }[] = []; + let current = { ...intervals[0] }; + + for (let i = 1; i < intervals.length; i++) { + const next = intervals[i]; + // Add small buffer to connect nearby spans (10ms) + const buffer = 10n * 1_000_000n; // 10ms in nanos + if (next.start <= current.end + buffer) { + // Overlapping or close, extend current + current.end = next.end > current.end ? next.end : current.end; + } else { + mergedIntervals.push(current); + current = { ...next }; + } + } + mergedIntervals.push(current); + + // Build segments and gaps + const segments: TimeSegment[] = []; + const gaps: TimeGap[] = []; + + // Add segment from minTime to first interval if there's a gap + if (mergedIntervals[0].start > minTime) { + const gapDurationMs = + Number(mergedIntervals[0].start - minTime) / 1_000_000; + if (gapDurationMs > GAP_THRESHOLD_MS) { + gaps.push({ + startTime: minTime, + endTime: mergedIntervals[0].start, + durationMs: gapDurationMs, + }); + segments.push({ + type: "gap", + startTime: minTime, + endTime: mergedIntervals[0].start, + originalDuration: gapDurationMs, + displayDuration: 0, // Not used for gaps - we use fixed pixel width + }); + } + } + + // Process merged intervals + for (let i = 0; i < mergedIntervals.length; i++) { + const interval = mergedIntervals[i]; + + // Add active segment + const activeDuration = + Number(interval.end - interval.start) / 1_000_000; + segments.push({ + type: "active", + startTime: interval.start, + endTime: interval.end, + originalDuration: activeDuration, + displayDuration: activeDuration, + }); + + // Check for gap before next interval + if (i < mergedIntervals.length - 1) { + const nextInterval = mergedIntervals[i + 1]; + const gapDurationMs = + Number(nextInterval.start - interval.end) / 1_000_000; + + if (gapDurationMs > GAP_THRESHOLD_MS) { + gaps.push({ + startTime: interval.end, + endTime: nextInterval.start, + durationMs: gapDurationMs, + }); + segments.push({ + type: "gap", + startTime: interval.end, + endTime: nextInterval.start, + originalDuration: gapDurationMs, + displayDuration: 0, // Not used for gaps - we use fixed pixel width + }); + } + } + } + + // Add segment from last interval to maxTime if there's a gap + const lastInterval = mergedIntervals[mergedIntervals.length - 1]; + if (lastInterval.end < maxTime) { + const gapDurationMs = Number(maxTime - lastInterval.end) / 1_000_000; + if (gapDurationMs > GAP_THRESHOLD_MS) { + gaps.push({ + startTime: lastInterval.end, + endTime: maxTime, + durationMs: gapDurationMs, + }); + segments.push({ + type: "gap", + startTime: lastInterval.end, + endTime: maxTime, + originalDuration: gapDurationMs, + displayDuration: 0, // Not used for gaps - we use fixed pixel width + }); + } + } + + return { segments, gaps }; +} + +// Convert original time to display position (accounting for collapsed gaps with fixed pixel width) +function createTimeMapper(segments: TimeSegment[], timelineWidth: number) { + if (segments.length === 0) { + return { + totalWidth: 0, + mapTimeToPixel: () => 0, + }; + } + + // Count gaps and calculate total active duration + const gapCount = segments.filter((s) => s.type === "gap").length; + const totalGapWidth = gapCount * GAP_FIXED_WIDTH_PX; + const remainingWidth = Math.max(0, timelineWidth - totalGapWidth); + + const totalActiveDuration = segments + .filter((s) => s.type === "active") + .reduce((sum, seg) => sum + seg.originalDuration, 0); + + // Pixels per ms for active segments + const pxPerMs = + totalActiveDuration > 0 ? remainingWidth / totalActiveDuration : 0; + + // Build cumulative pixel positions for each segment + const segmentPositions: { + segment: TimeSegment; + pxStart: number; + pxEnd: number; + }[] = []; + let pxOffset = 0; + + for (const segment of segments) { + let segmentWidth: number; + if (segment.type === "gap") { + segmentWidth = GAP_FIXED_WIDTH_PX; + } else { + // Ensure active segments have a minimum width + segmentWidth = Math.max( + segment.originalDuration * pxPerMs, + MIN_ACTIVE_SEGMENT_WIDTH_PX, + ); + } + + segmentPositions.push({ + segment, + pxStart: pxOffset, + pxEnd: pxOffset + segmentWidth, + }); + pxOffset += segmentWidth; + } + + // Map a time (in nanos) to a pixel position + const mapTimeToPixel = (timeNanos: bigint): number => { + for (const { segment, pxStart, pxEnd } of segmentPositions) { + if ( + timeNanos >= segment.startTime && + timeNanos <= segment.endTime + ) { + // Calculate progress within this segment + const elapsed = Number(timeNanos - segment.startTime); + const total = Number(segment.endTime - segment.startTime); + const progress = total === 0 ? 0.5 : elapsed / total; + return pxStart + progress * (pxEnd - pxStart); + } + } + + // If time is before first segment + if (timeNanos < segmentPositions[0].segment.startTime) { + return 0; + } + + // If time is after last segment + return timelineWidth; + }; + + return { totalWidth: pxOffset, mapTimeToPixel }; +} + +export function TimelineView({ + spans, + selectedSpanId, + selectedEventIndex, + onSelectSpan, + onSelectEvent, +}: TimelineViewProps) { + const containerRef = useRef(null); + const [zoom, setZoom] = useState(1); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, scrollLeft: 0 }); + + const flattenedSpans = useMemo(() => flattenSpans(spans), [spans]); + const allSpans = useMemo(() => getAllSpans(spans), [spans]); + + // Calculate timeline bounds + const { minTime, maxTime } = useMemo(() => { + if (allSpans.length === 0) { + return { minTime: 0n, maxTime: 0n }; + } + + let min = allSpans[0].startNs; + let max = allSpans[0].endNs ?? BigInt(Date.now()) * 1_000_000n; + + for (const span of allSpans) { + if (span.startNs < min) min = span.startNs; + const end = span.endNs ?? BigInt(Date.now()) * 1_000_000n; + if (end > max) max = end; + } + + return { + minTime: min, + maxTime: max, + }; + }, [allSpans]); + + // Detect time gaps and create segments + const { segments, gaps } = useMemo( + () => detectTimeSegments(allSpans, minTime, maxTime), + [allSpans, minTime, maxTime], + ); + + // Row height and spacing + const rowHeight = 40; + const rowGap = 8; + const totalHeight = flattenedSpans.length * (rowHeight + rowGap); + + // Base width calculation + const baseWidth = 1200; + const timelineWidth = baseWidth * zoom; + + // Create time mapper for converting real time to pixel position + const { totalWidth, mapTimeToPixel } = useMemo( + () => createTimeMapper(segments, timelineWidth), + [segments, timelineWidth], + ); + + // Calculate position and width for a span (using gap-aware mapping) + const getSpanStyle = useCallback( + (span: SpanNode) => { + if (totalWidth === 0) return { left: "0px", width: "100%" }; + + const startPx = mapTimeToPixel(span.startNs); + const endTime = span.endNs ?? BigInt(Date.now()) * 1_000_000n; + const endPx = mapTimeToPixel(endTime); + const widthPx = endPx - startPx; + + return { + left: `${startPx}px`, + width: `${Math.max(widthPx, 4)}px`, + }; + }, + [totalWidth, mapTimeToPixel], + ); + + // Calculate gap indicator positions (in pixels) + const gapIndicators = useMemo(() => { + return gaps.map((gap) => { + const startPx = mapTimeToPixel(gap.startTime); + const endPx = mapTimeToPixel(gap.endTime); + const centerPx = (startPx + endPx) / 2; + return { + gap, + centerPx, + startPx, + endPx, + }; + }); + }, [gaps, mapTimeToPixel]); + + // Zoom controls + const handleZoomIn = () => setZoom((z) => Math.min(z * 1.5, 10)); + const handleZoomOut = () => setZoom((z) => Math.max(z / 1.5, 0.5)); + const handleZoomReset = () => setZoom(1); + + // Pan handling + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0) return; + setIsDragging(true); + setDragStart({ + x: e.clientX, + scrollLeft: containerRef.current?.scrollLeft ?? 0, + }); + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !containerRef.current) return; + const dx = e.clientX - dragStart.x; + containerRef.current.scrollLeft = dragStart.scrollLeft - dx; + }, + [isDragging, dragStart], + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + if (spans.length === 0) { + return ( +
+ No traces to display +
+ ); + } + + return ( +
+ {/* Timeline content */} +
+
+ {/* Time scale header */} +
+ +
+ + {/* Gap indicators - vertical stripes across the timeline */} + {gapIndicators.map((indicator, idx) => ( +
+ {/* Striped background pattern */} +
+
+ ))} + + {/* Spans */} +
+ {flattenedSpans.map(({ span, depth, row }) => { + const style = getSpanStyle(span); + const isSelected = selectedSpanId === span.spanId; + const durationMs = span.endTimeUnixNano + ? (span.endTimeUnixNano - + span.startTimeUnixNano) / + 1_000_000 + : null; + + return ( +
+ {/* Span bar */} + + + {/* Events as dots */} + {span.events?.map((event, idx) => { + // Calculate event position relative to span start + const eventPx = mapTimeToPixel( + BigInt(event.timeUnixNano), + ); + const spanStartPx = mapTimeToPixel( + span.startNs, + ); + const relativePx = + eventPx - spanStartPx; + const isEventSelected = + isSelected && + selectedEventIndex === idx; + + return ( +
+ ); + })} +
+
+
+ +
+ + + +
+
+ ); +} + +interface GapIndicatorInfo { + gap: TimeGap; + centerPx: number; + startPx: number; + endPx: number; +} + +function TimeScale({ + totalWidth, + totalElapsedMs, + gapIndicators, +}: { + totalWidth: number; + totalElapsedMs: number; + gapIndicators: GapIndicatorInfo[]; +}) { + // Generate tick marks based on segments + const ticks = useMemo(() => { + if (totalWidth === 0) return []; + + const result: { + position: string; + label: string; + isGapLabel?: boolean; + }[] = []; + + // Add start tick + result.push({ + position: "0px", + label: "0ms", + }); + + // Add gap labels using the gapIndicators positions + for (const indicator of gapIndicators) { + result.push({ + position: `${indicator.centerPx}px`, + label: formatGapDuration(indicator.gap.durationMs), + isGapLabel: true, + }); + } + + // Add end tick with total real elapsed time + result.push({ + position: `${totalWidth}px`, + label: formatDuration(totalElapsedMs), + }); + + return result; + }, [totalWidth, totalElapsedMs, gapIndicators]); + + return ( +
+ {ticks.map((tick, i) => ( +
+ {tick.isGapLabel ? ( + <> +
+ + {tick.label} skipped + + + ) : ( + <> +
+ + {tick.label} + + + )} +
+ ))} +
+ ); +} + +// Format gap duration in a human-readable way +function formatGapDuration(ms: number): string { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + if (ms < 60000) { + return `${(ms / 1000).toFixed(1)}s`; + } + if (ms < 3600000) { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + const hours = Math.floor(ms / 3600000); + const minutes = Math.floor((ms % 3600000) / 60000); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} diff --git a/frontend/src/components/actors/traces/types.ts b/frontend/src/components/actors/traces/types.ts new file mode 100644 index 0000000000..70c29202b2 --- /dev/null +++ b/frontend/src/components/actors/traces/types.ts @@ -0,0 +1,41 @@ +import type { OtlpSpan, OtlpSpanEvent } from "@rivetkit/traces"; +import type { ReactNode } from "react"; + +export interface FlattenedSpan { + span: SpanNode; + depth: number; + row: number; +} + +export type SpanNode = { + timeUnixNano: bigint; + spanId: string | null; + endTimeUnixNano: any; + startTimeUnixNano: any; + name: ReactNode; + span: OtlpSpan; + startNs: bigint; + endNs: bigint | null; + children: SpanNode[]; + events: OtlpSpanEvent[]; +}; + +export type TraceItem = + | { type: "span"; node: SpanNode; timeNs: bigint } + | { type: "event"; event: OtlpSpanEvent; timeNs: bigint }; + +// Represents a time gap that was collapsed +export interface TimeGap { + startTime: bigint; // nanos + endTime: bigint; // nanos + durationMs: number; +} + +// Represents a time segment (either active or gap) +export interface TimeSegment { + type: "active" | "gap"; + startTime: bigint; // nanos + endTime: bigint; // nanos + originalDuration: number; // ms - actual duration + displayDuration: number; // ms - collapsed duration for gaps +} diff --git a/frontend/src/components/actors/traces/utils.ts b/frontend/src/components/actors/traces/utils.ts new file mode 100644 index 0000000000..d197d31d97 --- /dev/null +++ b/frontend/src/components/actors/traces/utils.ts @@ -0,0 +1,69 @@ +import type { SpanNode } from "./types"; + +// Helper to check if a span is in progress +export function isSpanInProgress(span: SpanNode): boolean { + return span.endNs === undefined; +} + +// Helper to calculate span duration in ms +export function getSpanDurationMs(span: SpanNode): number | null { + if (!span.endTimeUnixNano) return null; + return (span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000; +} + +// Format duration for display +export function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms.toFixed(0)}ms`; + } + return `${(ms / 1000).toFixed(1)}s`; +} + +// Format nanoseconds timestamp to time string +export function formatTimestamp(nanos: number): string { + const date = new Date(nanos / 1_000_000); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +export function buildSpanTree(spans: SpanNode[]): SpanNode[] { + const spanMap = new Map(); + const roots: SpanNode[] = []; + + // First pass: create nodes + spans.forEach((span) => { + spanMap.set(span.spanId, { ...span, children: [] }); + }); + + // Second pass: build tree + spans.forEach((span) => { + const node = spanMap.get(span.spanId)!; + if (span.parentSpanId && spanMap.has(span.parentSpanId)) { + spanMap.get(span.parentSpanId)!.children.push(node); + } else { + roots.push(node); + } + }); + + // Sort by start time + const sortByStartTime = (a: SpanNode, b: SpanNode) => + a.startTimeUnixNano - b.startTimeUnixNano; + + roots.sort(sortByStartTime); + spanMap.forEach((node) => node.children.sort(sortByStartTime)); + + return roots; +} + +// Get total event count for a span (including children) +export function getTotalEventCount(span: SpanNode): number { + const ownEvents = span.events?.length ?? 0; + const childEvents = span.children.reduce( + (sum, child) => sum + getTotalEventCount(child), + 0, + ); + return ownEvents + childEvents + span.children.length; +}