feat: session replay
* wip * wip * wip * wip * final fixes * comments * fix
This commit is contained in:
committed by
GitHub
parent
38d9b65ec8
commit
aa81bbfe77
@@ -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',
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
242
apps/start/src/components/sessions/replay/index.tsx
Normal file
242
apps/start/src/components/sessions/replay/index.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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 { ReplayTimeline } from './replay-timeline';
|
||||
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 { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
|
||||
const { startTime } = useReplayContext();
|
||||
const currentTime = useCurrentTime(250);
|
||||
|
||||
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.at(-1);
|
||||
if (!latest) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { origin = '', path = '/' } = latest.event;
|
||||
return `${origin}${path}`;
|
||||
}, [events, currentTime, startTime]);
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// chunk loading failed — replay may be incomplete
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
sessionId,
|
||||
projectId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
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 playerEvents =
|
||||
firstBatch?.data.flatMap((row) => row?.events ?? []) ?? [];
|
||||
const hasMore = firstBatch?.hasMore ?? false;
|
||||
const hasReplay = playerEvents.length !== 0;
|
||||
|
||||
function renderReplay() {
|
||||
if (replayLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
if (hasReplay) {
|
||||
return <ReplayPlayer events={playerEvents} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-[320px] items-center justify-center bg-background text-muted-foreground text-sm">
|
||||
No replay data available for this session.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReplayProvider>
|
||||
<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
|
||||
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>
|
||||
)
|
||||
}
|
||||
>
|
||||
{renderReplay()}
|
||||
{hasReplay && <ReplayTimeline events={events} />}
|
||||
</BrowserChrome>
|
||||
</div>
|
||||
<div className="relative hidden lg:block">
|
||||
<div className="absolute inset-0">
|
||||
<ReplayEventFeed events={events} replayLoading={replayLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasReplay && hasMore && (
|
||||
<ReplayChunkLoader
|
||||
fromIndex={firstBatch?.data?.length ?? 0}
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
</ReplayProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayShell({
|
||||
sessionId,
|
||||
projectId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return <ReplayContent projectId={projectId} sessionId={sessionId} />;
|
||||
}
|
||||
205
apps/start/src/components/sessions/replay/replay-context.tsx
Normal file
205
apps/start/src/components/sessions/replay/replay-context.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
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 };
|
||||
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 {
|
||||
// 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;
|
||||
duration: number;
|
||||
startTime: number | null;
|
||||
isReady: boolean;
|
||||
// Playback controls
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
seek: (timeMs: number) => void;
|
||||
setSpeed: (speed: number) => 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 isPlayingRef = useRef(false);
|
||||
const currentTimeRef = useRef(0);
|
||||
const listenersRef = useRef<Set<CurrentTimeListener>>(new Set());
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
const [isReady, setIsReady] = useState(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 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);
|
||||
currentTimeRef.current = 0;
|
||||
setDuration(0);
|
||||
setStartTime(null);
|
||||
setIsPlayingWithRef(false);
|
||||
}, [setIsPlayingWithRef]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
playerRef.current?.play();
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
playerRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
playerRef.current?.toggle();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const addEvent = useCallback(
|
||||
(event: { type: number; data: unknown; timestamp: number }) => {
|
||||
playerRef.current?.addEvent(event);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshDuration = useCallback(() => {
|
||||
const total = playerRef.current?.getMetaData().totalTime ?? 0;
|
||||
if (total > 0) setDuration(total);
|
||||
}, []);
|
||||
|
||||
const value: ReplayContextValue = {
|
||||
currentTimeRef,
|
||||
subscribeToCurrentTime,
|
||||
isPlaying,
|
||||
duration,
|
||||
startTime,
|
||||
isReady,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seek,
|
||||
setSpeed,
|
||||
addEvent,
|
||||
refreshDuration,
|
||||
onPlayerReady,
|
||||
onPlayerDestroy,
|
||||
setCurrentTime,
|
||||
setIsPlaying: setIsPlayingWithRef,
|
||||
setDuration,
|
||||
};
|
||||
|
||||
return (
|
||||
<ReplayContext.Provider value={value}>{children}</ReplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { SPEED_OPTIONS };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import { formatDuration } from './replay-utils';
|
||||
|
||||
export function ReplayTime() {
|
||||
const { duration } = useReplayContext();
|
||||
const currentTime = useCurrentTime(250);
|
||||
|
||||
return (
|
||||
<span className="text-sm tabular-nums text-muted-foreground font-mono">
|
||||
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayPlayPauseButton() {
|
||||
const { isPlaying, isReady, toggle } = 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>
|
||||
);
|
||||
}
|
||||
107
apps/start/src/components/sessions/replay/replay-event-feed.tsx
Normal file
107
apps/start/src/components/sessions/replay/replay-event-feed.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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';
|
||||
|
||||
type EventWithOffset = { event: IServiceEvent; offsetMs: number };
|
||||
|
||||
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);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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 visibleEvents = sortedEvents.slice(0, visibleCount);
|
||||
const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null;
|
||||
|
||||
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]);
|
||||
|
||||
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 w-full flex-col">
|
||||
{visibleEvents.map(({ event, offsetMs }) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-3 min-w-0 duration-300 fill-mode-both"
|
||||
>
|
||||
<ReplayEventItem
|
||||
event={event}
|
||||
isCurrent={event.id === currentEventId}
|
||||
onClick={() => seek(Math.max(0, offsetMs))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
function formatTime(date: Date | string): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
const s = d.getSeconds().toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
export function ReplayEventItem({
|
||||
event,
|
||||
isCurrent,
|
||||
onClick,
|
||||
}: {
|
||||
event: IServiceEvent;
|
||||
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',
|
||||
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">
|
||||
{formatTime(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
184
apps/start/src/components/sessions/replay/replay-player.tsx
Normal file
184
apps/start/src/components/sessions/replay/replay-player.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useReplayContext } from '@/components/sessions/replay/replay-context';
|
||||
import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context';
|
||||
import { useEffect, useMemo, useRef, useState } 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;
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
events: Array<{ type: number; data: unknown; timestamp: number }>;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<ReplayPlayerInstance | null>(null);
|
||||
const {
|
||||
onPlayerReady,
|
||||
onPlayerDestroy,
|
||||
setCurrentTime,
|
||||
setIsPlaying,
|
||||
setDuration,
|
||||
} = useReplayContext();
|
||||
const [importError, setImportError] = useState(false);
|
||||
|
||||
const recordedDimensions = useMemo(
|
||||
() => getRecordedDimensions(events),
|
||||
[events],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
const aspectRatio = recordedDimensions
|
||||
? recordedDimensions.width / recordedDimensions.height
|
||||
: 16 / 9;
|
||||
|
||||
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;
|
||||
|
||||
// Track play state from replayer (getMetaData() does not expose isPlaying)
|
||||
let playingState = false;
|
||||
|
||||
// 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) => {
|
||||
const playing = e.payload === 'playing';
|
||||
playingState = playing;
|
||||
setIsPlaying(playing);
|
||||
});
|
||||
|
||||
// Pause on tab hide; resume on show (prevents timer drift).
|
||||
// getMetaData() does not expose isPlaying, so we use playingState
|
||||
// kept in sync by ui-update-player-state above.
|
||||
let wasPlaying = false;
|
||||
handleVisibilityChange = () => {
|
||||
if (!player) return;
|
||||
if (document.hidden) {
|
||||
wasPlaying = playingState;
|
||||
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,
|
||||
);
|
||||
playerRef.current.$set({ width: w, height: h });
|
||||
};
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
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, 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">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
style={{ maxHeight: '70vh' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal file
261
apps/start/src/components/sessions/replay/replay-timeline.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useCurrentTime, 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, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ReplayPlayPauseButton } from './replay-controls';
|
||||
import { formatDuration, getEventOffsetMs } from './replay-utils';
|
||||
|
||||
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
|
||||
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) => {
|
||||
if (!trackRef.current || duration <= 0) return null;
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
if (rect.width <= 0 || !Number.isFinite(rect.width)) {
|
||||
return null;
|
||||
}
|
||||
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 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;
|
||||
const info = getTimeFromClientX(e.clientX);
|
||||
if (info) seek(info.timeMs);
|
||||
},
|
||||
[getTimeFromClientX, seek],
|
||||
);
|
||||
|
||||
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 = Math.max(0, Math.min(100, (currentTimeRef.current / duration) * 100));
|
||||
|
||||
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 outline-0"
|
||||
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
|
||||
ref={progressBarRef}
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
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)` }}
|
||||
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 }}
|
||||
>
|
||||
{formatDuration(hoverInfo.timeMs)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{groupedEvents.map((group) => {
|
||||
const first = group.items[0]!;
|
||||
const isGroup = group.items.length > 1;
|
||||
return (
|
||||
<Tooltip key={first.event.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-timeline-event
|
||||
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(first.offsetMs);
|
||||
}}
|
||||
aria-label={isGroup ? `${group.items.length} events at ${formatDuration(first.offsetMs)}` : `${first.event.name} at ${formatDuration(first.offsetMs)}`}
|
||||
>
|
||||
<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-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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
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')}`;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
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 { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { round } from '@openpanel/common';
|
||||
import type { IServiceSession } from '@openpanel/db';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Video } from 'lucide-react';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
|
||||
function formatDuration(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1000;
|
||||
@@ -44,13 +43,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 items-center gap-2">
|
||||
<ProjectLink
|
||||
className="font-medium"
|
||||
href={`/sessions/${session.id}`}
|
||||
title={session.id}
|
||||
>
|
||||
{session.id.slice(0, 8)}...
|
||||
</ProjectLink>
|
||||
{session.hasReplay && (
|
||||
<ProjectLink
|
||||
aria-label="View replay"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href={`/sessions/${session.id}#replay`}
|
||||
title="View replay"
|
||||
>
|
||||
<Video className="size-4" />
|
||||
</ProjectLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -63,8 +74,8 @@ export function useColumns() {
|
||||
if (session.profile) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="row items-center gap-2 font-medium"
|
||||
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
|
||||
className="font-medium row gap-2 items-center"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...session.profile} />
|
||||
{getProfileName(session.profile)}
|
||||
@@ -73,8 +84,8 @@ export function useColumns() {
|
||||
}
|
||||
return (
|
||||
<ProjectLink
|
||||
className="font-medium font-mono"
|
||||
href={`/profiles/${encodeURIComponent(session.profileId)}`}
|
||||
className="font-mono font-medium"
|
||||
>
|
||||
{session.profileId}
|
||||
</ProjectLink>
|
||||
|
||||
@@ -48,7 +48,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user