diff --git a/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx b/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx new file mode 100644 index 0000000000..262d29782e --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx @@ -0,0 +1,65 @@ +import { Prisma } from "@/generated/prisma/client"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { + aggregateSessionReplayChunksByReplayIds, + querySessionReplayAdminRows, + sessionReplayAdminRowToApiItem, +} from "../session-replay-admin-rows"; + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + session_replay_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + id: yupString().defined(), + project_user: yupObject({ + id: yupString().defined(), + display_name: yupString().nullable().defined(), + primary_email: yupString().nullable().defined(), + }).defined(), + started_at_millis: yupNumber().defined(), + last_event_at_millis: yupNumber().defined(), + chunk_count: yupNumber().defined(), + event_count: yupNumber().defined(), + }).defined(), + }), + async handler({ auth, params }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const schema = await getPrismaSchemaForTenancy(auth.tenancy); + const sessionReplayId = params.session_replay_id; + + const rows = await querySessionReplayAdminRows({ + prisma, + schema, + tenancyId: auth.tenancy.id, + suffixSql: Prisma.sql`AND sr."id" = ${sessionReplayId} LIMIT 1`, + }); + + const row = rows.at(0); + if (row == null) { + throw new KnownErrors.ItemNotFound(sessionReplayId); + } + + const aggById = await aggregateSessionReplayChunksByReplayIds(prisma, auth.tenancy.id, [sessionReplayId]); + const agg = aggById.get(sessionReplayId) ?? { chunkCount: 0, eventCount: 0 }; + + return { + statusCode: 200, + bodyType: "json", + body: sessionReplayAdminRowToApiItem(row, agg), + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/session-replays/route.tsx b/apps/backend/src/app/api/latest/internal/session-replays/route.tsx index e3c3d3b9b9..242733d1b7 100644 --- a/apps/backend/src/app/api/latest/internal/session-replays/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-replays/route.tsx @@ -1,6 +1,11 @@ -import { getClickhouseExternalClient } from "@/lib/clickhouse"; import { Prisma } from "@/generated/prisma/client"; +import { getClickhouseExternalClient } from "@/lib/clickhouse"; import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; +import { + aggregateSessionReplayChunksByReplayIds, + querySessionReplayAdminRows, + sessionReplayAdminRowToApiItem, +} from "./session-replay-admin-rows"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -171,36 +176,7 @@ export const GET = createSmartRouteHandler({ } } - type ReplayRow = { - id: string, - projectUserId: string, - startedAt: Date, - lastEventAt: Date, - projectUserDisplayName: string | null, - primaryEmail: string | null, - }; - - const rows = await prisma.$queryRaw` - SELECT - sr."id", - sr."projectUserId", - sr."startedAt", - sr."lastEventAt", - pu."displayName" AS "projectUserDisplayName", - ( - SELECT cc."value" - FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc - WHERE cc."projectUserId" = sr."projectUserId" - AND cc."tenancyId" = sr."tenancyId" - AND cc."type" = 'EMAIL' - AND cc."isPrimary" = 'TRUE'::"BooleanTrue" - LIMIT 1 - ) AS "primaryEmail" - FROM ${sqlQuoteIdent(schema)}."SessionReplay" sr - JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu - ON pu."projectUserId" = sr."projectUserId" - AND pu."tenancyId" = sr."tenancyId" - WHERE sr."tenancyId" = ${auth.tenancy.id}::UUID + const suffixSql = Prisma.sql` ${userIdsFilter.length > 0 ? Prisma.sql`AND sr."projectUserId" IN (${Prisma.join(userIdsFilter)})` : Prisma.empty} ${lastEventAtFrom ? Prisma.sql`AND sr."lastEventAt" >= ${lastEventAtFrom}` : Prisma.empty} ${lastEventAtTo ? Prisma.sql`AND sr."lastEventAt" <= ${lastEventAtTo}` : Prisma.empty} @@ -221,27 +197,19 @@ export const GET = createSmartRouteHandler({ LIMIT ${limit + 1} `; + const rows = await querySessionReplayAdminRows({ + prisma, + schema, + tenancyId: auth.tenancy.id, + suffixSql, + }); + const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? page[page.length - 1]!.id : null; const sessionIds = page.map((row) => row.id); - const chunkAggs = sessionIds.length - ? await prisma.sessionReplayChunk.groupBy({ - by: ["sessionReplayId"], - where: { tenancyId: auth.tenancy.id, sessionReplayId: { in: sessionIds } }, - _count: { _all: true }, - _sum: { eventCount: true }, - }) - : []; - - const aggBySessionId = new Map(); - for (const a of chunkAggs) { - aggBySessionId.set(a.sessionReplayId, { - chunkCount: a._count._all, - eventCount: a._sum.eventCount ?? 0, - }); - } + const aggBySessionId = await aggregateSessionReplayChunksByReplayIds(prisma, auth.tenancy.id, sessionIds); return { statusCode: 200, @@ -249,18 +217,7 @@ export const GET = createSmartRouteHandler({ body: { items: page.map((row) => { const agg = aggBySessionId.get(row.id) ?? { chunkCount: 0, eventCount: 0 }; - return { - id: row.id, - project_user: { - id: row.projectUserId, - display_name: row.projectUserDisplayName ?? null, - primary_email: row.primaryEmail ?? null, - }, - started_at_millis: row.startedAt.getTime(), - last_event_at_millis: row.lastEventAt.getTime(), - chunk_count: agg.chunkCount, - event_count: agg.eventCount, - }; + return sessionReplayAdminRowToApiItem(row, agg); }), pagination: { next_cursor: nextCursor }, }, diff --git a/apps/backend/src/app/api/latest/internal/session-replays/session-replay-admin-rows.ts b/apps/backend/src/app/api/latest/internal/session-replays/session-replay-admin-rows.ts new file mode 100644 index 0000000000..fef7cc6823 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-replays/session-replay-admin-rows.ts @@ -0,0 +1,92 @@ +import { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { type PrismaClientWithReplica, sqlQuoteIdent } from "@/prisma-client"; + +/** Row shape from the admin session replay list / get SQL (SessionReplay + ProjectUser + primary email). */ +export type SessionReplayAdminListRow = { + id: string, + projectUserId: string, + startedAt: Date, + lastEventAt: Date, + projectUserDisplayName: string | null, + primaryEmail: string | null, +}; + +export type SessionReplayChunkAgg = { chunkCount: number, eventCount: number }; + +/** + * Base query used by the internal session replay list and single-replay routes. + * `suffixSql` is everything after `WHERE sr."tenancyId" = …` (filters, ORDER BY, LIMIT). + */ +export async function querySessionReplayAdminRows(options: { + prisma: PrismaClientWithReplica, + schema: string, + tenancyId: string, + suffixSql: Prisma.Sql, +}): Promise { + const { prisma, schema, tenancyId, suffixSql } = options; + return await prisma.$queryRaw` + SELECT + sr."id", + sr."projectUserId", + sr."startedAt", + sr."lastEventAt", + pu."displayName" AS "projectUserDisplayName", + ( + SELECT cc."value" + FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc + WHERE cc."projectUserId" = sr."projectUserId" + AND cc."tenancyId" = sr."tenancyId" + AND cc."type" = 'EMAIL' + AND cc."isPrimary" = 'TRUE'::"BooleanTrue" + LIMIT 1 + ) AS "primaryEmail" + FROM ${sqlQuoteIdent(schema)}."SessionReplay" sr + JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu + ON pu."projectUserId" = sr."projectUserId" + AND pu."tenancyId" = sr."tenancyId" + WHERE sr."tenancyId" = ${tenancyId}::UUID + ${suffixSql} + `; +} + +export async function aggregateSessionReplayChunksByReplayIds( + prisma: PrismaClientWithReplica, + tenancyId: string, + sessionReplayIds: string[], +): Promise> { + if (sessionReplayIds.length === 0) { + return new Map(); + } + const chunkAggs = await prisma.sessionReplayChunk.groupBy({ + by: ["sessionReplayId"], + where: { tenancyId, sessionReplayId: { in: sessionReplayIds } }, + _count: { _all: true }, + _sum: { eventCount: true }, + }); + const map = new Map(); + for (const a of chunkAggs) { + map.set(a.sessionReplayId, { + chunkCount: a._count._all, + eventCount: a._sum.eventCount ?? 0, + }); + } + return map; +} + +export function sessionReplayAdminRowToApiItem( + row: SessionReplayAdminListRow, + agg: SessionReplayChunkAgg, +) { + return { + id: row.id, + project_user: { + id: row.projectUserId, + display_name: row.projectUserDisplayName ?? null, + primary_email: row.primaryEmail ?? null, + }, + started_at_millis: row.startedAt.getTime(), + last_event_at_millis: row.lastEventAt.getTime(), + chunk_count: agg.chunkCount, + event_count: agg.eventCount, + }; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx new file mode 100644 index 0000000000..56eca8af2d --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx @@ -0,0 +1,10 @@ +import PageClient from "../page-client"; + +export default async function Page(props: { + params: Promise<{ + replayId: string, + }>, +}) { + const params = await props.params; + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 2790241b6c..0005b09ae1 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -1,8 +1,10 @@ "use client"; +import { TeamSearchTable } from "@/components/data-table/team-search-table"; +import { UserSearchPicker } from "@/components/data-table/user-search-picker"; +import { StyledLink } from "@/components/link"; import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Skeleton, Switch, Typography } from "@/components/ui"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { StyledLink } from "@/components/link"; import { useFromNow } from "@/hooks/use-from-now"; import { getDesiredGlobalOffsetFromPlaybackState, @@ -16,26 +18,24 @@ import { NULL_TAB_KEY, } from "@/lib/session-replay-streams"; import { cn } from "@/lib/utils"; -import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { ArrowLeftIcon, ArrowsClockwiseIcon, CheckIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, LinkIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { UserSearchPicker } from "@/components/data-table/user-search-picker"; -import { TeamSearchTable } from "@/components/data-table/team-search-table"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; import { + ALLOWED_PLAYER_SPEEDS, createInitialState, replayReducer, - ALLOWED_PLAYER_SPEEDS, - type ReplaySettings, - type ReplayState, + type ChunkRange, type ReplayAction, type ReplayEffect, + type ReplaySettings, + type ReplayState, type StreamInfo, - type ChunkRange, } from "./session-replay-machine"; const PAGE_SIZE = 50; @@ -87,6 +87,7 @@ type AdminAppWithSessionReplays = ReturnType & { items: RecordingRow[], nextCursor: string | null, }>, + getSessionReplay: (sessionReplayId: string) => Promise, getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise<{ chunks: ChunkRow[], chunkEvents: Array<{ chunkId: string, events: unknown[] }>, @@ -460,8 +461,13 @@ function useReplayMachine(initialSettings: ReplaySettings) { // Main component // --------------------------------------------------------------------------- -export default function PageClient() { +type PageClientProps = { + initialReplayId?: string, +}; + +export default function PageClient({ initialReplayId }: PageClientProps) { const adminApp = useAdminApp() as AdminAppWithSessionReplays; + const isStandaloneReplayPage = initialReplayId != null; // ---- Recording list + filters ---- @@ -475,18 +481,38 @@ export default function PageClient() { const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); const [clickCountsByReplayId, setClickCountsByReplayId] = useState>(new Map()); const [timelineEvents, setTimelineEvents] = useState([]); + const [standaloneReplay, setStandaloneReplay] = useState(null); + const [standaloneReplayError, setStandaloneReplayError] = useState(null); const listBoxRef = useRef(null); - const [selectedRecordingId, setSelectedRecordingId] = useState(null); + const [selectedRecordingId, setSelectedRecordingId] = useState(initialReplayId ?? null); + const [replayShareLinkCopied, setReplayShareLinkCopied] = useState(false); const selectedRecording = useMemo( - () => recordings.find(r => r.id === selectedRecordingId) ?? null, - [recordings, selectedRecordingId], + () => recordings.find(r => r.id === selectedRecordingId) + ?? (standaloneReplay?.id === selectedRecordingId ? standaloneReplay : null), + [recordings, selectedRecordingId, standaloneReplay], ); const hasAutoSelectedRef = useRef(false); const loadingMoreRef = useRef(false); + useEffect(() => { + if (!isStandaloneReplayPage) return; + if (selectedRecordingId === initialReplayId && (standaloneReplay == null || standaloneReplay.id === initialReplayId)) return; + setSelectedRecordingId(initialReplayId); + }, [initialReplayId, isStandaloneReplayPage, selectedRecordingId, standaloneReplay, setSelectedRecordingId]); + + useEffect(() => { + setReplayShareLinkCopied(false); + }, [selectedRecordingId]); + + useEffect(() => { + if (!replayShareLinkCopied) return; + const timer = setTimeout(() => setReplayShareLinkCopied(false), 2000); + return () => clearTimeout(timer); + }, [replayShareLinkCopied]); + const loadPage = useCallback(async (cursor: string | null) => { if (cursor !== null && loadingMoreRef.current) return; @@ -533,13 +559,18 @@ export default function PageClient() { }, [adminApp, appliedFilters]); useEffect(() => { + if (isStandaloneReplayPage) { + setLoadingInitial(false); + return; + } setRecordings([]); setNextCursor(null); hasAutoSelectedRef.current = false; runAsynchronously(() => loadPage(null), { noErrorLogging: true }); - }, [loadPage]); + }, [isStandaloneReplayPage, loadPage]); useEffect(() => { + if (isStandaloneReplayPage) return; if (recordings.length === 0) return; const ids = recordings.map(r => r.id); runAsynchronously(async () => { @@ -559,7 +590,27 @@ export default function PageClient() { } setClickCountsByReplayId(map); }, { noErrorLogging: true }); - }, [recordings, adminApp]); + }, [isStandaloneReplayPage, recordings, adminApp]); + + useEffect(() => { + if (initialReplayId == null) return; + let cancelled = false; + setStandaloneReplay(null); + setStandaloneReplayError(null); + runAsynchronously(async () => { + try { + const replay = await adminApp.getSessionReplay(initialReplayId); + if (cancelled) return; + setStandaloneReplay(replay); + } catch (e: any) { + if (cancelled) return; + setStandaloneReplayError(e?.message ?? "Failed to load session replay."); + } + }, { noErrorLogging: true }); + return () => { + cancelled = true; + }; + }, [adminApp, initialReplayId, isStandaloneReplayPage]); const onListScroll = useCallback(() => { const el = listBoxRef.current; @@ -1133,9 +1184,10 @@ export default function PageClient() { }, [adminApp, msRef]); useEffect(() => { - if (!selectedRecordingId || !selectedRecording) return; + if (!selectedRecordingId) return; + if (!isStandaloneReplayPage && !selectedRecording) return; runAsynchronously(() => loadChunksAndDownload(selectedRecordingId), { noErrorLogging: true }); - }, [loadChunksAndDownload, selectedRecordingId, selectedRecording]); + }, [isStandaloneReplayPage, loadChunksAndDownload, selectedRecordingId, selectedRecording]); useEffect(() => { if (!selectedRecordingId) { @@ -1371,371 +1423,395 @@ export default function PageClient() { }, [draftFilters]); useEffect(() => { + if (isStandaloneReplayPage) return; if (recordings.length === 0) { setSelectedRecordingId(null); return; } if (selectedRecordingId && recordings.some((r) => r.id === selectedRecordingId)) return; setSelectedRecordingId(recordings[0]?.id ?? null); - }, [recordings, selectedRecordingId]); + }, [isStandaloneReplayPage, recordings, selectedRecordingId]); // ---- Rendering ---- return ( - + + + Back to all replays + + ) : undefined} + fillWidth + > - -
-
-
- - Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""} - - - - + + e.preventDefault()} + > + { requestAnimationFrame(() => openFilterDialog("user")); }}> + User + + { requestAnimationFrame(() => openFilterDialog("team")); }}> + Team + + { requestAnimationFrame(() => openFilterDialog("duration")); }}> + Duration + + { requestAnimationFrame(() => openFilterDialog("lastActive")); }}> + Last active + + { requestAnimationFrame(() => openFilterDialog("clicks")); }}> + Click count + + + +
+ + {activeFilterCount > 0 && ( +
+ {appliedFilters.userId && ( + + user:{appliedFilters.userLabel || "selected"} )} - - - e.preventDefault()} - > - { requestAnimationFrame(() => openFilterDialog("user")); }}> - User - - { requestAnimationFrame(() => openFilterDialog("team")); }}> - Team - - { requestAnimationFrame(() => openFilterDialog("duration")); }}> - Duration - - { requestAnimationFrame(() => openFilterDialog("lastActive")); }}> - Last active - - { requestAnimationFrame(() => openFilterDialog("clicks")); }}> - Click count - - - -
- - {activeFilterCount > 0 && ( -
- {appliedFilters.userId && ( - - user:{appliedFilters.userLabel || "selected"} - - )} - {appliedFilters.teamId && ( - - team:{appliedFilters.teamLabel || "selected"} - - )} - {(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && ( - - duration - - )} - {appliedFilters.lastActivePreset && ( - - last active: {appliedFilters.lastActivePreset} - - )} - {appliedFilters.clickCountMin && ( - - clicks - + {appliedFilters.teamId && ( + + team:{appliedFilters.teamLabel || "selected"} + + )} + {(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && ( + + duration + + )} + {appliedFilters.lastActivePreset && ( + + last active: {appliedFilters.lastActivePreset} + + )} + {appliedFilters.clickCountMin && ( + + clicks + + )} + +
)} -
- )} -
- setActiveFilterDialog(open ? "user" : null)}> - - - User Filter - -
- ( - - )} - /> -
- + )} + /> +
+ -
-
- -
- - setActiveFilterDialog(open ? "team" : null)}> - - - Team Filter - -
- ( - +
+ +
+
+ + setActiveFilterDialog(open ? "team" : null)}> + + + Team Filter + +
+ ( + - )} - /> -
- + )} + /> +
+ -
-
- -
- - setActiveFilterDialog(open ? "duration" : null)}> - - - Duration Filter - -
{ + }} + > + Clear + + + + +
+ + setActiveFilterDialog(open ? "duration" : null)}> + + + Duration Filter + + { e.preventDefault(); applyDraftFilters(); - }}> -
- - -
-
- - -
- -
-
- - setActiveFilterDialog(open ? "lastActive" : null)}> - - - Last Active Filter - -
- {([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => ( - + +
+ +
+
+ + setActiveFilterDialog(open ? "lastActive" : null)}> + + + Last Active Filter + +
+ {([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => ( + - ))} -
-
- + ))} +
+
+ -
-
-
- - setActiveFilterDialog(open ? "clicks" : null)}> - - - Click Count Filter - -
{ + }} + > + Clear + + + +
+ + setActiveFilterDialog(open ? "clicks" : null)}> + + + Click Count Filter + + { e.preventDefault(); applyDraftFilters(); - }}> -
- setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))} - placeholder="Minimum click count" - /> -
-
- - -
- -
-
+ }}> +
+ setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))} + placeholder="Minimum click count" + /> +
+
+ + +
+ + + - {listError && ( -
- {listError} -
- )} + {listError && ( +
+ {listError} +
+ )} -
- {loadingInitial ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - -
- ))} -
- ) : recordings.length === 0 ? ( -
- - {activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."} - -
- ) : ( -
- {recordings.map((r) => { - const isSelected = r.id === selectedRecordingId; - const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime(); - const duration = formatDurationMs(durationMs); - return ( - +
+ ); + })} + + {loadingMore && ( +
+ +
- - ); - })} - - {loadingMore && ( -
- - + )}
)}
- )} - - -
+ + - + + + )} - +
- {(ms.downloadError || ms.playerError) && ( + {(standaloneReplayError || ms.downloadError || ms.playerError) && (
+ {standaloneReplayError && {standaloneReplayError}} {ms.downloadError && {ms.downloadError}} {ms.playerError && {ms.playerError}}
@@ -1749,16 +1825,43 @@ export default function PageClient() { > {getRecordingTitle(selectedRecording)} + ) : isStandaloneReplayPage && selectedRecordingId ? ( + + Replay {selectedRecordingId} + ) : ( )} - actRef.current({ type: "UPDATE_SETTINGS", updates })} - /> +
+ {selectedRecordingId && ( + + )} + actRef.current({ type: "UPDATE_SETTINGS", updates })} + /> +
- {selectedRecording ? ( + {selectedRecordingId ? (
{ + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const upload = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_400, + events: [ + { type: 2, timestamp: 1_700_000_000_100 }, + { type: 3, timestamp: 1_700_000_000_250 }, + ], + }); + expect(upload.status).toBe(200); + const recordingId = upload.body?.session_replay_id; + expect(typeof recordingId).toBe("string"); + if (typeof recordingId !== "string") { + throw new Error("Expected session replay id."); + } + + const res = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}`, { + method: "GET", + accessType: "admin", + }); + + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "chunk_count": 1, + "event_count": 2, + "id": "", + "last_event_at_millis": 1700000000250, + "project_user": { + "display_name": null, + "id": "", + "primary_email": "default-mailbox--@stack-generated.example.com", + }, + "started_at_millis": 1700000000100, + }, + "headers": Headers {