diff --git a/.gitignore b/.gitignore index dd3880690..f1eada8fa 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,4 @@ packages/tanstack-start/* # claude code .claude/scheduled_tasks.lock +.gstack/ diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx index 48e44b316..6f886d08d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx @@ -42,7 +42,7 @@ import useResizeObserver from '@react-hook/resize-observer'; import { useUser } from "@hexclave/next"; import { ALL_APPS } from "@hexclave/shared/dist/apps/apps-config"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; -import { LayoutGroup, motion, useReducedMotion, type Transition } from "motion/react"; +import { StickyPageHeader } from "../sticky-page-header"; import { ErrorBoundary } from "next/dist/client/components/error-boundary"; import { type ElementType, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AnalyticsEventLimitBanner } from "../analytics/shared"; @@ -114,99 +114,6 @@ function formatPagesPerVisitor(value: number): string { return value.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } -const OVERVIEW_HEADER_COMPACT_SCROLL_TOP = 24; -const OVERVIEW_HEADER_MORPH_MS = 520; -const OVERVIEW_HEADER_TITLE_EXIT_MS = 150; -const overviewHeaderLayoutTransition: Transition = { - duration: OVERVIEW_HEADER_MORPH_MS / 1000, - ease: [0.32, 0.72, 0, 1], -}; -const reducedOverviewHeaderLayoutTransition: Transition = { - duration: 0, -}; - -const scrollableOverflowValues = new Set(["auto", "scroll", "overlay"]); - -function findScrollContainer(element: HTMLElement): HTMLElement | null { - let current = element.parentElement; - while (current != null) { - const overflowY = window.getComputedStyle(current).overflowY; - if (scrollableOverflowValues.has(overflowY) && current.scrollHeight > current.clientHeight) { - return current; - } - current = current.parentElement; - } - - return null; -} - -function useOverviewHeaderCompacted(enabled: boolean) { - const sentinelRef = useRef(null); - const [compacted, setCompacted] = useState(false); - - useEffect(() => { - if (!enabled) { - setCompacted(false); - return; - } - - const sentinel = sentinelRef.current; - if (sentinel == null) return; - - const scrollContainer = findScrollContainer(sentinel); - - const observer = new IntersectionObserver((entries) => { - const entry = entries[0]; - const nextCompacted = !entry.isIntersecting; - setCompacted((current) => current === nextCompacted ? current : nextCompacted); - }, { - root: scrollContainer, - rootMargin: `-${OVERVIEW_HEADER_COMPACT_SCROLL_TOP}px 0px 0px 0px`, - threshold: 0, - }); - - observer.observe(sentinel); - - return () => { - observer.disconnect(); - }; - }, [enabled]); - - return { compacted, sentinelRef }; -} - -function useRenderWhileClosing(open: boolean, durationMs: number): boolean { - const [shouldRender, setShouldRender] = useState(open); - - useEffect(() => { - if (open) { - setShouldRender(true); - return; - } - - const timeout = setTimeout(() => setShouldRender(false), durationMs); - return () => clearTimeout(timeout); - }, [durationMs, open]); - - return open || shouldRender; -} - -function useDelayedTrue(value: boolean, delayMs: number): boolean { - const [delayedValue, setDelayedValue] = useState(value); - - useEffect(() => { - if (!value) { - setDelayedValue(false); - return; - } - - const timeout = setTimeout(() => setDelayedValue(true), delayMs); - return () => clearTimeout(timeout); - }, [delayMs, value]); - - return delayedValue; -} - const BROWSER_SLUGS = new Map([ ["chrome", "googlechrome"], ["google chrome", "googlechrome"], @@ -631,122 +538,6 @@ function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange: ); } -function OverviewHeaderChrome({ - title, - actions, - compacted, - layoutCompacted, - renderTitle, - layoutTransition, - animateLayout, -}: { - title: string, - actions: ReactNode, - compacted: boolean, - layoutCompacted: boolean, - renderTitle: boolean, - layoutTransition: Transition, - animateLayout: boolean, -}) { - return ( - - -
-
- {renderTitle && ( -
- - {title} - -
- )} - - {actions} - -
- - ); -} - -function OverviewHeader({ title, actions, sticky }: { title: string, actions: ReactNode, sticky: boolean }) { - const { compacted, sentinelRef } = useOverviewHeaderCompacted(sticky); - const renderTitle = useRenderWhileClosing(!compacted, OVERVIEW_HEADER_TITLE_EXIT_MS); - const shouldReduceMotion = useReducedMotion(); - const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : OVERVIEW_HEADER_TITLE_EXIT_MS); - const layoutCompacted = sticky && (shouldReduceMotion ? compacted : delayedCompacted); - const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition; - - return ( - <> - {sticky && ( -
- )} -
- - - -
- - ); -} - function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) { // Fills the height granted by PageLayout's containedHeight mode (the globe // tab sets it) instead of guessing the chrome height with 100vh math, which @@ -1456,7 +1247,7 @@ export default function MetricsPage() { > {/* The globe tab is a contained, no-scroll scene. A sticky top offset would shift this bar over the globe card and clip the live-users badge. */} - + {view === "overview" && } {/* Inside the error boundary so a failed filtered fetch surfaces the diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx index fa5aceb11..afc081ac9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/page-client.tsx @@ -1,18 +1,18 @@ "use client"; -import { Button, Input, Typography } from "@/components/ui"; +import { Button, Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { ArrowClockwiseIcon, SpinnerGapIcon } from "@phosphor-icons/react"; +import { ArrowClockwiseIcon, ChartLineIcon, SpinnerGapIcon, StackIcon, TreeStructureIcon } from "@phosphor-icons/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; +import { StickyPageHeader } from "../../sticky-page-header"; import { useAdminApp } from "../../use-admin-app"; import { AnalyticsEventLimitBanner, ErrorDisplay, RowDetailDialog, - VirtualizedFlatTable, isDateValue, parseClickHouseDate, type RowData, @@ -30,14 +30,32 @@ import { import { TraceWaterfall, type TraceBreadcrumb } from "./waterfall"; const SPANS_QUERY = ` -SELECT id, span_type, span_started_at, span_ended_at, parent_span_ids, data, - user_id, refresh_token_id, session_replay_id, session_replay_segment_id -FROM default.spans -WHERE span_started_at >= now64(3) - INTERVAL {hours:UInt32} HOUR -ORDER BY span_started_at DESC +SELECT + s.id, + s.span_type, + s.span_started_at, + s.span_ended_at, + s.parent_span_ids, + s.data, + s.user_id, + s.refresh_token_id, + s.session_replay_id, + s.session_replay_segment_id, + u.display_name AS user_display_name, + u.primary_email AS user_primary_email, + u.profile_image_url AS user_profile_image_url +FROM default.spans AS s +LEFT ANY JOIN default.users AS u + ON s.project_id = u.project_id + AND s.branch_id = u.branch_id + AND s.user_id = toString(u.id) +WHERE s.span_started_at >= now64(3) - INTERVAL {hours:UInt32} HOUR +ORDER BY s.span_started_at DESC LIMIT 3000 `; +// Events are not browsable on this page, but they still render as markers +// inside their parent span's waterfall row, so we fetch them for correlation. const EVENTS_QUERY = ` SELECT event_type, event_at, data, user_id, parent_span_ids, refresh_token_id, session_replay_id, session_replay_segment_id @@ -54,8 +72,6 @@ const TIME_RANGES = [ { label: "30d", hours: 720 }, ] as const; -const EVENT_TABLE_COLUMNS = ["event_type", "event_at", "user_id", "parent_span_ids", "data"]; - const CARD_CLASSES = "rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl dark:border-white/[0.06] dark:bg-zinc-900/90"; function tryParseJson(value: unknown): unknown { @@ -117,7 +133,7 @@ function Segmented({ value, onChange, options }: { className={cn( "px-2.5 py-1 rounded-md text-xs font-medium transition-colors hover:transition-none", value === option.value - ? "bg-background shadow-sm text-foreground" + ? "bg-white text-foreground shadow-sm ring-1 ring-black/[0.04] dark:bg-zinc-950 dark:ring-white/[0.06]" : "text-muted-foreground hover:text-foreground", )} > @@ -130,21 +146,33 @@ function Segmented({ value, onChange, options }: { function EmptyState({ title, children }: { title: string, children?: React.ReactNode }) { return ( -
+
{title} {children}
); } +function HeaderCountStat({ icon, value, label }: { icon: React.ReactNode, value: number, label: string }) { + return ( + + + + {icon} + {value} + + + {label} + + ); +} + export default function PageClient() { const adminApp = useAdminApp(); const [hours, setHours] = useState(24); - const [tab, setTab] = useState<"traces" | "events">("traces"); - const [scope, setScope] = useState<"custom" | "all">("custom"); + const [scope, setScope] = useState<"custom" | "all">("all"); const [search, setSearch] = useState(""); - const [eventTypeFilter, setEventTypeFilter] = useState(null); const [selectedRootId, setSelectedRootId] = useState(null); const [detailRow, setDetailRow] = useState(null); @@ -236,83 +264,78 @@ export default function PageClient() { return { trace: selectedTrace, breadcrumb: [] }; }, [selectedTrace, focusedSpanId]); - const eventTypeCounts = useMemo(() => { - const counts = new Map(); - for (const event of scopedEvents) { - counts.set(event.eventType, (counts.get(event.eventType) ?? 0) + 1); - } - return [...counts.entries()].sort((a, b) => b[1] - a[1]); - }, [scopedEvents]); - - const tableEvents = useMemo(() => { - const needle = search.trim().toLowerCase(); - return scopedEvents.filter((event) => - (eventTypeFilter == null || event.eventType === eventTypeFilter) - && (needle === "" || event.eventType.toLowerCase().includes(needle)), - ); - }, [scopedEvents, eventTypeFilter, search]); - const openDetail = useCallback((raw: Record) => { setDetailRow(raw); }, []); return ( - - ({ label: range.label, value: range.hours }))} /> - + + {/* StickyPageHeader's sentinel uses -mb-[17px]; compensate so this dense page keeps matching top/side gutters. */} +
+ +
+ setScope(value)} + options={[{ label: "All", value: "all" as const }, { label: "Custom", value: "custom" as const }]} + /> + setSearch(e.target.value)} + placeholder="Filter by span or event type…" + className="h-8 w-56 text-xs" + /> +
+ } value={filteredTraces.length} label={`${filteredTraces.length.toLocaleString()} ${filteredTraces.length === 1 ? "trace" : "traces"}`} /> + } value={scopedSpans.length} label={`${scopedSpans.length.toLocaleString()} ${scopedSpans.length === 1 ? "span" : "spans"}`} /> + } value={scopedEvents.length} label={`${scopedEvents.length.toLocaleString()} ${scopedEvents.length === 1 ? "event" : "events"}`} /> +
+ ({ label: range.label, value: range.hours }))} /> + +
+ + } + /> + +
+
- } - > - - {/* Tab + filter bar */} -
- setTab(value)} - options={[{ label: "Traces", value: "traces" as const }, { label: "Events", value: "events" as const }]} - /> - setScope(value)} - options={[{ label: "Custom", value: "custom" as const }, { label: "All (incl. system)", value: "all" as const }]} - /> - setSearch(e.target.value)} - placeholder={tab === "traces" ? "Filter by span or event type…" : "Filter by event type…"} - className="h-8 w-56 text-xs" - /> - - {filteredTraces.length} {filteredTraces.length === 1 ? "trace" : "traces"} · {scopedSpans.length} {scopedSpans.length === 1 ? "span" : "spans"} · {scopedEvents.length} {scopedEvents.length === 1 ? "event" : "events"} - -
- - {tab === "traces" ? ( -
- {/* Trace list */} -
-
+
+ {/* Trace list: on desktop it scrolls with the waterfall until it + reaches the page's sticky top edge, then scrolls internally if + taller than the viewport. On narrow screens it stacks above + the waterfall with a capped height. */} +
+
Traces
-
+
{loading && ( -
+
)} @@ -343,15 +366,18 @@ export default function PageClient() {
- {/* Waterfall */} -
+ {/* Waterfall: grows with its rows so the page scrolls; deep rows + slide up behind the floating header pill, like the overview. */} +
{loading && ( -
+
)} {!loading && error == null && displayedTrace == null && ( - +
+ +
)} {!loading && error == null && displayedTrace != null && ( )} {!loading && error != null && ( -
+
)}
- ) : ( -
- {/* Event type breakdown chips */} - {eventTypeCounts.length > 0 && ( -
- {eventTypeCounts.slice(0, 12).map(([eventType, count]) => ( - - ))} - {eventTypeFilter != null && ( - - )} -
- )} - {loading && ( -
- -
- )} - {!loading && error != null && ( -
- -
- )} - {!loading && error == null && tableEvents.length === 0 && ( - -
-                  {`await app.trackEvent("signup_completed",\n  { plan: "pro" });`}
-                
-
- )} - {!loading && error == null && tableEvents.length > 0 && ( - event.raw)} - onRowClick={(row) => openDetail(row)} - /> - )} -
- )} +
{formatDuration(endMs - startMs)}; } +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function userProfileLabel(node: TraceNode): string | null { + return stringValue(node.span.raw.user_display_name) + ?? stringValue(node.span.raw.user_primary_email); +} + +function userProfileInitials(label: string): string { + return label.trim().slice(0, 2).toUpperCase(); +} + +function escapeSvgText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function initialsAvatarDataUrl(initials: string): string { + const svg = `${escapeSvgText(initials)}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +function TraceAvatar({ imageUrl, label }: { imageUrl: string | null, label: string | null }) { + const avatarSrc = imageUrl ?? (label != null ? initialsAvatarDataUrl(userProfileInitials(label)) : undefined); + return ( + + + + + ); +} + +function collectVisibleSubtreeIds(node: TraceNode, needle: string): string[] { + const visibleChildren = needle === "" + ? node.children + : node.children.filter((child) => subtreeMatches(child, needle)); + return [ + node.span.id, + ...visibleChildren.flatMap((child) => collectVisibleSubtreeIds(child, needle)), + ]; +} + function TreeNode({ node, trace, @@ -49,8 +93,8 @@ function TreeNode({ nowMs: number, needle: string, activeSpanId: string | null, - expandedOverrides: Record, - onToggle: (spanId: string) => void, + expandedOverrides: Map, + onToggle: (node: TraceNode, recursive: boolean) => void, onSelectSpan: (rootId: string, spanId: string) => void, }) { const isSearching = needle !== ""; @@ -58,38 +102,48 @@ function TreeNode({ ? node.children.filter((child) => subtreeMatches(child, needle)) : node.children; const hasChildren = visibleChildren.length > 0; - const expanded = expandedOverrides[node.span.id] + const expanded = expandedOverrides.get(node.span.id) ?? (isSearching ? hasChildren : node.depth < DEFAULT_EXPANDED_DEPTH); const isActive = node.span.id === activeSpanId; const isRoot = node.depth === 0; + const userProfileImageUrl = stringValue(node.span.raw.user_profile_image_url); + const userProfile = userProfileLabel(node); + const rowGridTemplateColumns = `${8 + node.depth * 12}px 16px 20px minmax(0, 1fr)`; return ( <>
onSelectSpan(trace.root.span.id, node.span.id)} > + {hasChildren ? ( ) : ( - + )} - -
-
+ {isRoot ? ( + + ) : ( + + )} +
+
{node.span.spanType} - + {/* 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. */} @@ -111,9 +165,9 @@ function TreeNode({
{isRoot && ( -
+
{new Date(trace.startMs).toLocaleString()} - {trace.spanCount}s · {trace.eventCount}e + {trace.spanCount}s · {trace.eventCount}e
)}
@@ -155,15 +209,20 @@ export function SpanTreeList({ activeSpanId: string | null, onSelectSpan: (rootId: string, spanId: string) => void, }) { - const [expandedOverrides, setExpandedOverrides] = useState>({}); + const [expandedOverrides, setExpandedOverrides] = useState>(() => new Map()); - const handleToggle = (spanId: string) => { + const handleToggle = (node: TraceNode, recursive: boolean) => { 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 }; + const currentlyExpanded = prev.get(node.span.id) + ?? (isSearching ? node.children.length > 0 : node.depth < DEFAULT_EXPANDED_DEPTH); + const nextExpanded = !currentlyExpanded; + const next = new Map(prev); + const spanIds = recursive ? collectVisibleSubtreeIds(node, needle) : [node.span.id]; + for (const spanId of spanIds) { + next.set(spanId, nextExpanded); + } + return next; }); }; @@ -185,14 +244,3 @@ export function SpanTreeList({
); } - -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; -} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx index e61ad86a5..fd7e837a4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/spans-events/waterfall.tsx @@ -1,11 +1,10 @@ "use client"; -import { Badge } from "@/components/ui"; +import { Badge, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { CaretRightIcon, CornersOutIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, ChartLineIcon, ClockIcon, CornersOutIcon, KeyboardIcon, StackIcon } from "@phosphor-icons/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { - flattenTrace, formatDuration, isSystemSpanType, panViewWindow, @@ -13,7 +12,9 @@ import { type EventInput, type SpanInput, type Trace, + type TraceNode, type ViewWindow, + type WaterfallRow, } from "./trace-utils"; const SPAN_COLOR_CLASSES = [ @@ -43,6 +44,43 @@ const FULL_VIEW: ViewWindow = { start: 0, end: 1 }; export type TraceBreadcrumb = { spanId: string, spanType: string }; +type DragSelection = { + anchor: number, + current: number, +}; + +function clampUnit(value: number): number { + return Math.min(Math.max(value, 0), 1); +} + +function timelineFraction(clientX: number, rect: { left: number, width: number }): number { + if (rect.width <= 0) return 0; + return clampUnit((clientX - rect.left) / rect.width); +} + +function collectSubtreeSpanIds(node: TraceNode): string[] { + return [ + node.span.id, + ...node.children.flatMap(collectSubtreeSpanIds), + ]; +} + +function flattenVisibleTrace(trace: Trace, collapsedSpanIds: Set): WaterfallRow[] { + const rows: WaterfallRow[] = []; + const walk = (node: TraceNode) => { + rows.push({ kind: "span", node }); + if (collapsedSpanIds.has(node.span.id)) return; + const items: { atMs: number, row: () => void }[] = [ + ...node.events.map((event) => ({ atMs: event.atMs, row: () => rows.push({ kind: "event", event, depth: node.depth + 1 }) })), + ...node.children.map((child) => ({ atMs: child.span.startMs, row: () => walk(child) })), + ]; + items.sort((a, b) => a.atMs - b.atMs); + for (const item of items) item.row(); + }; + walk(trace.root); + return rows; +} + function TimelineGridlines() { return ( <> @@ -53,6 +91,32 @@ function TimelineGridlines() { ); } +function DragSelectionOverlay({ selection }: { selection: DragSelection | null }) { + if (selection == null) return null; + const left = Math.min(selection.anchor, selection.current) * 100; + const width = Math.abs(selection.current - selection.anchor) * 100; + return ( + + ); +} + +function TraceHeaderStat({ icon, value, label }: { icon: React.ReactNode, value: number | string, label: string }) { + return ( + + + + {icon} + {value} + + + {label} + + ); +} + export function TraceWaterfall({ trace, nowMs, @@ -70,14 +134,17 @@ export function TraceWaterfall({ onSelectSpan: (span: SpanInput) => void, onSelectEvent: (event: EventInput) => void, }) { - const rows = useMemo(() => flattenTrace(trace), [trace]); + const [collapsedSpanIds, setCollapsedSpanIds] = useState>(() => new Set()); + const rows = useMemo(() => flattenVisibleTrace(trace, collapsedSpanIds), [trace, collapsedSpanIds]); const [view, setView] = useState(FULL_VIEW); + const [dragSelection, setDragSelection] = useState(null); const viewRef = useRef(view); viewRef.current = view; const rootSpanId = trace.root.span.id; useEffect(() => { setView(FULL_VIEW); + setCollapsedSpanIds(new Set()); }, [rootSpanId]); // The scale is clamped to "now": a $refresh-token's expiry a year out must @@ -120,170 +187,258 @@ export function TraceWaterfall({ return () => container.removeEventListener("wheel", handleWheel); }, []); + const startTimelineDrag = (event: React.PointerEvent) => { + if (event.button !== 0) return; + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width <= 0) return; + event.preventDefault(); + event.stopPropagation(); + const anchor = timelineFraction(event.clientX, rect); + setDragSelection({ anchor, current: anchor }); + + const handlePointerMove = (moveEvent: PointerEvent) => { + setDragSelection({ anchor, current: timelineFraction(moveEvent.clientX, rect) }); + }; + const handlePointerUp = (upEvent: PointerEvent) => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + const current = timelineFraction(upEvent.clientX, rect); + setDragSelection(null); + const start = Math.min(anchor, current); + const end = Math.max(anchor, current); + if (end - start < 0.015) return; + setView((prev) => { + const span = prev.end - prev.start; + return { + start: prev.start + start * span, + end: prev.start + end * span, + }; + }); + }; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }; + + const toggleCollapsed = (node: TraceNode, recursive: boolean) => { + setCollapsedSpanIds((prev) => { + const next = new Set(prev); + const shouldCollapse = !next.has(node.span.id); + const ids = recursive ? collectSubtreeSpanIds(node) : [node.span.id]; + for (const id of ids) { + if (shouldCollapse) { + next.add(id); + } else { + next.delete(id); + } + } + return next; + }); + }; + const gridTemplateColumns = `${NAME_COLUMN} 1fr ${DURATION_COLUMN}`; return ( -
- {/* Trace header */} -
- {breadcrumb.map((crumb) => ( - - - - - ))} - {trace.root.span.spanType} - {trace.endMs == null ? ( - open - ) : trace.endMs > nowMs ? ( - - {formatDuration(nowMs - trace.startMs)} → - - ) : ( - {formatDuration(trace.endMs - trace.startMs)} - )} - - {trace.spanCount} {trace.spanCount === 1 ? "span" : "spans"} · {trace.eventCount} {trace.eventCount === 1 ? "event" : "events"} · started {new Date(trace.startMs).toLocaleString()} - -
+ +
+
+ {/* Trace header */} +
+ {breadcrumb.map((crumb) => ( + + + + + ))} + {trace.root.span.spanType} + {trace.endMs == null ? ( + open + ) : trace.endMs > nowMs ? ( + + {formatDuration(nowMs - trace.startMs)} → + + ) : ( + {formatDuration(trace.endMs - trace.startMs)} + )} +
+ } value={trace.spanCount} label={`${trace.spanCount} ${trace.spanCount === 1 ? "span" : "spans"}`} /> + } value={trace.eventCount} label={`${trace.eventCount} ${trace.eventCount === 1 ? "event" : "events"}`} /> + } value={new Date(trace.startMs).toLocaleTimeString()} label={`Started ${new Date(trace.startMs).toLocaleString()}`} /> + + + + + + + +
Drag timeline: zoom to range
+
Cmd/Ctrl + scroll: zoom
+
Horizontal scroll: pan
+
Option/Alt + caret: collapse subtree
+
+
+
+
- {/* Time ruler */} -
- name -
- {[0, 25, 50, 75, 100].map((pct) => ( - + name +
e.stopPropagation()} + > + + {[0, 25, 50, 75, 100].map((pct) => ( + - {formatDuration((viewStartMs - scaleStart) + (viewSpanMs * pct) / 100)} + style={{ left: `${pct}%` }} + > + {formatDuration((viewStartMs - scaleStart) + (viewSpanMs * pct) / 100)} + + ))} +
+ + {isZoomed ? ( + + ) : ( + "duration" + )} - ))} +
- - {isZoomed ? ( - - ) : ( - "duration" - )} - -
- {/* Rows */} -
- {rows.map((row, index) => { - 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 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 ( -
onSelectSpan(span)} - > -
- - - {span.spanType} - - {!isViewRoot && ( - + ) : ( + + )} + + + {span.spanType} + + {!isViewRoot && ( + - )} -
-
- - {barVisible && ( - + + + )} +
+
e.stopPropagation()}> + + + {barVisible && ( + - )} + style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "3px" }} + title={runsIntoFuture ? `Ends ${new Date(span.endMs ?? 0).toLocaleString()}` : undefined} + /> + )} +
+ + {open + ? "open" + : runsIntoFuture + ? {formatDuration(nowMs - span.startMs)} → + : formatDuration((span.endMs ?? scaleEnd) - span.startMs)} +
- - {open - ? "open" - : runsIntoFuture - ? {formatDuration(nowMs - span.startMs)} → - : formatDuration((span.endMs ?? scaleEnd) - span.startMs)} - -
- ); - } else { - const { event } = row; - const leftPct = toPct(event.atMs); - return ( -
onSelectEvent(event)} - > -
- - {event.eventType} + ); + } else { + const { event } = row; + const leftPct = toPct(event.atMs); + return ( +
onSelectEvent(event)} + > +
+ + {event.eventType} +
+
e.stopPropagation()}> + + + {leftPct >= 0 && leftPct <= 100 && ( + + )} +
+ + +{formatDuration(event.atMs - scaleStart)} +
-
- - {leftPct >= 0 && leftPct <= 100 && ( - - )} -
- - +{formatDuration(event.atMs - scaleStart)} - -
- ); - } - })} + ); + } + })} +
-
+ ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sticky-page-header.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sticky-page-header.tsx new file mode 100644 index 000000000..f59c6d32d --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sticky-page-header.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { cn, Typography } from "@/components/ui"; +import { LayoutGroup, motion, useReducedMotion, type Transition } from "motion/react"; +import { useEffect, useRef, useState, type ReactNode, type Ref } from "react"; + +// Shared sticky page header (extracted from the overview page): at rest it is +// a full-width card with the title on the left and the actions on the right; +// once the page scrolls, the title fades/blurs out first and the chrome then +// morphs into a compact floating pill hugging the right edge. + +const STICKY_HEADER_COMPACT_SCROLL_TOP = 24; +const STICKY_HEADER_MORPH_MS = 520; +const STICKY_HEADER_TITLE_EXIT_MS = 150; +const stickyHeaderLayoutTransition: Transition = { + duration: STICKY_HEADER_MORPH_MS / 1000, + ease: [0.32, 0.72, 0, 1], +}; +const reducedStickyHeaderLayoutTransition: Transition = { + duration: 0, +}; + +const scrollableOverflowValues = new Set(["auto", "scroll", "overlay"]); + +function findScrollContainer(element: HTMLElement): HTMLElement | null { + let current = element.parentElement; + while (current != null) { + const overflowY = window.getComputedStyle(current).overflowY; + if (scrollableOverflowValues.has(overflowY) && current.scrollHeight > current.clientHeight) { + return current; + } + current = current.parentElement; + } + + return null; +} + +function useStickyHeaderCompacted(enabled: boolean) { + const sentinelRef = useRef(null); + const [compacted, setCompacted] = useState(false); + + useEffect(() => { + if (!enabled) { + setCompacted(false); + return; + } + + const sentinel = sentinelRef.current; + if (sentinel == null) return; + + const scrollContainer = findScrollContainer(sentinel); + + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + const nextCompacted = !entry.isIntersecting; + setCompacted((current) => current === nextCompacted ? current : nextCompacted); + }, { + root: scrollContainer, + rootMargin: `-${STICKY_HEADER_COMPACT_SCROLL_TOP}px 0px 0px 0px`, + threshold: 0, + }); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, [enabled]); + + return { compacted, sentinelRef }; +} + +function useRenderWhileClosing(open: boolean, durationMs: number): boolean { + const [shouldRender, setShouldRender] = useState(open); + + useEffect(() => { + if (open) { + setShouldRender(true); + return; + } + + const timeout = setTimeout(() => setShouldRender(false), durationMs); + return () => clearTimeout(timeout); + }, [durationMs, open]); + + return open || shouldRender; +} + +function useDelayedTrue(value: boolean, delayMs: number): boolean { + const [delayedValue, setDelayedValue] = useState(value); + + useEffect(() => { + if (!value) { + setDelayedValue(false); + return; + } + + const timeout = setTimeout(() => setDelayedValue(true), delayMs); + return () => clearTimeout(timeout); + }, [delayMs, value]); + + return delayedValue; +} + +function StickyHeaderChrome({ + title, + description, + actions, + compacted, + layoutCompacted, + renderTitle, + layoutTransition, + animateLayout, +}: { + title: string, + description?: ReactNode, + actions: ReactNode, + compacted: boolean, + layoutCompacted: boolean, + renderTitle: boolean, + layoutTransition: Transition, + animateLayout: boolean, +}) { + return ( + + +
+
+ {renderTitle && ( +
+ + {title} + + {description != null && ( + + {description} + + )} +
+ )} + + {actions} + +
+ + ); +} + +export function StickyPageHeader({ title, description, actions, sticky, layoutGroupId, headerRef }: { + title: string, + description?: ReactNode, + actions: ReactNode, + sticky: boolean, + /** Unique per page — namespaces the motion layout animations. */ + layoutGroupId: string, + /** Attached to the sticky wrapper, e.g. to measure the header's live height. */ + headerRef?: Ref, +}) { + const { compacted, sentinelRef } = useStickyHeaderCompacted(sticky); + const renderTitle = useRenderWhileClosing(!compacted, STICKY_HEADER_TITLE_EXIT_MS); + const shouldReduceMotion = useReducedMotion(); + const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : STICKY_HEADER_TITLE_EXIT_MS); + const layoutCompacted = sticky && (shouldReduceMotion ? compacted : delayedCompacted); + const layoutTransition = shouldReduceMotion ? reducedStickyHeaderLayoutTransition : stickyHeaderLayoutTransition; + + return ( + <> + {sticky && ( +
+ )} +
+ + + +
+ + ); +} diff --git a/apps/dashboard/src/components/ui/tooltip.tsx b/apps/dashboard/src/components/ui/tooltip.tsx index 38422d27d..8a155c54c 100644 --- a/apps/dashboard/src/components/ui/tooltip.tsx +++ b/apps/dashboard/src/components/ui/tooltip.tsx @@ -25,15 +25,17 @@ const TooltipContent = forwardRefIfNeeded< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - + + + )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 8c5d5a4b1..745c3710b 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -388,7 +388,7 @@ export const ALL_APPS_FRONTEND = { href: "analytics", navigationItems: [ { displayName: "Tables", href: "./tables" }, - { displayName: "Spans & Events", href: "./spans-events" }, + { displayName: "Traces", href: "./spans-events" }, { displayName: "Replays", href: "../session-replays" }, { displayName: "Clickmaps", href: "./clickmaps" }, { displayName: "Queries", href: "./queries" },