wip
This commit is contained in:
42
apps/start/src/components/sessions/replay/browser-chrome.tsx
Normal file
42
apps/start/src/components/sessions/replay/browser-chrome.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function BrowserChrome({
|
||||
url,
|
||||
children,
|
||||
right,
|
||||
controls = (
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
),
|
||||
className,
|
||||
}: {
|
||||
url?: ReactNode;
|
||||
children: ReactNode;
|
||||
right?: ReactNode;
|
||||
controls?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-lg border border-border bg-background',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background h-10">
|
||||
{controls}
|
||||
{url !== false && (
|
||||
<div className="flex-1 mx-4 px-3 h-8 py-1 text-sm bg-def-100 rounded-md border border-border flex items-center truncate">
|
||||
{url}
|
||||
</div>
|
||||
)}
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/start/src/components/sessions/replay/index.tsx
Normal file
139
apps/start/src/components/sessions/replay/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ReplayProvider,
|
||||
useReplayContext,
|
||||
} from '@/components/sessions/replay/replay-context';
|
||||
import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed';
|
||||
import { ReplayPlayer } from '@/components/sessions/replay/replay-player';
|
||||
import { ReplayTimeline } from '@/components/sessions/replay/replay-timeline';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { BrowserChrome } from './browser-chrome';
|
||||
import { ReplayTime } from './replay-controls';
|
||||
|
||||
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
|
||||
const t =
|
||||
typeof event.createdAt === 'object' && event.createdAt instanceof Date
|
||||
? event.createdAt.getTime()
|
||||
: new Date(event.createdAt).getTime();
|
||||
return t - startTime;
|
||||
}
|
||||
|
||||
function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
|
||||
const { currentTime, startTime } = useReplayContext();
|
||||
|
||||
const currentUrl = useMemo(() => {
|
||||
if (startTime == null || !events.length) return '';
|
||||
|
||||
const withOffset = events
|
||||
.map((ev) => ({
|
||||
event: ev,
|
||||
offsetMs: getEventOffsetMs(ev, startTime),
|
||||
}))
|
||||
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
|
||||
.sort((a, b) => a.offsetMs - b.offsetMs);
|
||||
|
||||
const latest = withOffset[withOffset.length - 1];
|
||||
if (!latest) return '';
|
||||
|
||||
const { origin = '', path = '/' } = latest.event;
|
||||
const pathPart = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${origin}${pathPart}`;
|
||||
}, [events, currentTime, startTime]);
|
||||
|
||||
return <span className="text-muted-foreground truncate">{currentUrl}</span>;
|
||||
}
|
||||
|
||||
function ReplayContent({
|
||||
sessionId,
|
||||
projectId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const {
|
||||
data: replayData,
|
||||
isLoading: replayLoading,
|
||||
isError: replayError,
|
||||
} = useQuery(trpc.session.replay.queryOptions({ sessionId, projectId }));
|
||||
const { data: eventsData } = useQuery(
|
||||
trpc.event.events.queryOptions({
|
||||
projectId,
|
||||
sessionId,
|
||||
filters: [],
|
||||
columnVisibility: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = eventsData?.data ?? [];
|
||||
const replayEvents = replayData?.events as
|
||||
| Array<{ type: number; data: unknown; timestamp: number }>
|
||||
| undefined;
|
||||
|
||||
if (replayLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
|
||||
<div className="flex min-w-0 flex-col overflow-hidden">
|
||||
<BrowserChrome>
|
||||
<div className="flex h-[320px] items-center justify-center bg-black">
|
||||
<div className="h-8 w-8 animate-pulse rounded-full bg-muted-foreground/20" />
|
||||
</div>
|
||||
</BrowserChrome>
|
||||
</div>
|
||||
<div className="hidden lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (replayError || !replayEvents?.length) {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
|
||||
<div className="flex min-w-0 flex-col overflow-hidden">
|
||||
<BrowserChrome
|
||||
url={<span className="text-muted-foreground">about:blank</span>}
|
||||
>
|
||||
<div className="flex h-[320px] items-center justify-center bg-black text-muted-foreground">
|
||||
No replay data available for this session.
|
||||
</div>
|
||||
</BrowserChrome>
|
||||
</div>
|
||||
<div className="hidden lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReplayProvider>
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
|
||||
<div className="flex min-w-0 flex-col overflow-hidden">
|
||||
<BrowserChrome
|
||||
url={<BrowserUrlBar events={events} />}
|
||||
right={<ReplayTime />}
|
||||
>
|
||||
<ReplayPlayer events={replayEvents} />
|
||||
<ReplayTimeline events={events} />
|
||||
</BrowserChrome>
|
||||
</div>
|
||||
<div className="hidden lg:block relative">
|
||||
<div className="absolute inset-0">
|
||||
<ReplayEventFeed events={events} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReplayProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayShell({
|
||||
sessionId,
|
||||
projectId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return <ReplayContent sessionId={sessionId} projectId={projectId} />;
|
||||
}
|
||||
210
apps/start/src/components/sessions/replay/replay-context.tsx
Normal file
210
apps/start/src/components/sessions/replay/replay-context.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface ReplayPlayerInstance {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
goto: (timeOffset: number, play?: boolean) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
getMetaData: () => { startTime: number; endTime: number; totalTime: number };
|
||||
getReplayer: () => { getCurrentTime: () => number };
|
||||
$destroy?: () => void;
|
||||
}
|
||||
|
||||
interface ReplayContextValue {
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
duration: number;
|
||||
startTime: number | null;
|
||||
isReady: boolean;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
seek: (timeOffset: number, play?: boolean) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
registerPlayer: (player: ReplayPlayerInstance) => void;
|
||||
unregisterPlayer: () => void;
|
||||
}
|
||||
|
||||
const ReplayContext = createContext<ReplayContextValue | null>(null);
|
||||
|
||||
const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const;
|
||||
|
||||
export function useReplayContext() {
|
||||
const ctx = useContext(ReplayContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useReplayContext must be used within ReplayProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ReplayProvider({ children }: { children: ReactNode }) {
|
||||
const playerRef = useRef<ReplayPlayerInstance | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [speed, setSpeedState] = useState(1);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastUpdateRef = useRef(0);
|
||||
// Refs so stable callbacks can read latest values
|
||||
const isPlayingRef = useRef(false);
|
||||
const durationRef = useRef(0);
|
||||
const currentTimeRef = useRef(0);
|
||||
|
||||
const registerPlayer = useCallback((player: ReplayPlayerInstance) => {
|
||||
playerRef.current = player;
|
||||
try {
|
||||
const meta = player.getMetaData();
|
||||
durationRef.current = meta.totalTime;
|
||||
setDuration(meta.totalTime);
|
||||
setStartTime(meta.startTime);
|
||||
setCurrentTime(0);
|
||||
currentTimeRef.current = 0;
|
||||
setIsReady(true);
|
||||
} catch {
|
||||
setIsReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unregisterPlayer = useCallback(() => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
playerRef.current = null;
|
||||
setIsReady(false);
|
||||
setCurrentTime(0);
|
||||
currentTimeRef.current = 0;
|
||||
setDuration(0);
|
||||
durationRef.current = 0;
|
||||
setStartTime(null);
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const play = useCallback(() => {
|
||||
playerRef.current?.play();
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
playerRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// If at the end, reset to start and play
|
||||
const atEnd = currentTimeRef.current >= durationRef.current - 100;
|
||||
if (atEnd && !isPlayingRef.current) {
|
||||
player.goto(0, true);
|
||||
setCurrentTime(0);
|
||||
currentTimeRef.current = 0;
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
player.toggle();
|
||||
const next = !isPlayingRef.current;
|
||||
setIsPlaying(next);
|
||||
isPlayingRef.current = next;
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((timeOffset: number, play?: boolean) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
const shouldPlay = play ?? isPlayingRef.current;
|
||||
player.goto(timeOffset, shouldPlay);
|
||||
setCurrentTime(timeOffset);
|
||||
currentTimeRef.current = timeOffset;
|
||||
setIsPlaying(shouldPlay);
|
||||
isPlayingRef.current = shouldPlay;
|
||||
}, []);
|
||||
|
||||
const setSpeed = useCallback((s: number) => {
|
||||
if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return;
|
||||
playerRef.current?.setSpeed(s);
|
||||
setSpeedState(s);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !playerRef.current) return;
|
||||
|
||||
const tick = () => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
try {
|
||||
const replayer = player.getReplayer();
|
||||
const now = replayer.getCurrentTime();
|
||||
// Throttle state updates to ~10fps (every 100ms) to avoid excessive re-renders
|
||||
const t = Math.floor(now / 100);
|
||||
if (t !== lastUpdateRef.current) {
|
||||
lastUpdateRef.current = t;
|
||||
setCurrentTime(now);
|
||||
currentTimeRef.current = now;
|
||||
}
|
||||
|
||||
// Detect end of replay
|
||||
if (
|
||||
now >= durationRef.current - 50 &&
|
||||
durationRef.current > 0 &&
|
||||
isPlayingRef.current
|
||||
) {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
}
|
||||
} catch {
|
||||
// Player may be destroyed
|
||||
}
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
const value: ReplayContextValue = {
|
||||
currentTime,
|
||||
isPlaying,
|
||||
speed,
|
||||
duration,
|
||||
startTime,
|
||||
isReady,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seek,
|
||||
setSpeed,
|
||||
registerPlayer,
|
||||
unregisterPlayer,
|
||||
};
|
||||
|
||||
return (
|
||||
<ReplayContext.Provider value={value}>{children}</ReplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { SPEED_OPTIONS };
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SPEED_OPTIONS,
|
||||
useReplayContext,
|
||||
} from '@/components/sessions/replay/replay-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChevronDown, Pause, Play, SkipBack, SkipForward } from 'lucide-react';
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ReplayTime() {
|
||||
const { currentTime, duration } = useReplayContext();
|
||||
|
||||
return (
|
||||
<span className="text-sm tabular-nums text-muted-foreground font-mono">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayPlayPauseButton() {
|
||||
const { isPlaying, isReady, toggle, seek } = useReplayContext();
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={isPlaying ? 'outline' : 'default'}
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// {/* <DropdownMenu>
|
||||
// <DropdownMenuTrigger asChild>
|
||||
// <Button variant="outline" size="sm" className="h-8 gap-1">
|
||||
// {speed}x
|
||||
// <ChevronDown className="h-3.5 w-3.5" />
|
||||
// </Button>
|
||||
// </DropdownMenuTrigger>
|
||||
// <DropdownMenuContent align="end">
|
||||
// {SPEED_OPTIONS.map((s) => (
|
||||
// <DropdownMenuItem
|
||||
// key={s}
|
||||
// onClick={() => setSpeed(s)}
|
||||
// className={speed === s ? 'bg-accent' : ''}
|
||||
// >
|
||||
// {s}x
|
||||
// </DropdownMenuItem>
|
||||
// ))}
|
||||
// </DropdownMenuContent>
|
||||
// </DropdownMenu> */}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { BrowserChrome } from './browser-chrome';
|
||||
|
||||
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
|
||||
const t =
|
||||
typeof event.createdAt === 'object' && event.createdAt instanceof Date
|
||||
? event.createdAt.getTime()
|
||||
: new Date(event.createdAt).getTime();
|
||||
return t - startTime;
|
||||
}
|
||||
|
||||
export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
|
||||
const { currentTime, startTime, isReady, seek } = useReplayContext();
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevCountRef = useRef(0);
|
||||
|
||||
const { visibleEvents, currentEventId } = useMemo(() => {
|
||||
if (startTime == null || !isReady) {
|
||||
return { visibleEvents: [], currentEventId: null as string | null };
|
||||
}
|
||||
const withOffset = events
|
||||
.map((ev) => ({
|
||||
event: ev,
|
||||
offsetMs: getEventOffsetMs(ev, startTime),
|
||||
}))
|
||||
// Include events up to 10s before recording started (e.g. screen views)
|
||||
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
|
||||
.sort((a, b) => a.offsetMs - b.offsetMs);
|
||||
|
||||
const visibleEvents = withOffset.map(({ event, offsetMs }) => ({
|
||||
event,
|
||||
offsetMs,
|
||||
}));
|
||||
|
||||
const current =
|
||||
visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
|
||||
const currentEventId = current?.event.id ?? null;
|
||||
|
||||
return { visibleEvents, currentEventId };
|
||||
}, [events, startTime, isReady, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || visibleEvents.length === 0) return;
|
||||
|
||||
const isNewItem = visibleEvents.length > prevCountRef.current;
|
||||
prevCountRef.current = visibleEvents.length;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: isNewItem ? 'smooth' : 'instant',
|
||||
});
|
||||
});
|
||||
}, [visibleEvents.length]);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<BrowserChrome
|
||||
url={false}
|
||||
controls={<span className="text-lg font-medium">Timeline</span>}
|
||||
className="h-full"
|
||||
>
|
||||
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
|
||||
<div className="flex flex-col">
|
||||
{visibleEvents.map(({ event, offsetMs }) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-3 duration-300 fill-mode-both"
|
||||
>
|
||||
<ReplayEventItem
|
||||
event={event}
|
||||
offsetMs={offsetMs}
|
||||
isCurrent={event.id === currentEventId}
|
||||
onClick={() => seek(Math.max(0, offsetMs))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{visibleEvents.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Events will appear as the replay plays.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</BrowserChrome>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
function formatOffset(ms: number): string {
|
||||
const sign = ms < 0 ? '-' : '+';
|
||||
const abs = Math.abs(ms);
|
||||
const totalSeconds = Math.floor(abs / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${sign}${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ReplayEventItem({
|
||||
event,
|
||||
offsetMs,
|
||||
isCurrent,
|
||||
onClick,
|
||||
}: {
|
||||
event: IServiceEvent;
|
||||
offsetMs: number;
|
||||
isCurrent: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const displayName =
|
||||
event.name === 'screen_view' && event.path
|
||||
? event.path
|
||||
: event.name.replace(/_/g, ' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent bg-card',
|
||||
isCurrent ? 'bg-accent/10' : 'bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="row items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
<EventIcon name={event.name} meta={event.meta} size="sm" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{displayName}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatOffset(offsetMs)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
99
apps/start/src/components/sessions/replay/replay-player.tsx
Normal file
99
apps/start/src/components/sessions/replay/replay-player.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import 'rrweb-player/dist/style.css';
|
||||
|
||||
/** rrweb meta event (type 4) carries the recorded viewport size */
|
||||
function getRecordedDimensions(
|
||||
events: Array<{ type: number; data: unknown }>,
|
||||
): { width: number; height: number } | null {
|
||||
const meta = events.find((e) => e.type === 4);
|
||||
if (
|
||||
meta &&
|
||||
typeof meta.data === 'object' &&
|
||||
meta.data !== null &&
|
||||
'width' in meta.data &&
|
||||
'height' in meta.data
|
||||
) {
|
||||
const { width, height } = meta.data as { width: number; height: number };
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ReplayPlayer({
|
||||
events,
|
||||
}: {
|
||||
events: Array<{ type: number; data: unknown; timestamp: number }>;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<ReplayPlayerInstance | null>(null);
|
||||
const { registerPlayer, unregisterPlayer } = useReplayContext();
|
||||
|
||||
const recordedDimensions = useMemo(
|
||||
() => getRecordedDimensions(events),
|
||||
[events],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!events.length || !containerRef.current) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
import('rrweb-player').then((module) => {
|
||||
const PlayerConstructor = module.default;
|
||||
if (!containerRef.current || !mounted) return;
|
||||
containerRef.current.innerHTML = '';
|
||||
|
||||
const maxHeight = window.innerHeight * 0.7;
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const aspectRatio = recordedDimensions
|
||||
? recordedDimensions.width / recordedDimensions.height
|
||||
: 16 / 9;
|
||||
const height = Math.min(
|
||||
Math.round(containerWidth / aspectRatio),
|
||||
maxHeight,
|
||||
);
|
||||
const width = Math.min(
|
||||
containerWidth,
|
||||
Math.round(height * aspectRatio),
|
||||
);
|
||||
|
||||
const player = new PlayerConstructor({
|
||||
target: containerRef.current,
|
||||
props: {
|
||||
events,
|
||||
width,
|
||||
height,
|
||||
autoPlay: false,
|
||||
showController: false,
|
||||
speedOption: [0.5, 1, 2, 4, 8],
|
||||
},
|
||||
}) as ReplayPlayerInstance;
|
||||
playerRef.current = player;
|
||||
registerPlayer(player);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unregisterPlayer();
|
||||
if (playerRef.current?.$destroy) {
|
||||
playerRef.current.$destroy();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [events, registerPlayer, unregisterPlayer, recordedDimensions]);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full justify-center overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
style={{ maxHeight: '70vh' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal file
234
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ReplayPlayPauseButton } from './replay-controls';
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
|
||||
const t =
|
||||
typeof event.createdAt === 'object' && event.createdAt instanceof Date
|
||||
? event.createdAt.getTime()
|
||||
: new Date(event.createdAt).getTime();
|
||||
return t - startTime;
|
||||
}
|
||||
|
||||
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
const { currentTime, duration, startTime, isReady, seek } =
|
||||
useReplayContext();
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverInfo, setHoverInfo] = useState<{
|
||||
pct: number;
|
||||
timeMs: number;
|
||||
} | null>(null);
|
||||
|
||||
const getTimeFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!trackRef.current || duration <= 0) return null;
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(1, x / rect.width));
|
||||
return { pct, timeMs: pct * duration };
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
const handleTrackMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest('[data-timeline-event]')) {
|
||||
setHoverInfo(null);
|
||||
return;
|
||||
}
|
||||
const info = getTimeFromClientX(e.clientX);
|
||||
if (info) setHoverInfo(info);
|
||||
},
|
||||
[getTimeFromClientX],
|
||||
);
|
||||
|
||||
const handleTrackMouseLeave = useCallback(() => {
|
||||
if (!isDragging) setHoverInfo(null);
|
||||
}, [isDragging]);
|
||||
|
||||
const seekToPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const info = getTimeFromClientX(clientX);
|
||||
if (info) seek(info.timeMs);
|
||||
},
|
||||
[getTimeFromClientX, seek],
|
||||
);
|
||||
|
||||
const handleTrackMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only handle direct clicks on the track, not on child elements like the thumb
|
||||
if (
|
||||
e.target !== trackRef.current &&
|
||||
!(e.target as HTMLElement).closest('.replay-track-bg')
|
||||
)
|
||||
return;
|
||||
seekToPosition(e.clientX);
|
||||
},
|
||||
[seekToPosition],
|
||||
);
|
||||
|
||||
const handleThumbMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
seekToPosition(moveEvent.clientX);
|
||||
const info = getTimeFromClientX(moveEvent.clientX);
|
||||
if (info) setHoverInfo(info);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setHoverInfo(null);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[seekToPosition, getTimeFromClientX],
|
||||
);
|
||||
|
||||
if (!isReady || duration <= 0) return null;
|
||||
|
||||
const progressPct =
|
||||
duration > 0
|
||||
? Math.max(0, Math.min(100, (currentTime / duration) * 100))
|
||||
: 0;
|
||||
|
||||
const eventsWithOffset = events
|
||||
.map((ev) => ({
|
||||
event: ev,
|
||||
offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0,
|
||||
}))
|
||||
.filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="row items-center gap-4 p-4">
|
||||
<ReplayPlayPauseButton />
|
||||
<div className="col gap-4 flex-1 px-2">
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={currentTime}
|
||||
tabIndex={0}
|
||||
className="relative flex h-8 cursor-pointer items-center"
|
||||
onMouseDown={handleTrackMouseDown}
|
||||
onMouseMove={handleTrackMouseMove}
|
||||
onMouseLeave={handleTrackMouseLeave}
|
||||
onKeyDown={(e) => {
|
||||
const step = 5000;
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
seek(Math.max(0, currentTime - step));
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
seek(Math.min(duration, currentTime + step));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="replay-track-bg bg-muted h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-[width] duration-75"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 cursor-grab rounded-full border-2 border-primary bg-background shadow-sm transition-[left] duration-75 active:cursor-grabbing"
|
||||
style={{ left: `calc(${progressPct}% - 8px)` }}
|
||||
onMouseDown={handleThumbMouseDown}
|
||||
aria-hidden
|
||||
/>
|
||||
{/* Hover timestamp tooltip */}
|
||||
<AnimatePresence>
|
||||
{hoverInfo && (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute z-20"
|
||||
style={{
|
||||
left: `${hoverInfo.pct * 100}%`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-0 top-1/2 h-4 w-px -translate-x-1/2 -translate-y-1/2 bg-foreground/30" />
|
||||
{/* Timestamp badge */}
|
||||
<motion.div
|
||||
className="absolute bottom-6 left-1/2 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background shadow"
|
||||
initial={{ opacity: 0, y: 16, scale: 0.5 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{formatTime(hoverInfo.timeMs)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{eventsWithOffset.map(({ event: ev, offsetMs }) => {
|
||||
const pct = (offsetMs / duration) * 100;
|
||||
return (
|
||||
<Tooltip key={ev.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-timeline-event
|
||||
className={cn(
|
||||
'absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-125',
|
||||
)}
|
||||
style={{ left: `${pct}%`, marginLeft: -12 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
seek(offsetMs);
|
||||
}}
|
||||
aria-label={`${ev.name} at ${formatTime(offsetMs)}`}
|
||||
>
|
||||
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="col gap-2">
|
||||
<div className="font-medium row items-center gap-2">
|
||||
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
|
||||
{ev.name === 'screen_view' ? ev.path : ev.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(offsetMs)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Video } from 'lucide-react';
|
||||
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
@@ -44,13 +45,25 @@ export function useColumns() {
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/sessions/${session.id}`}
|
||||
className="font-medium"
|
||||
title={session.id}
|
||||
>
|
||||
{session.id.slice(0, 8)}...
|
||||
</ProjectLink>
|
||||
<div className="row gap-2 items-center">
|
||||
<ProjectLink
|
||||
href={`/sessions/${session.id}`}
|
||||
className="font-medium"
|
||||
title={session.id}
|
||||
>
|
||||
{session.id.slice(0, 8)}...
|
||||
</ProjectLink>
|
||||
{session.hasReplay && (
|
||||
<ProjectLink
|
||||
href={`/sessions/${session.id}#replay`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="View replay"
|
||||
aria-label="View replay"
|
||||
>
|
||||
<Video className="size-4" />
|
||||
</ProjectLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { ReplayShell } from '@/components/sessions/replay';
|
||||
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
@@ -117,6 +118,9 @@ function Component() {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="mb-6">
|
||||
<ReplayShell sessionId={sessionId} projectId={projectId} />
|
||||
</div>
|
||||
<EventsTable query={query} />
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
43
apps/start/src/types/rrweb-player.d.ts
vendored
Normal file
43
apps/start/src/types/rrweb-player.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
declare module 'rrweb-player' {
|
||||
interface RrwebPlayerProps {
|
||||
events: Array<{ type: number; data: unknown; timestamp: number }>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
autoPlay?: boolean;
|
||||
showController?: boolean;
|
||||
speedOption?: number[];
|
||||
}
|
||||
|
||||
interface RrwebPlayerOptions {
|
||||
target: HTMLElement;
|
||||
props: RrwebPlayerProps;
|
||||
}
|
||||
|
||||
interface RrwebReplayer {
|
||||
getCurrentTime: () => number;
|
||||
}
|
||||
|
||||
interface RrwebPlayerMetaData {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
interface RrwebPlayerInstance {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
goto: (timeOffset: number, play?: boolean) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
getMetaData: () => RrwebPlayerMetaData;
|
||||
getReplayer: () => RrwebReplayer;
|
||||
addEventListener?: (
|
||||
event: string,
|
||||
handler: (...args: unknown[]) => void,
|
||||
) => void;
|
||||
$destroy?: () => void;
|
||||
}
|
||||
|
||||
const rrwebPlayer: new (options: RrwebPlayerOptions) => RrwebPlayerInstance;
|
||||
export default rrwebPlayer;
|
||||
}
|
||||
Reference in New Issue
Block a user