refactor session replays to be per visit. sessions persist too long resulting in low-quality recordings
This commit is contained in:
1 parent
b2d45613b5
commit
0e1d6cef6b
26 files changed
+248
-83
No files matched your search
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
|
||||
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback';
|
||||
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback';
|
||||
import { useNavigation } from '@/components/hooks';
|
||||
|
||||
export interface ReplayModalProps extends ModalProps {
|
||||
@@ -33,7 +33,7 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
|
||||
<Dialog variant="sheet">
|
||||
{({ close }) => (
|
||||
<Column padding="6">
|
||||
<ReplayPlayback websiteId={websiteId} sessionId={replay} onClose={close} />
|
||||
<ReplayPlayback websiteId={websiteId} replayId={replay} onClose={close} />
|
||||
</Column>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
@@ -22,8 +22,8 @@ export function ReplaysTable({ ...props }: DataTableProps) {
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="id" label={t(labels.session)} width="100px">
|
||||
{(row: any) => (
|
||||
<Link href={updateParams({ session: row.id })}>
|
||||
<Avatar seed={row.id} size={32} />
|
||||
<Link href={updateParams({ session: row.sessionId })}>
|
||||
<Avatar seed={row.sessionId} size={32} />
|
||||
</Link>
|
||||
)}
|
||||
</DataColumn>
|
||||
|
||||
+5
-5
@@ -9,15 +9,15 @@ import { ReplayPlayer } from './ReplayPlayer';
|
||||
|
||||
export function ReplayPlayback({
|
||||
websiteId,
|
||||
sessionId,
|
||||
replayId,
|
||||
onClose,
|
||||
}: {
|
||||
websiteId: string;
|
||||
sessionId: string;
|
||||
replayId: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { data: replay, isLoading, error } = useReplayQuery(websiteId, sessionId);
|
||||
const { data: session } = useWebsiteSessionQuery(websiteId, sessionId);
|
||||
const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId);
|
||||
const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId);
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
@@ -33,7 +33,7 @@ export function ReplayPlayback({
|
||||
{session && (
|
||||
<Row justifyContent="space-between" alignItems="flex-start">
|
||||
<Row alignItems="center" gap="4">
|
||||
<Avatar seed={sessionId} size={48} />
|
||||
<Avatar seed={replay.sessionId} size={48} />
|
||||
<Column>
|
||||
<Text weight="bold">{t(labels.replay)}</Text>
|
||||
<Text color="muted">
|
||||
File renamed without changes.
+3
-3
@@ -4,11 +4,11 @@ import { ReplayPlayback } from './ReplayPlayback';
|
||||
export default async function ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ websiteId: string; sessionId: string }>;
|
||||
params: Promise<{ websiteId: string; replayId: string }>;
|
||||
}) {
|
||||
const { websiteId, sessionId } = await params;
|
||||
const { websiteId, replayId } = await params;
|
||||
|
||||
return <ReplayPlayback websiteId={websiteId} sessionId={sessionId} />;
|
||||
return <ReplayPlayback websiteId={websiteId} replayId={replayId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { X } from 'lucide-react';
|
||||
import { ReplayPlayer } from '@/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks';
|
||||
import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
|
||||
import { SessionActivity } from './SessionActivity';
|
||||
import { SessionData } from './SessionData';
|
||||
import { SessionInfo } from './SessionInfo';
|
||||
import { SessionReplaysDataTable } from './SessionReplaysDataTable';
|
||||
import { SessionStats } from './SessionStats';
|
||||
|
||||
export function SessionProfile({
|
||||
@@ -29,7 +29,6 @@ export function SessionProfile({
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
|
||||
const { data: replay } = useReplayQuery(websiteId, sessionId);
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
@@ -65,7 +64,7 @@ export function SessionProfile({
|
||||
<TabList>
|
||||
<Tab id="activity">{t(labels.activity)}</Tab>
|
||||
<Tab id="properties">{t(labels.properties)}</Tab>
|
||||
{replay?.events?.length > 0 && <Tab id="replay">{t(labels.replay)}</Tab>}
|
||||
<Tab id="replays">{t(labels.replay)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="activity">
|
||||
<SessionActivity
|
||||
@@ -78,11 +77,9 @@ export function SessionProfile({
|
||||
<TabPanel id="properties">
|
||||
<SessionData sessionId={sessionId} websiteId={websiteId} />
|
||||
</TabPanel>
|
||||
{replay?.events?.length > 0 && (
|
||||
<TabPanel id="replay">
|
||||
<ReplayPlayer events={replay.events} />
|
||||
</TabPanel>
|
||||
)}
|
||||
<TabPanel id="replays">
|
||||
<SessionReplaysDataTable websiteId={websiteId} sessionId={sessionId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
</Column>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useReplayQuery, useSessionReplaysQuery } from '@/components/hooks';
|
||||
import { ReplayPlayer } from '../replays/[replayId]/ReplayPlayer';
|
||||
import { SessionReplaysTable } from './SessionReplaysTable';
|
||||
|
||||
function InlinePlayer({ websiteId, replayId }: { websiteId: string; replayId: string }) {
|
||||
const { data: replay } = useReplayQuery(websiteId, replayId);
|
||||
|
||||
if (!replay?.events?.length) return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem 0' }}>
|
||||
<ReplayPlayer events={replay.events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReplaysDataTable({
|
||||
websiteId,
|
||||
sessionId,
|
||||
}: {
|
||||
websiteId: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const queryResult = useSessionReplaysQuery(websiteId, sessionId);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
|
||||
const handlePlay = (id: string) => {
|
||||
setSelectedId(prev => (prev === id ? undefined : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Column>
|
||||
{selectedId && <InlinePlayer websiteId={websiteId} replayId={selectedId} />}
|
||||
<DataGrid query={queryResult} allowPaging>
|
||||
{({ data }) => (
|
||||
<SessionReplaysTable data={data} onPlay={handlePlay} selectedId={selectedId} />
|
||||
)}
|
||||
</DataGrid>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
|
||||
import { Play } from 'lucide-react';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function SessionReplaysTable({
|
||||
onPlay,
|
||||
selectedId,
|
||||
...props
|
||||
}: DataTableProps & { onPlay: (id: string) => void; selectedId?: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="id" label={t(labels.replayId)} />
|
||||
<DataColumn id="duration" label={t(labels.duration)} width="100px">
|
||||
{(row: any) => formatDuration(row.duration || 0)}
|
||||
</DataColumn>
|
||||
<DataColumn id="eventCount" label={t(labels.actions)} width="80px" />
|
||||
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="140px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="play" label="" width="80px">
|
||||
{(row: any) => (
|
||||
<Button
|
||||
variant={row.id === selectedId ? 'primary' : 'quiet'}
|
||||
onClick={() => onPlay(row.id)}
|
||||
>
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export async function POST(request: Request) {
|
||||
return badRequest({ message: 'Invalid session token.' });
|
||||
}
|
||||
|
||||
const { sessionId } = cache;
|
||||
const { sessionId, visitId } = cache;
|
||||
|
||||
// Query directly to avoid stale Redis cache for recordingEnabled
|
||||
const website = await getWebsite(websiteId);
|
||||
@@ -80,6 +80,7 @@ export async function POST(request: Request) {
|
||||
await saveRecording({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
chunkIndex,
|
||||
events,
|
||||
eventCount: events.length,
|
||||
|
||||
+6
-5
@@ -1,4 +1,3 @@
|
||||
import { stitchChunkEvents } from '@/lib/replay';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
@@ -6,7 +5,7 @@ import { getReplayChunks } from '@/queries/sql';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
|
||||
{ params }: { params: Promise<{ websiteId: string; replayId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
@@ -14,19 +13,21 @@ export async function GET(
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, sessionId } = await params;
|
||||
const { websiteId, replayId } = await params;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const chunks = await getReplayChunks(websiteId, sessionId);
|
||||
const chunks = await getReplayChunks(websiteId, replayId);
|
||||
|
||||
const allEvents = stitchChunkEvents(chunks);
|
||||
const allEvents = chunks.flatMap(chunk => chunk.events);
|
||||
const sessionId = chunks.length > 0 ? chunks[0].sessionId : null;
|
||||
const startedAt = chunks.length > 0 ? chunks[0].startedAt : null;
|
||||
const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null;
|
||||
|
||||
return json({
|
||||
sessionId,
|
||||
events: allEvents,
|
||||
startedAt,
|
||||
endedAt,
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams, withDateRange } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getSessionReplays } from '@/queries/sql';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
|
||||
) {
|
||||
const schema = withDateRange({
|
||||
...pagingParams,
|
||||
...searchParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, sessionId } = await params;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getSessionReplays(websiteId, filters, sessionId);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
Reference in new issue
Block a user