wip
This commit is contained in:
@@ -92,7 +92,7 @@ async function getDeviceIdFromSession({
|
||||
* Deterministic session id for (projectId, deviceId) within a time window,
|
||||
* with a grace period at the *start* of each window to avoid boundary splits.
|
||||
*
|
||||
* - windowMs: 5 minutes by default
|
||||
* - windowMs: 30 minutes by default
|
||||
* - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket)
|
||||
* - Output: base64url, 128-bit (16 bytes) truncated from SHA-256
|
||||
*/
|
||||
|
||||
@@ -89,7 +89,7 @@ export function useColumns() {
|
||||
projectId: row.original.projectId,
|
||||
});
|
||||
}}
|
||||
className="font-medium"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{renderName()}
|
||||
</button>
|
||||
@@ -144,10 +144,21 @@ export function useColumns() {
|
||||
{
|
||||
accessorKey: 'sessionId',
|
||||
header: 'Session ID',
|
||||
size: 320,
|
||||
size: 100,
|
||||
meta: {
|
||||
hidden: true,
|
||||
},
|
||||
cell({ row }) {
|
||||
const { sessionId } = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
{sessionId.slice(0,6)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'deviceId',
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BrowserChrome } from './browser-chrome';
|
||||
import { ReplayTime } from './replay-controls';
|
||||
import { getEventOffsetMs } from './replay-utils';
|
||||
import {
|
||||
ReplayProvider,
|
||||
useCurrentTime,
|
||||
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 { startTime } = useReplayContext();
|
||||
const currentTime = useCurrentTime(250);
|
||||
|
||||
const currentUrl = useMemo(() => {
|
||||
if (startTime == null || !events.length) return '';
|
||||
if (startTime == null || !events.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const withOffset = events
|
||||
.map((ev) => ({
|
||||
@@ -37,14 +33,102 @@ function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
|
||||
.sort((a, b) => a.offsetMs - b.offsetMs);
|
||||
|
||||
const latest = withOffset[withOffset.length - 1];
|
||||
if (!latest) return '';
|
||||
if (!latest) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { origin = '', path = '/' } = latest.event;
|
||||
const pathPart = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${origin}${pathPart}`;
|
||||
return `${origin}${path}`;
|
||||
}, [events, currentTime, startTime]);
|
||||
|
||||
return <span className="text-muted-foreground truncate">{currentUrl}</span>;
|
||||
return <span className="truncate text-muted-foreground">{currentUrl}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds remaining chunks into the player after it's ready.
|
||||
* Receives already-fetched chunks from the initial batch, then pages
|
||||
* through the rest using replayChunksFrom.
|
||||
*/
|
||||
function ReplayChunkLoader({
|
||||
sessionId,
|
||||
projectId,
|
||||
fromIndex,
|
||||
}: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
fromIndex: number;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { addEvent, refreshDuration } = useReplayContext();
|
||||
|
||||
useEffect(() => {
|
||||
function recursive(fromIndex: number) {
|
||||
queryClient
|
||||
.fetchQuery(
|
||||
trpc.session.replayChunksFrom.queryOptions({
|
||||
sessionId,
|
||||
projectId,
|
||||
fromIndex,
|
||||
})
|
||||
)
|
||||
.then((res) => {
|
||||
res.data.forEach((row) => {
|
||||
row.events.forEach((event) => {
|
||||
addEvent(event);
|
||||
});
|
||||
});
|
||||
refreshDuration();
|
||||
if (res.hasMore) {
|
||||
recursive(fromIndex + res.data.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recursive(fromIndex);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FullscreenButton({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
}, [containerRef]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
onClick={toggle}
|
||||
type="button"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayContent({
|
||||
@@ -55,75 +139,82 @@ function ReplayContent({
|
||||
projectId: string;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const {
|
||||
data: replayData,
|
||||
isLoading: replayLoading,
|
||||
isError: replayError,
|
||||
} = useQuery(trpc.session.replay.queryOptions({ sessionId, projectId }));
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: eventsData } = useQuery(
|
||||
trpc.event.events.queryOptions({
|
||||
projectId,
|
||||
sessionId,
|
||||
filters: [],
|
||||
columnVisibility: {},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch first batch of chunks (includes chunk 0 for player init + more)
|
||||
const { data: firstBatch, isLoading: replayLoading } = useQuery(
|
||||
trpc.session.replayChunksFrom.queryOptions({
|
||||
sessionId,
|
||||
projectId,
|
||||
fromIndex: 0,
|
||||
})
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const playerEvents = firstBatch?.data.flatMap((row) => row.events) ?? [];
|
||||
const hasMore = firstBatch?.hasMore ?? false;
|
||||
const hasReplay = playerEvents.length !== 0;
|
||||
|
||||
return (
|
||||
<ReplayProvider>
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
|
||||
<div
|
||||
className="grid gap-4 lg:grid-cols-[1fr_380px] [&:fullscreen]:flex [&:fullscreen]:flex-col [&:fullscreen]:bg-background [&:fullscreen]:p-4"
|
||||
id="replay"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col overflow-hidden">
|
||||
<BrowserChrome
|
||||
url={<BrowserUrlBar events={events} />}
|
||||
right={<ReplayTime />}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
{hasReplay && <ReplayTime />}
|
||||
<FullscreenButton containerRef={containerRef} />
|
||||
</div>
|
||||
}
|
||||
url={
|
||||
hasReplay ? (
|
||||
<BrowserUrlBar events={events} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">about:blank</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ReplayPlayer events={replayEvents} />
|
||||
<ReplayTimeline events={events} />
|
||||
{replayLoading ? (
|
||||
<div className="col h-[320px] items-center justify-center gap-4 bg-background">
|
||||
<div className="h-8 w-8 animate-pulse rounded-full bg-muted" />
|
||||
<div>Loading session replay</div>
|
||||
</div>
|
||||
) : hasReplay ? (
|
||||
<ReplayPlayer events={playerEvents} />
|
||||
) : (
|
||||
<div className="flex h-[320px] items-center justify-center bg-background text-muted-foreground text-sm">
|
||||
No replay data available for this session.
|
||||
</div>
|
||||
)}
|
||||
{hasReplay && <ReplayTimeline events={events} />}
|
||||
</BrowserChrome>
|
||||
</div>
|
||||
<div className="hidden lg:block relative">
|
||||
<div className="relative hidden lg:block">
|
||||
<div className="absolute inset-0">
|
||||
<ReplayEventFeed events={events} />
|
||||
<ReplayEventFeed events={events} replayLoading={replayLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasReplay && hasMore && (
|
||||
<ReplayChunkLoader
|
||||
fromIndex={firstBatch?.data?.length ?? 0}
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
</ReplayProvider>
|
||||
);
|
||||
}
|
||||
@@ -135,5 +226,5 @@ export function ReplayShell({
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return <ReplayContent sessionId={sessionId} projectId={projectId} />;
|
||||
return <ReplayContent projectId={projectId} sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
@@ -18,23 +16,40 @@ export interface ReplayPlayerInstance {
|
||||
setSpeed: (speed: number) => void;
|
||||
getMetaData: () => { startTime: number; endTime: number; totalTime: number };
|
||||
getReplayer: () => { getCurrentTime: () => number };
|
||||
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
|
||||
addEventListener: (event: string, handler: (e: { payload: unknown }) => void) => void;
|
||||
$set?: (props: Record<string, unknown>) => void;
|
||||
$destroy?: () => void;
|
||||
}
|
||||
|
||||
type CurrentTimeListener = (t: number) => void;
|
||||
|
||||
interface ReplayContextValue {
|
||||
currentTime: number;
|
||||
// High-frequency value — read via ref, not state. Use subscribeToCurrentTime
|
||||
// or useCurrentTime() to get updates without causing 60fps re-renders.
|
||||
currentTimeRef: React.MutableRefObject<number>;
|
||||
subscribeToCurrentTime: (fn: CurrentTimeListener) => () => void;
|
||||
// Low-frequency state (safe to consume directly)
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
duration: number;
|
||||
startTime: number | null;
|
||||
isReady: boolean;
|
||||
// Playback controls
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
seek: (timeOffset: number, play?: boolean) => void;
|
||||
seek: (timeMs: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
registerPlayer: (player: ReplayPlayerInstance) => void;
|
||||
unregisterPlayer: () => void;
|
||||
// Lazy chunk loading
|
||||
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
|
||||
refreshDuration: () => void;
|
||||
// Called by ReplayPlayer to register/unregister the rrweb instance
|
||||
onPlayerReady: (player: ReplayPlayerInstance, playerStartTime: number) => void;
|
||||
onPlayerDestroy: () => void;
|
||||
// State setters exposed so ReplayPlayer can wire rrweb event listeners
|
||||
setCurrentTime: (t: number) => void;
|
||||
setIsPlaying: (p: boolean) => void;
|
||||
setDuration: (d: number) => void;
|
||||
}
|
||||
|
||||
const ReplayContext = createContext<ReplayContextValue | null>(null);
|
||||
@@ -49,147 +64,122 @@ export function useReplayContext() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to currentTime updates at a throttled rate.
|
||||
* intervalMs=0 means every tick (use for the progress bar DOM writes).
|
||||
* intervalMs=250 means 4 updates/second (use for text displays).
|
||||
*/
|
||||
export function useCurrentTime(intervalMs = 0): number {
|
||||
const { currentTimeRef, subscribeToCurrentTime } = useReplayContext();
|
||||
const [time, setTime] = useState(currentTimeRef.current);
|
||||
const lastUpdateRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToCurrentTime((t) => {
|
||||
if (intervalMs === 0) {
|
||||
setTime(t);
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
if (now - lastUpdateRef.current >= intervalMs) {
|
||||
lastUpdateRef.current = now;
|
||||
setTime(t);
|
||||
}
|
||||
});
|
||||
}, [subscribeToCurrentTime, intervalMs]);
|
||||
|
||||
return time;
|
||||
}
|
||||
|
||||
export function ReplayProvider({ children }: { children: ReactNode }) {
|
||||
const playerRef = useRef<ReplayPlayerInstance | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const isPlayingRef = useRef(false);
|
||||
const currentTimeRef = useRef(0);
|
||||
const listenersRef = useRef<Set<CurrentTimeListener>>(new Set());
|
||||
|
||||
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 setIsPlayingWithRef = useCallback((playing: boolean) => {
|
||||
isPlayingRef.current = playing;
|
||||
setIsPlaying(playing);
|
||||
}, []);
|
||||
|
||||
const subscribeToCurrentTime = useCallback((fn: CurrentTimeListener) => {
|
||||
listenersRef.current.add(fn);
|
||||
return () => {
|
||||
listenersRef.current.delete(fn);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Called by ReplayPlayer on every ui-update-current-time tick.
|
||||
// Updates the ref and notifies subscribers — no React state update here.
|
||||
const setCurrentTime = useCallback((t: number) => {
|
||||
currentTimeRef.current = t;
|
||||
for (const fn of listenersRef.current) {
|
||||
fn(t);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unregisterPlayer = useCallback(() => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
const onPlayerReady = useCallback(
|
||||
(player: ReplayPlayerInstance, playerStartTime: number) => {
|
||||
playerRef.current = player;
|
||||
setStartTime(playerStartTime);
|
||||
currentTimeRef.current = 0;
|
||||
setIsPlayingWithRef(false);
|
||||
setIsReady(true);
|
||||
},
|
||||
[setIsPlayingWithRef],
|
||||
);
|
||||
|
||||
const onPlayerDestroy = useCallback(() => {
|
||||
playerRef.current = null;
|
||||
setIsReady(false);
|
||||
setCurrentTime(0);
|
||||
currentTimeRef.current = 0;
|
||||
setDuration(0);
|
||||
durationRef.current = 0;
|
||||
setStartTime(null);
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
}, []);
|
||||
setIsPlayingWithRef(false);
|
||||
}, [setIsPlayingWithRef]);
|
||||
|
||||
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;
|
||||
playerRef.current?.toggle();
|
||||
}, []);
|
||||
|
||||
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 seek = useCallback((timeMs: number) => {
|
||||
playerRef.current?.goto(timeMs, isPlayingRef.current);
|
||||
}, []);
|
||||
|
||||
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 addEvent = useCallback(
|
||||
(event: { type: number; data: unknown; timestamp: number }) => {
|
||||
playerRef.current?.addEvent(event);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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 refreshDuration = useCallback(() => {
|
||||
const total = playerRef.current?.getMetaData().totalTime ?? 0;
|
||||
if (total > 0) setDuration(total);
|
||||
}, []);
|
||||
|
||||
const value: ReplayContextValue = {
|
||||
currentTime,
|
||||
currentTimeRef,
|
||||
subscribeToCurrentTime,
|
||||
isPlaying,
|
||||
speed,
|
||||
duration,
|
||||
startTime,
|
||||
isReady,
|
||||
@@ -198,8 +188,13 @@ export function ReplayProvider({ children }: { children: ReactNode }) {
|
||||
toggle,
|
||||
seek,
|
||||
setSpeed,
|
||||
registerPlayer,
|
||||
unregisterPlayer,
|
||||
addEvent,
|
||||
refreshDuration,
|
||||
onPlayerReady,
|
||||
onPlayerDestroy,
|
||||
setCurrentTime,
|
||||
setIsPlaying: setIsPlayingWithRef,
|
||||
setDuration,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SPEED_OPTIONS,
|
||||
useReplayContext,
|
||||
} from '@/components/sessions/replay/replay-context';
|
||||
import { useCurrentTime, 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')}`;
|
||||
}
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import { formatDuration } from './replay-utils';
|
||||
|
||||
export function ReplayTime() {
|
||||
const { currentTime, duration } = useReplayContext();
|
||||
const { duration } = useReplayContext();
|
||||
const currentTime = useCurrentTime(250);
|
||||
|
||||
return (
|
||||
<span className="text-sm tabular-nums text-muted-foreground font-mono">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayPlayPauseButton() {
|
||||
const { isPlaying, isReady, toggle, seek } = useReplayContext();
|
||||
const { isPlaying, isReady, toggle } = useReplayContext();
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
@@ -47,23 +31,3 @@ export function ReplayPlayPauseButton() {
|
||||
</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> */}
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import { useCurrentTime, 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';
|
||||
import { getEventOffsetMs } from './replay-utils';
|
||||
|
||||
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;
|
||||
}
|
||||
type EventWithOffset = { event: IServiceEvent; offsetMs: number };
|
||||
|
||||
export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
|
||||
const { currentTime, startTime, isReady, seek } = useReplayContext();
|
||||
export function ReplayEventFeed({ events, replayLoading }: { events: IServiceEvent[]; replayLoading: boolean }) {
|
||||
const { startTime, isReady, seek } = useReplayContext();
|
||||
const currentTime = useCurrentTime(100);
|
||||
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)
|
||||
// Pre-sort events by offset once when events/startTime changes.
|
||||
// This is the expensive part — done once, not on every tick.
|
||||
const sortedEvents = useMemo<EventWithOffset[]>(() => {
|
||||
if (startTime == null || !isReady) return [];
|
||||
return events
|
||||
.map((ev) => ({ event: ev, offsetMs: getEventOffsetMs(ev, startTime) }))
|
||||
.filter(({ offsetMs }) => offsetMs >= -10_000)
|
||||
.sort((a, b) => a.offsetMs - b.offsetMs);
|
||||
}, [events, startTime, isReady]);
|
||||
|
||||
const visibleEvents = withOffset.map(({ event, offsetMs }) => ({
|
||||
event,
|
||||
offsetMs,
|
||||
}));
|
||||
// Binary search to find how many events are visible at currentTime.
|
||||
// O(log n) instead of O(n) filter on every tick.
|
||||
const visibleCount = useMemo(() => {
|
||||
let lo = 0;
|
||||
let hi = sortedEvents.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if ((sortedEvents[mid]?.offsetMs ?? 0) <= currentTime) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}, [sortedEvents, currentTime]);
|
||||
|
||||
const current =
|
||||
visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
|
||||
const currentEventId = current?.event.id ?? null;
|
||||
|
||||
return { visibleEvents, currentEventId };
|
||||
}, [events, startTime, isReady, currentTime]);
|
||||
const visibleEvents = sortedEvents.slice(0, visibleCount);
|
||||
const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
@@ -60,8 +58,6 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
|
||||
});
|
||||
}, [visibleEvents.length]);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<BrowserChrome
|
||||
url={false}
|
||||
@@ -77,17 +73,33 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
|
||||
>
|
||||
<ReplayEventItem
|
||||
event={event}
|
||||
offsetMs={offsetMs}
|
||||
isCurrent={event.id === currentEventId}
|
||||
onClick={() => seek(Math.max(0, offsetMs))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{visibleEvents.length === 0 && (
|
||||
{!replayLoading && visibleEvents.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Events will appear as the replay plays.
|
||||
</div>
|
||||
)}
|
||||
{replayLoading &&
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 border-b px-3 py-2"
|
||||
>
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-muted" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-muted"
|
||||
style={{ width: `${50 + (i % 4) * 12}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</BrowserChrome>
|
||||
|
||||
@@ -16,7 +16,6 @@ export function ReplayEventItem({
|
||||
onClick,
|
||||
}: {
|
||||
event: IServiceEvent;
|
||||
offsetMs: number;
|
||||
isCurrent: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -30,7 +29,7 @@ export function ReplayEventItem({
|
||||
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',
|
||||
'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent',
|
||||
isCurrent ? 'bg-accent/10' : 'bg-card',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import 'rrweb-player/dist/style.css';
|
||||
|
||||
@@ -24,6 +22,16 @@ function getRecordedDimensions(
|
||||
return null;
|
||||
}
|
||||
|
||||
function calcDimensions(
|
||||
containerWidth: number,
|
||||
aspectRatio: number,
|
||||
): { width: number; height: number } {
|
||||
const maxHeight = window.innerHeight * 0.7;
|
||||
const height = Math.min(Math.round(containerWidth / aspectRatio), maxHeight);
|
||||
const width = Math.min(containerWidth, Math.round(height * aspectRatio));
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function ReplayPlayer({
|
||||
events,
|
||||
}: {
|
||||
@@ -31,7 +39,14 @@ export function ReplayPlayer({
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<ReplayPlayerInstance | null>(null);
|
||||
const { registerPlayer, unregisterPlayer } = useReplayContext();
|
||||
const {
|
||||
onPlayerReady,
|
||||
onPlayerDestroy,
|
||||
setCurrentTime,
|
||||
setIsPlaying,
|
||||
setDuration,
|
||||
} = useReplayContext();
|
||||
const [importError, setImportError] = useState(false);
|
||||
|
||||
const recordedDimensions = useMemo(
|
||||
() => getRecordedDimensions(events),
|
||||
@@ -41,51 +56,115 @@ export function ReplayPlayer({
|
||||
useEffect(() => {
|
||||
if (!events.length || !containerRef.current) return;
|
||||
|
||||
// Clear any previous player DOM
|
||||
containerRef.current.innerHTML = '';
|
||||
|
||||
let mounted = true;
|
||||
let player: ReplayPlayerInstance | null = null;
|
||||
let handleVisibilityChange: (() => void) | null = null;
|
||||
|
||||
import('rrweb-player').then((module) => {
|
||||
const PlayerConstructor = module.default;
|
||||
if (!containerRef.current || !mounted) return;
|
||||
containerRef.current.innerHTML = '';
|
||||
const aspectRatio = recordedDimensions
|
||||
? recordedDimensions.width / recordedDimensions.height
|
||||
: 16 / 9;
|
||||
|
||||
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, height } = calcDimensions(
|
||||
containerRef.current.offsetWidth,
|
||||
aspectRatio,
|
||||
);
|
||||
|
||||
import('rrweb-player')
|
||||
.then((module) => {
|
||||
if (!containerRef.current || !mounted) return;
|
||||
|
||||
const PlayerConstructor = module.default;
|
||||
player = new PlayerConstructor({
|
||||
target: containerRef.current,
|
||||
props: {
|
||||
events,
|
||||
width,
|
||||
height,
|
||||
autoPlay: false,
|
||||
showController: false,
|
||||
speedOption: [0.5, 1, 2, 4, 8],
|
||||
UNSAFE_replayCanvas: true,
|
||||
skipInactive: false,
|
||||
},
|
||||
}) as ReplayPlayerInstance;
|
||||
|
||||
playerRef.current = player;
|
||||
|
||||
// Wire rrweb's built-in event emitter — no RAF loop needed.
|
||||
// Note: rrweb-player does NOT emit ui-update-duration; duration is
|
||||
// read from getMetaData() on init and after each addEvent batch.
|
||||
player.addEventListener('ui-update-current-time', (e) => {
|
||||
const t = e.payload as number;
|
||||
setCurrentTime(t);
|
||||
});
|
||||
|
||||
player.addEventListener('ui-update-player-state', (e) => {
|
||||
setIsPlaying(e.payload === 'playing');
|
||||
});
|
||||
|
||||
// Pause on tab hide; resume on show (prevents timer drift)
|
||||
let wasPlaying = false;
|
||||
handleVisibilityChange = () => {
|
||||
if (!player) return;
|
||||
if (document.hidden) {
|
||||
const meta = player.getMetaData() as { isPlaying?: boolean };
|
||||
wasPlaying = meta.isPlaying ?? false;
|
||||
if (wasPlaying) player.pause();
|
||||
} else {
|
||||
if (wasPlaying) {
|
||||
player.play();
|
||||
wasPlaying = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Notify context — marks isReady = true and sets initial duration
|
||||
const meta = player.getMetaData();
|
||||
if (meta.totalTime > 0) setDuration(meta.totalTime);
|
||||
onPlayerReady(player, meta.startTime);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setImportError(true);
|
||||
});
|
||||
|
||||
const onWindowResize = () => {
|
||||
if (!containerRef.current || !mounted || !playerRef.current?.$set) return;
|
||||
const { width: w, height: h } = calcDimensions(
|
||||
containerRef.current.offsetWidth,
|
||||
aspectRatio,
|
||||
);
|
||||
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);
|
||||
});
|
||||
playerRef.current.$set({ width: w, height: h });
|
||||
};
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unregisterPlayer();
|
||||
if (playerRef.current?.$destroy) {
|
||||
playerRef.current.$destroy();
|
||||
playerRef.current = null;
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
if (handleVisibilityChange) {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (player) {
|
||||
player.pause();
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
playerRef.current = null;
|
||||
onPlayerDestroy();
|
||||
};
|
||||
}, [events, registerPlayer, unregisterPlayer, recordedDimensions]);
|
||||
}, [events, recordedDimensions, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying, setDuration]);
|
||||
|
||||
if (importError) {
|
||||
return (
|
||||
<div className="flex h-[320px] items-center justify-center bg-black text-sm text-muted-foreground">
|
||||
Failed to load replay player.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full justify-center overflow-hidden">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -9,36 +7,50 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, 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;
|
||||
}
|
||||
import { formatDuration, getEventOffsetMs } from './replay-utils';
|
||||
|
||||
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
const { currentTime, duration, startTime, isReady, seek } =
|
||||
const { currentTimeRef, duration, startTime, isReady, seek, subscribeToCurrentTime } =
|
||||
useReplayContext();
|
||||
// currentTime as React state is only needed for keyboard seeks (low frequency).
|
||||
// The progress bar and thumb are updated directly via DOM refs to avoid re-renders.
|
||||
const currentTime = useCurrentTime(250);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverInfo, setHoverInfo] = useState<{
|
||||
pct: number;
|
||||
timeMs: number;
|
||||
} | null>(null);
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const rafDragRef = useRef<number | null>(null);
|
||||
|
||||
// Clean up any in-progress drag listeners when the component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dragCleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update progress bar and thumb directly via DOM on every tick — no React re-render.
|
||||
useEffect(() => {
|
||||
if (duration <= 0) return;
|
||||
return subscribeToCurrentTime((t) => {
|
||||
const pct = Math.max(0, Math.min(100, (t / duration) * 100));
|
||||
if (progressBarRef.current) {
|
||||
progressBarRef.current.style.width = `${pct}%`;
|
||||
}
|
||||
if (thumbRef.current) {
|
||||
thumbRef.current.style.left = `calc(${pct}% - 8px)`;
|
||||
}
|
||||
});
|
||||
}, [subscribeToCurrentTime, duration]);
|
||||
|
||||
const getTimeFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
@@ -67,14 +79,6 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
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
|
||||
@@ -83,46 +87,57 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
!(e.target as HTMLElement).closest('.replay-track-bg')
|
||||
)
|
||||
return;
|
||||
seekToPosition(e.clientX);
|
||||
const info = getTimeFromClientX(e.clientX);
|
||||
if (info) seek(info.timeMs);
|
||||
},
|
||||
[seekToPosition],
|
||||
[getTimeFromClientX, seek],
|
||||
);
|
||||
|
||||
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],
|
||||
const eventsWithOffset = useMemo(
|
||||
() =>
|
||||
events
|
||||
.map((ev) => ({
|
||||
event: ev,
|
||||
offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0,
|
||||
}))
|
||||
.filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration),
|
||||
[events, startTime, duration],
|
||||
);
|
||||
|
||||
// Group events that are within 24px of each other on the track.
|
||||
// We need the track width for pixel math — use a stable ref-based calculation.
|
||||
const groupedEvents = useMemo(() => {
|
||||
if (!eventsWithOffset.length || duration <= 0) return [];
|
||||
|
||||
// Sort by offsetMs so we sweep left-to-right
|
||||
const sorted = [...eventsWithOffset].sort((a, b) => a.offsetMs - b.offsetMs);
|
||||
|
||||
// 24px in ms — recalculated from container width; fall back to 2% of duration
|
||||
const trackWidth = trackRef.current?.offsetWidth ?? 600;
|
||||
const thresholdMs = (24 / trackWidth) * duration;
|
||||
|
||||
const groups: { items: typeof sorted; pct: number }[] = [];
|
||||
for (const item of sorted) {
|
||||
const last = groups[groups.length - 1];
|
||||
const lastPct = last ? (last.items[last.items.length - 1]!.offsetMs / duration) * 100 : -Infinity;
|
||||
const thisPct = (item.offsetMs / duration) * 100;
|
||||
|
||||
if (last && item.offsetMs - last.items[last.items.length - 1]!.offsetMs <= thresholdMs) {
|
||||
last.items.push(item);
|
||||
// Anchor the group at its first item's position
|
||||
} else {
|
||||
groups.push({ items: [item], pct: thisPct });
|
||||
}
|
||||
// keep pct pointing at the first item (already set on push)
|
||||
void lastPct;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [eventsWithOffset, duration]);
|
||||
|
||||
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);
|
||||
const progressPct = Math.max(0, Math.min(100, (currentTimeRef.current / duration) * 100));
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -136,7 +151,7 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={currentTime}
|
||||
tabIndex={0}
|
||||
className="relative flex h-8 cursor-pointer items-center"
|
||||
className="relative flex h-8 cursor-pointer items-center outline-0"
|
||||
onMouseDown={handleTrackMouseDown}
|
||||
onMouseMove={handleTrackMouseMove}
|
||||
onMouseLeave={handleTrackMouseLeave}
|
||||
@@ -153,14 +168,15 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
>
|
||||
<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"
|
||||
ref={progressBarRef}
|
||||
className="bg-primary h-full rounded-full"
|
||||
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"
|
||||
ref={thumbRef}
|
||||
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
|
||||
style={{ left: `calc(${progressPct}% - 8px)` }}
|
||||
onMouseDown={handleThumbMouseDown}
|
||||
aria-hidden
|
||||
/>
|
||||
{/* Hover timestamp tooltip */}
|
||||
@@ -188,40 +204,48 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
exit={{ opacity: 0, y: 16, scale: 0.5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{formatTime(hoverInfo.timeMs)}
|
||||
{formatDuration(hoverInfo.timeMs)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{eventsWithOffset.map(({ event: ev, offsetMs }) => {
|
||||
const pct = (offsetMs / duration) * 100;
|
||||
{groupedEvents.map((group) => {
|
||||
const first = group.items[0]!;
|
||||
const isGroup = group.items.length > 1;
|
||||
return (
|
||||
<Tooltip key={ev.id}>
|
||||
<Tooltip key={first.event.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 }}
|
||||
className="absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-105"
|
||||
style={{ left: `${group.pct}%`, marginLeft: -12 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
seek(offsetMs);
|
||||
seek(first.offsetMs);
|
||||
}}
|
||||
aria-label={`${ev.name} at ${formatTime(offsetMs)}`}
|
||||
aria-label={isGroup ? `${group.items.length} events at ${formatDuration(first.offsetMs)}` : `${first.event.name} at ${formatDuration(first.offsetMs)}`}
|
||||
>
|
||||
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
|
||||
<EventIcon name={first.event.name} meta={first.event.meta} size="sm" />
|
||||
{isGroup && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold leading-none text-background">
|
||||
{group.items.length}
|
||||
</span>
|
||||
)}
|
||||
</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 side="top" className="col gap-1.5">
|
||||
{group.items.map(({ event: ev, offsetMs }) => (
|
||||
<div key={ev.id} className="row items-center gap-2">
|
||||
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
|
||||
<span className="font-medium">
|
||||
{ev.name === 'screen_view' ? ev.path : ev.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDuration(offsetMs)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
20
apps/start/src/components/sessions/replay/replay-utils.ts
Normal file
20
apps/start/src/components/sessions/replay/replay-utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** Format a duration in milliseconds as M:SS */
|
||||
export function formatDuration(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')}`;
|
||||
}
|
||||
4
apps/start/src/types/rrweb-player.d.ts
vendored
4
apps/start/src/types/rrweb-player.d.ts
vendored
@@ -6,6 +6,8 @@ declare module 'rrweb-player' {
|
||||
autoPlay?: boolean;
|
||||
showController?: boolean;
|
||||
speedOption?: number[];
|
||||
UNSAFE_replayCanvas?: boolean;
|
||||
skipInactive?: boolean;
|
||||
}
|
||||
|
||||
interface RrwebPlayerOptions {
|
||||
@@ -31,10 +33,12 @@ declare module 'rrweb-player' {
|
||||
setSpeed: (speed: number) => void;
|
||||
getMetaData: () => RrwebPlayerMetaData;
|
||||
getReplayer: () => RrwebReplayer;
|
||||
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
|
||||
addEventListener?: (
|
||||
event: string,
|
||||
handler: (...args: unknown[]) => void,
|
||||
) => void;
|
||||
$set?: (props: Partial<RrwebPlayerProps>) => void;
|
||||
$destroy?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ export class ReplayBuffer extends BaseBuffer {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = items.map((item) =>
|
||||
getSafeJson<IClickhouseSessionReplayChunk>(item),
|
||||
);
|
||||
const chunks = items
|
||||
.map((item) => getSafeJson<IClickhouseSessionReplayChunk>(item))
|
||||
.filter((item): item is IClickhouseSessionReplayChunk => item != null);
|
||||
|
||||
for (const chunk of this.chunks(chunks, this.chunkSize)) {
|
||||
await ch.insert({
|
||||
|
||||
@@ -2,17 +2,17 @@ import { cacheable } from '@openpanel/redis';
|
||||
import type { IChartEventFilter } from '@openpanel/validation';
|
||||
import sqlstring from 'sqlstring';
|
||||
import {
|
||||
TABLE_NAMES,
|
||||
ch,
|
||||
chQuery,
|
||||
convertClickhouseDateToJs,
|
||||
formatClickhouseDate,
|
||||
TABLE_NAMES,
|
||||
} from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
import { createSqlBuilder } from '../sql-builder';
|
||||
import { getEventFiltersWhereClause } from './chart.service';
|
||||
import { getOrganizationByProjectIdCached } from './organization.service';
|
||||
import { type IServiceProfile, getProfilesCached } from './profile.service';
|
||||
import { getProfilesCached, type IServiceProfile } from './profile.service';
|
||||
|
||||
export type IClickhouseSession = {
|
||||
id: string;
|
||||
@@ -180,8 +180,9 @@ export async function getSessionList({
|
||||
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
|
||||
}
|
||||
|
||||
if (profileId)
|
||||
if (profileId) {
|
||||
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
||||
}
|
||||
if (search) {
|
||||
const s = sqlstring.escape(`%${search}%`);
|
||||
sb.where.search = `(entry_path ILIKE ${s} OR exit_path ILIKE ${s} OR referrer ILIKE ${s} OR referrer_name ILIKE ${s})`;
|
||||
@@ -237,7 +238,8 @@ export async function getSessionList({
|
||||
sb.select[column] = column;
|
||||
});
|
||||
|
||||
sb.select.has_replay = `exists(SELECT 1 FROM ${TABLE_NAMES.session_replay_chunks} WHERE session_id = id AND project_id = ${sqlstring.escape(projectId)}) as has_replay`;
|
||||
sb.select.has_replay = `toBool(src.session_id != '') as has_replay`;
|
||||
sb.joins.has_replay = `LEFT JOIN (SELECT DISTINCT session_id FROM ${TABLE_NAMES.session_replay_chunks} WHERE project_id = ${sqlstring.escape(projectId)} AND started_at > now() - INTERVAL ${dateIntervalInDays} DAY) AS src ON src.session_id = id`;
|
||||
|
||||
const sql = getSql();
|
||||
const data = await chQuery<
|
||||
@@ -325,40 +327,42 @@ export async function getSessionsCount({
|
||||
|
||||
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
|
||||
|
||||
export async function getSessionReplayEvents(
|
||||
export interface ISessionReplayChunkMeta {
|
||||
chunk_index: number;
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
events_count: number;
|
||||
is_full_snapshot: boolean;
|
||||
}
|
||||
|
||||
const REPLAY_CHUNKS_PAGE_SIZE = 40;
|
||||
|
||||
export async function getSessionReplayChunksFrom(
|
||||
sessionId: string,
|
||||
projectId: string,
|
||||
): Promise<{ events: unknown[] }> {
|
||||
const chunks = await clix(ch)
|
||||
.select<{ chunk_index: number; payload: string }>([
|
||||
'chunk_index',
|
||||
'payload',
|
||||
])
|
||||
.from(TABLE_NAMES.session_replay_chunks)
|
||||
.where('session_id', '=', sessionId)
|
||||
.where('project_id', '=', projectId)
|
||||
.orderBy('chunk_index', 'ASC')
|
||||
.execute();
|
||||
|
||||
const allEvents = chunks.flatMap(
|
||||
(chunk) => JSON.parse(chunk.payload) as unknown[],
|
||||
fromIndex: number
|
||||
) {
|
||||
const rows = await chQuery<{ chunk_index: number; payload: string }>(
|
||||
`SELECT chunk_index, payload
|
||||
FROM ${TABLE_NAMES.session_replay_chunks}
|
||||
WHERE session_id = ${sqlstring.escape(sessionId)}
|
||||
AND project_id = ${sqlstring.escape(projectId)}
|
||||
ORDER BY started_at, ended_at
|
||||
LIMIT ${REPLAY_CHUNKS_PAGE_SIZE + 1}
|
||||
OFFSET ${fromIndex}`
|
||||
);
|
||||
|
||||
// rrweb event types: 2 = FullSnapshot, 4 = Meta
|
||||
// Incremental snapshots (type 3) before the first FullSnapshot are orphaned
|
||||
// and cause the player to fast-forward through empty time. Strip them but
|
||||
// keep Meta events (type 4) since rrweb needs them for viewport dimensions.
|
||||
const firstFullSnapshotIdx = allEvents.findIndex((e: any) => e.type === 2);
|
||||
|
||||
let events = allEvents;
|
||||
if (firstFullSnapshotIdx > 0) {
|
||||
const metaEvents = allEvents
|
||||
.slice(0, firstFullSnapshotIdx)
|
||||
.filter((e: any) => e.type === 4);
|
||||
events = [...metaEvents, ...allEvents.slice(firstFullSnapshotIdx)];
|
||||
}
|
||||
|
||||
return { events };
|
||||
return {
|
||||
data: rows.slice(0, REPLAY_CHUNKS_PAGE_SIZE).map((row, index) => ({
|
||||
chunkIndex: index + fromIndex,
|
||||
events: JSON.parse(row.payload) as {
|
||||
type: number;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}[],
|
||||
})),
|
||||
hasMore: rows.length > REPLAY_CHUNKS_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionService {
|
||||
|
||||
@@ -31,6 +31,11 @@ export function startReplayRecorder(
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any existing recorder before starting a new one to avoid leaks
|
||||
if (stopRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
|
||||
const maxEventsPerChunk = config.maxEventsPerChunk ?? 200;
|
||||
const flushIntervalMs = config.flushIntervalMs ?? 10_000;
|
||||
const maxPayloadBytes = config.maxPayloadBytes ?? 1_048_576; // 1MB
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import {
|
||||
getSessionList,
|
||||
getSessionReplayEvents,
|
||||
getSessionReplayChunksFrom,
|
||||
sessionService,
|
||||
} from '@openpanel/db';
|
||||
import { zChartEventFilter } from '@openpanel/validation';
|
||||
@@ -66,9 +66,15 @@ export const sessionRouter = createTRPCRouter({
|
||||
return sessionService.byId(sessionId, projectId);
|
||||
}),
|
||||
|
||||
replay: protectedProcedure
|
||||
.input(z.object({ sessionId: z.string(), projectId: z.string() }))
|
||||
.query(async ({ input: { sessionId, projectId } }) => {
|
||||
return getSessionReplayEvents(sessionId, projectId);
|
||||
replayChunksFrom: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
projectId: z.string(),
|
||||
fromIndex: z.number().int().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { sessionId, projectId, fromIndex } }) => {
|
||||
return getSessionReplayChunksFrom(sessionId, projectId, fromIndex);
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user