mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
@@ -147,3 +147,4 @@ packages/tanstack-start/*
|
||||
|
||||
# claude code
|
||||
.claude/scheduled_tasks.lock
|
||||
.gstack/
|
||||
|
||||
+2
-211
@@ -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
|
||||
|
||||
+113
-142
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Spans & Events",
|
||||
title: "Traces",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
|
||||
+81
-33
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+305
-150
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+240
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user