feat(dashboard): implement sticky page header component

- Added a new StickyPageHeader component to enhance the user interface by providing a sticky header that morphs during scrolling.
- Integrated the StickyPageHeader into the metrics page and spans-events analytics page for improved navigation.
- Updated the .gitignore to exclude .gstack/ files.
- Refactored existing header logic to utilize the new sticky header functionality, improving code organization and maintainability.
This commit is contained in:
mantrakp04
2026-07-03 14:01:15 -07:00
parent 4de05f4ddd
commit 18c40b03d1
9 changed files with 755 additions and 547 deletions
+1
View File
@@ -147,3 +147,4 @@ packages/tanstack-start/*
# claude code
.claude/scheduled_tasks.lock
.gstack/
@@ -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<HTMLDivElement>(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<string, string>([
["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 (
<motion.div
layout={animateLayout}
transition={layoutTransition}
className={cn(
"pointer-events-auto relative w-full max-w-full",
layoutCompacted && "ml-auto w-fit",
)}
>
<motion.div
layout={animateLayout}
transition={layoutTransition}
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 z-0 rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl will-change-transform transition-[background-color,border-color,box-shadow,opacity] duration-[520ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none",
layoutCompacted && "rounded-xl border-black/[0.08] bg-white/[0.78] shadow-[0_14px_34px_rgba(15,23,42,0.14)] ring-1 ring-white/[0.55] dark:border-white/[0.08] dark:bg-background/[0.72] dark:shadow-[0_14px_34px_rgba(0,0,0,0.26)] dark:ring-white/[0.08] dark:backdrop-blur-xl",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-white/70 to-transparent opacity-0 transition-opacity duration-[520ms] motion-reduce:transition-none dark:via-white/20",
layoutCompacted && "opacity-100",
)}
/>
<div
className={cn(
"relative z-10 flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-4 dark:px-0 dark:py-0 dark:sm:px-0 dark:sm:py-0",
layoutCompacted && "gap-0 sm:gap-0",
layoutCompacted && "px-3 py-2 sm:px-4 sm:py-2.5 dark:px-4 dark:py-2.5 dark:sm:px-4 dark:sm:py-2.5",
)}
>
{renderTitle && (
<div
className={cn(
"min-w-0 transition-[opacity,transform,filter] duration-[150ms] ease-out motion-reduce:transition-none sm:flex-1",
compacted && "pointer-events-none opacity-0 blur-[1px]",
)}
>
<Typography
type="h2"
className="truncate text-xl font-semibold tracking-tight sm:text-2xl"
>
{title}
</Typography>
</div>
)}
<motion.div
layout={animateLayout}
transition={layoutTransition}
className={cn(
"relative z-10 min-w-0 max-w-full flex-shrink-0 overflow-x-auto will-change-transform [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"transition-opacity duration-[520ms] motion-reduce:transition-none",
layoutCompacted && "opacity-95",
)}
>
{actions}
</motion.div>
</div>
</motion.div>
);
}
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 && (
<div key="sentinel" ref={sentinelRef} aria-hidden className="-mb-[17px] h-px w-px" />
)}
<div
key="header"
className={cn(
"relative z-30 w-full pointer-events-none",
sticky && "sticky top-[4.25rem] mb-2 dark:top-[5.75rem]",
)}
>
<LayoutGroup id="overview-sticky-header">
<OverviewHeaderChrome
title={title}
actions={actions}
compacted={sticky ? compacted : false}
layoutCompacted={layoutCompacted}
renderTitle={sticky ? renderTitle : true}
layoutTransition={layoutTransition}
animateLayout
/>
</LayoutGroup>
</div>
</>
);
}
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. */}
<OverviewHeader title={headerTitle} actions={headerActions} sticky={view === "overview"} />
<StickyPageHeader title={headerTitle} actions={headerActions} sticky={view === "overview"} layoutGroupId="overview-sticky-header" />
{view === "overview" && <AnalyticsEventLimitBanner />}
<ErrorBoundary errorComponent={MetricsErrorComponent}>
{/* Inside the error boundary so a failed filtered fetch surfaces the
@@ -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<T extends string | number>({ 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<T extends string | number>({ value, onChange, options }: {
function EmptyState({ title, children }: { title: string, children?: React.ReactNode }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
<div className="flex flex-col items-center justify-center gap-3 p-6 py-16 text-center">
<Typography variant="secondary" className="text-sm font-medium">{title}</Typography>
{children}
</div>
);
}
function HeaderCountStat({ icon, value, label }: { icon: React.ReactNode, value: number, label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-muted-foreground">
{icon}
<span className="font-mono text-[11px] tabular-nums">{value}</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
export default function PageClient() {
const adminApp = useAdminApp();
const [hours, setHours] = useState<number>(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<string | null>(null);
const [selectedRootId, setSelectedRootId] = useState<string | null>(null);
const [detailRow, setDetailRow] = useState<RowData | null>(null);
@@ -236,83 +264,78 @@ export default function PageClient() {
return { trace: selectedTrace, breadcrumb: [] };
}, [selectedTrace, focusedSpanId]);
const eventTypeCounts = useMemo(() => {
const counts = new Map<string, number>();
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<string, unknown>) => {
setDetailRow(raw);
}, []);
return (
<AppEnabledGuard appId="analytics">
<PageLayout
title="Spans & Events"
description="Custom spans and events from the SDK, correlated into traces."
fillWidth
containedHeight
actions={
<div className="flex items-center gap-2">
<Segmented value={hours} onChange={setHours} options={TIME_RANGES.map((range) => ({ label: range.label, value: range.hours }))} />
<Button
className="gap-1.5"
variant="secondary"
disabled={loading}
onClick={() => runAsynchronouslyWithAlert(loadData)}
>
<ArrowClockwiseIcon className="h-4 w-4" />
Refresh
</Button>
<PageLayout fillWidth>
{/* StickyPageHeader's sentinel uses -mb-[17px]; compensate so this dense page keeps matching top/side gutters. */}
<div
className="flex flex-col pt-[17px] [--header-sticky-top:4.25rem] dark:[--header-sticky-top:5.75rem]"
>
<StickyPageHeader
title="Traces"
sticky
layoutGroupId="traces-sticky-header"
actions={
<TooltipProvider>
<div className="flex flex-wrap items-center gap-2">
<Segmented
value={scope}
onChange={(value) => setScope(value)}
options={[{ label: "All", value: "all" as const }, { label: "Custom", value: "custom" as const }]}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by span or event type…"
className="h-8 w-56 text-xs"
/>
<div className="flex items-center gap-1 whitespace-nowrap text-xs">
<HeaderCountStat icon={<TreeStructureIcon className="h-3.5 w-3.5" />} value={filteredTraces.length} label={`${filteredTraces.length.toLocaleString()} ${filteredTraces.length === 1 ? "trace" : "traces"}`} />
<HeaderCountStat icon={<StackIcon className="h-3.5 w-3.5" />} value={scopedSpans.length} label={`${scopedSpans.length.toLocaleString()} ${scopedSpans.length === 1 ? "span" : "spans"}`} />
<HeaderCountStat icon={<ChartLineIcon className="h-3.5 w-3.5" />} value={scopedEvents.length} label={`${scopedEvents.length.toLocaleString()} ${scopedEvents.length === 1 ? "event" : "events"}`} />
</div>
<Segmented value={hours} onChange={setHours} options={TIME_RANGES.map((range) => ({ label: range.label, value: range.hours }))} />
<Button
className="h-8 gap-1.5 px-3 text-xs"
variant="secondary"
disabled={loading}
onClick={() => runAsynchronouslyWithAlert(loadData)}
>
<ArrowClockwiseIcon className="h-4 w-4" />
Refresh
</Button>
</div>
</TooltipProvider>
}
/>
<div className="mt-2 empty:hidden">
<AnalyticsEventLimitBanner />
</div>
}
>
<AnalyticsEventLimitBanner />
{/* Tab + filter bar */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Segmented
value={tab}
onChange={(value) => setTab(value)}
options={[{ label: "Traces", value: "traces" as const }, { label: "Events", value: "events" as const }]}
/>
<Segmented
value={scope}
onChange={(value) => setScope(value)}
options={[{ label: "Custom", value: "custom" as const }, { label: "All (incl. system)", value: "all" as const }]}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={tab === "traces" ? "Filter by span or event type…" : "Filter by event type…"}
className="h-8 w-56 text-xs"
/>
<span className="text-xs text-muted-foreground ml-auto">
{filteredTraces.length} {filteredTraces.length === 1 ? "trace" : "traces"} · {scopedSpans.length} {scopedSpans.length === 1 ? "span" : "spans"} · {scopedEvents.length} {scopedEvents.length === 1 ? "event" : "events"}
</span>
</div>
{tab === "traces" ? (
<div className="flex-1 min-h-0 flex gap-4">
{/* Trace list */}
<div className={cn(CARD_CLASSES, "w-80 shrink-0 flex flex-col min-h-0 overflow-hidden")}>
<div className="px-3 py-2 border-b border-border/50 shrink-0">
<div className="mt-2 flex flex-col gap-4 lg:flex-row lg:items-start">
{/* 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. */}
<div
className={cn(
CARD_CLASSES,
"flex w-full flex-col overflow-hidden max-h-[45dvh]",
"lg:sticky lg:top-[var(--header-sticky-top)] lg:w-80 lg:shrink-0",
"lg:max-h-[calc(100dvh-var(--header-sticky-top)-0.75rem)]",
)}
>
<div className="px-4 py-3 border-b border-border/50 shrink-0">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Traces</span>
</div>
<div className="flex-1 min-h-0 overflow-auto">
<div className="min-h-0 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center justify-center py-16">
<SpinnerGapIcon className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
@@ -343,15 +366,18 @@ export default function PageClient() {
</div>
</div>
{/* Waterfall */}
<div className={cn(CARD_CLASSES, "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden")}>
{/* Waterfall: grows with its rows so the page scrolls; deep rows
slide up behind the floating header pill, like the overview. */}
<div className={cn(CARD_CLASSES, "flex-1 min-w-0 flex flex-col min-h-[420px] overflow-hidden")}>
{loading && (
<div className="flex items-center justify-center h-full">
<div className="flex flex-1 items-center justify-center py-24">
<SpinnerGapIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error == null && displayedTrace == null && (
<EmptyState title="Select a trace to see its waterfall." />
<div className="flex flex-1 items-center justify-center">
<EmptyState title="Select a trace to see its waterfall." />
</div>
)}
{!loading && error == null && displayedTrace != null && (
<TraceWaterfall
@@ -364,68 +390,13 @@ export default function PageClient() {
/>
)}
{!loading && error != null && (
<div className="flex items-center justify-center h-full">
<div className="flex flex-1 items-center justify-center py-24">
<ErrorDisplay error={error} onRetry={loadData} />
</div>
)}
</div>
</div>
) : (
<div className={cn(CARD_CLASSES, "flex-1 min-h-0 flex flex-col overflow-hidden")}>
{/* Event type breakdown chips */}
{eventTypeCounts.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border/50 shrink-0">
{eventTypeCounts.slice(0, 12).map(([eventType, count]) => (
<button
key={eventType}
onClick={() => setEventTypeFilter(eventTypeFilter === eventType ? null : eventType)}
className={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-[11px] font-mono transition-colors hover:transition-none",
eventTypeFilter === eventType
? "border-foreground/30 bg-foreground/[0.08] text-foreground"
: "border-border/60 text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
>
<span>{eventType}</span>
<span className="font-semibold">{count}</span>
</button>
))}
{eventTypeFilter != null && (
<button
onClick={() => setEventTypeFilter(null)}
className="px-2 py-0.5 rounded-full text-[11px] text-muted-foreground hover:text-foreground"
>
Clear
</button>
)}
</div>
)}
{loading && (
<div className="flex items-center justify-center h-full">
<SpinnerGapIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error != null && (
<div className="flex items-center justify-center h-full">
<ErrorDisplay error={error} onRetry={loadData} />
</div>
)}
{!loading && error == null && tableEvents.length === 0 && (
<EmptyState title="No events in this time range.">
<pre className="text-left font-mono text-[11px] text-muted-foreground bg-muted/30 rounded-lg p-3 overflow-auto max-w-full">
{`await app.trackEvent("signup_completed",\n { plan: "pro" });`}
</pre>
</EmptyState>
)}
{!loading && error == null && tableEvents.length > 0 && (
<VirtualizedFlatTable
columns={EVENT_TABLE_COLUMNS}
rows={tableEvents.map((event) => event.raw)}
onRowClick={(row) => openDetail(row)}
/>
)}
</div>
)}
</div>
<RowDetailDialog
row={detailRow}
@@ -1,7 +1,7 @@
import PageClient from "./page-client";
export const metadata = {
title: "Spans & Events",
title: "Traces",
};
export default function Page() {
@@ -1,11 +1,10 @@
"use client";
import { Badge } from "@/components/ui";
import { Avatar, AvatarFallback, AvatarImage, 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;
@@ -34,6 +33,51 @@ function DurationLabel({ startMs, endMs, nowMs }: { startMs: number, endMs: numb
return <span className="font-mono text-[11px] text-muted-foreground">{formatDuration(endMs - startMs)}</span>;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function initialsAvatarDataUrl(initials: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" rx="20" fill="#e5e7eb"/><text x="20" y="24" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="700" fill="#6b7280">${escapeSvgText(initials)}</text></svg>`;
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 (
<Avatar className="h-5 w-5 border border-border/60 bg-muted text-muted-foreground">
<AvatarImage src={avatarSrc} alt={label ?? "User profile picture"} />
<AvatarFallback className="bg-muted" title={label ?? "User profile"} />
</Avatar>
);
}
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<string, boolean>,
onToggle: (spanId: string) => void,
expandedOverrides: Map<string, boolean>,
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 (
<>
<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",
"w-full grid items-center gap-x-1.5 pr-3 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none cursor-pointer min-w-0",
isRoot ? "py-2" : "py-1.5",
isActive && "bg-muted/50 hover:bg-muted/50",
)}
style={{ paddingLeft: `${8 + node.depth * 12}px` }}
style={{ gridTemplateColumns: rowGridTemplateColumns }}
onClick={() => onSelectSpan(trace.root.span.id, node.span.id)}
>
<span aria-hidden />
{hasChildren ? (
<button
className="p-0.5 shrink-0 text-muted-foreground hover:text-foreground"
className="flex h-4 w-4 items-center justify-center rounded text-muted-foreground hover:text-foreground"
title={expanded ? "Collapse" : "Expand"}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
onToggle(node.span.id);
onToggle(node, e.altKey);
}}
>
<CaretRightIcon className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")} />
</button>
) : (
<span className="w-4 shrink-0" />
<span className="h-4 w-4" />
)}
<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">
{isRoot ? (
<TraceAvatar imageUrl={userProfileImageUrl} label={userProfile} />
) : (
<span className="h-5 w-5" />
)}
<div className="min-w-0">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<span
className={cn(
"font-mono text-xs truncate",
@@ -99,7 +153,7 @@ function TreeNode({
>
{node.span.spanType}
</span>
<span className="ml-auto shrink-0">
<span className="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. */}
@@ -111,9 +165,9 @@ function TreeNode({
</span>
</div>
{isRoot && (
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
<div className="mt-0.5 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 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>
<span className="shrink-0">{trace.spanCount}s · {trace.eventCount}e</span>
</div>
)}
</div>
@@ -155,15 +209,20 @@ export function SpanTreeList({
activeSpanId: string | null,
onSelectSpan: (rootId: string, spanId: string) => void,
}) {
const [expandedOverrides, setExpandedOverrides] = useState<Record<string, boolean>>({});
const [expandedOverrides, setExpandedOverrides] = useState<Map<string, boolean>>(() => 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({
</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,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<string>): 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 (
<span
className="pointer-events-none absolute inset-y-0 rounded-sm border border-blue-500/70 bg-blue-500/15"
style={{ left: `${left}%`, width: `${width}%` }}
/>
);
}
function TraceHeaderStat({ icon, value, label }: { icon: React.ReactNode, value: number | string, label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-muted-foreground">
{icon}
<span className="font-mono text-[11px]">{value}</span>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
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<Set<string>>(() => new Set());
const rows = useMemo(() => flattenVisibleTrace(trace, collapsedSpanIds), [trace, collapsedSpanIds]);
const [view, setView] = useState<ViewWindow>(FULL_VIEW);
const [dragSelection, setDragSelection] = useState<DragSelection | null>(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<HTMLDivElement>) => {
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 (
<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) => (
<span key={crumb.spanId} className="flex items-center gap-1.5 min-w-0 shrink">
<button
className="font-mono text-xs text-muted-foreground hover:text-foreground truncate transition-colors hover:transition-none"
onClick={() => onFocusSpan(crumb.spanId)}
title={`Focus ${crumb.spanType}`}
>
{crumb.spanType}
</button>
<CaretRightIcon className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</span>
))}
<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>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{trace.spanCount} {trace.spanCount === 1 ? "span" : "spans"} · {trace.eventCount} {trace.eventCount === 1 ? "event" : "events"} · started {new Date(trace.startMs).toLocaleString()}
</span>
</div>
<TooltipProvider>
<div ref={containerRef} className="flex flex-col flex-1">
<div>
{/* Trace header */}
<div className="flex items-center gap-1.5 px-4 py-3 border-b border-border/50 min-w-0">
{breadcrumb.map((crumb) => (
<span key={crumb.spanId} className="flex items-center gap-1.5 min-w-0 shrink">
<button
className="font-mono text-xs text-muted-foreground hover:text-foreground truncate transition-colors hover:transition-none"
onClick={() => onFocusSpan(crumb.spanId)}
title={`Focus ${crumb.spanType}`}
>
{crumb.spanType}
</button>
<CaretRightIcon className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</span>
))}
<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>
)}
<div className="ml-auto flex shrink-0 items-center gap-1 text-xs">
<TraceHeaderStat icon={<StackIcon className="h-3.5 w-3.5" />} value={trace.spanCount} label={`${trace.spanCount} ${trace.spanCount === 1 ? "span" : "spans"}`} />
<TraceHeaderStat icon={<ChartLineIcon className="h-3.5 w-3.5" />} value={trace.eventCount} label={`${trace.eventCount} ${trace.eventCount === 1 ? "event" : "events"}`} />
<TraceHeaderStat icon={<ClockIcon className="h-3.5 w-3.5" />} value={new Date(trace.startMs).toLocaleTimeString()} label={`Started ${new Date(trace.startMs).toLocaleString()}`} />
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center rounded p-1 text-muted-foreground">
<KeyboardIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="font-mono leading-relaxed">
<div>Drag timeline: zoom to range</div>
<div>Cmd/Ctrl + scroll: zoom</div>
<div>Horizontal scroll: pan</div>
<div>Option/Alt + caret: collapse subtree</div>
</TooltipContent>
</Tooltip>
</div>
</div>
{/* 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
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(
{/* 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
ref={trackRef}
className="relative h-4 cursor-ew-resize"
onPointerDown={startTimelineDrag}
onClick={(e) => e.stopPropagation()}
>
<DragSelectionOverlay selection={dragSelection} />
{[0, 25, 50, 75, 100].map((pct) => (
<span
key={pct}
className={cn(
"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((viewStartMs - scaleStart) + (viewSpanMs * pct) / 100)}
style={{ left: `${pct}%` }}
>
{formatDuration((viewStartMs - scaleStart) + (viewSpanMs * pct) / 100)}
</span>
))}
</div>
<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>
</div>
<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 */}
<div className="flex-1 min-h-0 overflow-auto">
{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 (
<div
key={`span-${span.id}-${index}`}
className="group w-full grid gap-3 px-4 items-center h-8 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
style={{ gridTemplateColumns }}
onClick={() => onSelectSpan(span)}
>
<div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: `${row.node.depth * 14}px` }}>
<span className={cn("h-2 w-2 rounded-[3px] shrink-0", spanColorClass(span.spanType))} />
<span className={cn("font-mono text-[11px] truncate", isSystemSpanType(span.spanType) ? "text-muted-foreground" : "font-medium")}>
{span.spanType}
</span>
{!isViewRoot && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.08] transition-opacity"
title="Focus this span"
onClick={(e) => {
{/* Rows */}
<div>
{rows.map((row, index) => {
if (row.kind === "span") {
const { span } = row.node;
const hasChildren = row.node.children.length > 0 || row.node.events.length > 0;
const collapsed = collapsedSpanIds.has(span.id);
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 (
<div
key={`span-${span.id}-${index}`}
className="group w-full grid gap-3 px-4 items-center h-8 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
style={{ gridTemplateColumns }}
onClick={() => onSelectSpan(span)}
>
<div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: `${row.node.depth * 14}px` }}>
{hasChildren ? (
<button
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-foreground/[0.08] hover:text-foreground"
title={collapsed ? "Expand" : "Collapse"}
aria-expanded={!collapsed}
onClick={(e) => {
e.stopPropagation();
toggleCollapsed(row.node, e.altKey);
}}
>
<CaretRightIcon className={cn("h-3 w-3 transition-transform", !collapsed && "rotate-90")} />
</button>
) : (
<span className="w-4 shrink-0" />
)}
<span className={cn("h-2 w-2 rounded-[3px] shrink-0", spanColorClass(span.spanType))} />
<span className={cn("font-mono text-[11px] truncate", isSystemSpanType(span.spanType) ? "text-muted-foreground" : "font-medium")}>
{span.spanType}
</span>
{!isViewRoot && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.08] transition-opacity"
title="Focus this span"
onClick={(e) => {
e.stopPropagation();
onFocusSpan(span.id);
}}
>
<CornersOutIcon className="h-3 w-3" />
</button>
)}
</div>
<div className="relative h-4 cursor-ew-resize">
<TimelineGridlines />
{barVisible && (
<span
className={cn(
}}
>
<CornersOutIcon className="h-3 w-3" />
</button>
)}
</div>
<div className="relative h-4 cursor-ew-resize" onPointerDown={startTimelineDrag} onClick={(e) => e.stopPropagation()}>
<TimelineGridlines />
<DragSelectionOverlay selection={dragSelection} />
{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}
/>
)}
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"
: runsIntoFuture
? <span title={`Ends ${new Date(span.endMs ?? 0).toLocaleString()}`}>{formatDuration(nowMs - span.startMs)} </span>
: formatDuration((span.endMs ?? scaleEnd) - span.startMs)}
</span>
</div>
<span className="font-mono text-[11px] text-muted-foreground text-right">
{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>
);
} else {
const { event } = row;
const leftPct = toPct(event.atMs);
return (
<div
key={`event-${index}`}
className="w-full grid gap-3 px-4 items-center h-7 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
style={{ gridTemplateColumns }}
onClick={() => onSelectEvent(event)}
>
<div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: `${row.depth * 14}px` }}>
<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>
);
} else {
const { event } = row;
const leftPct = toPct(event.atMs);
return (
<div
key={`event-${index}`}
className="w-full grid gap-3 px-4 items-center h-7 border-b border-border/20 hover:bg-muted/30 transition-colors hover:transition-none text-left cursor-pointer"
style={{ gridTemplateColumns }}
onClick={() => onSelectEvent(event)}
>
<div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: `${row.depth * 14}px` }}>
<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 cursor-ew-resize" onPointerDown={startTimelineDrag} onClick={(e) => e.stopPropagation()}>
<TimelineGridlines />
<DragSelectionOverlay selection={dragSelection} />
{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)}
</span>
</div>
<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)}
</span>
</div>
);
}
})}
);
}
})}
</div>
</div>
</div>
</TooltipProvider>
);
}
@@ -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<HTMLDivElement>(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 (
<motion.div
layout={animateLayout}
transition={layoutTransition}
className={cn(
"pointer-events-auto relative w-full max-w-full",
layoutCompacted && "ml-auto w-fit",
)}
>
<motion.div
layout={animateLayout}
transition={layoutTransition}
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 z-0 rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl will-change-transform transition-[background-color,border-color,box-shadow,opacity] duration-[520ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none",
layoutCompacted && "rounded-xl border-black/[0.08] bg-white/[0.78] shadow-[0_14px_34px_rgba(15,23,42,0.14)] ring-1 ring-white/[0.55] dark:border-white/[0.08] dark:bg-background/[0.72] dark:shadow-[0_14px_34px_rgba(0,0,0,0.26)] dark:ring-white/[0.08] dark:backdrop-blur-xl",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-white/70 to-transparent opacity-0 transition-opacity duration-[520ms] motion-reduce:transition-none dark:via-white/20",
layoutCompacted && "opacity-100",
)}
/>
<div
className={cn(
"relative z-10 flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-4 dark:px-0 dark:py-0 dark:sm:px-0 dark:sm:py-0",
layoutCompacted && "gap-0 sm:gap-0",
layoutCompacted && "px-3 py-2 sm:px-4 sm:py-2.5 dark:px-4 dark:py-2.5 dark:sm:px-4 dark:sm:py-2.5",
)}
>
{renderTitle && (
<div
className={cn(
// The min-width keeps the title readable when the actions are
// wide — the actions container shrinks (scrolling or wrapping
// internally) instead of crushing the title to zero width.
"min-w-0 sm:min-w-[8rem] transition-[opacity,transform,filter] duration-[150ms] ease-out motion-reduce:transition-none sm:flex-1",
compacted && "pointer-events-none opacity-0 blur-[1px]",
)}
>
<Typography
type="h2"
className="truncate text-xl font-semibold tracking-tight sm:text-2xl"
>
{title}
</Typography>
{description != null && (
<Typography type="p" variant="secondary" className="mt-0.5 truncate text-sm">
{description}
</Typography>
)}
</div>
)}
<motion.div
layout={animateLayout}
transition={layoutTransition}
className={cn(
"relative z-10 min-w-0 max-w-full overflow-x-auto will-change-transform [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"transition-opacity duration-[520ms] motion-reduce:transition-none",
layoutCompacted && "opacity-95",
)}
>
{actions}
</motion.div>
</div>
</motion.div>
);
}
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<HTMLDivElement>,
}) {
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 && (
<div key="sentinel" ref={sentinelRef} aria-hidden className="-mb-[17px] h-px w-px" />
)}
<div
key="header"
ref={headerRef}
className={cn(
"relative z-30 w-full pointer-events-none",
sticky && "sticky top-[4.25rem] mb-2 dark:top-[5.75rem]",
)}
>
<LayoutGroup id={layoutGroupId}>
<StickyHeaderChrome
title={title}
description={description}
actions={actions}
compacted={sticky ? compacted : false}
layoutCompacted={layoutCompacted}
renderTitle={sticky ? renderTitle : true}
layoutTransition={layoutTransition}
animateLayout
/>
</LayoutGroup>
</div>
</>
);
}
+11 -9
View File
@@ -25,15 +25,17 @@ const TooltipContent = forwardRefIfNeeded<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"stack-scope z-50 overflow-hidden rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"stack-scope z-50 overflow-hidden rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+1 -1
View File
@@ -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" },