mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into promptless/document-requires-totp-mfa-jwt-claim
This commit is contained in:
commit
79d16f10c6
@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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<ReplayRow[]>`
|
||||
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<string, { chunkCount: number, eventCount: number }>();
|
||||
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 },
|
||||
},
|
||||
|
||||
@ -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<PrismaClient>,
|
||||
schema: string,
|
||||
tenancyId: string,
|
||||
suffixSql: Prisma.Sql,
|
||||
}): Promise<SessionReplayAdminListRow[]> {
|
||||
const { prisma, schema, tenancyId, suffixSql } = options;
|
||||
return await prisma.$queryRaw<SessionReplayAdminListRow[]>`
|
||||
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<PrismaClient>,
|
||||
tenancyId: string,
|
||||
sessionReplayIds: string[],
|
||||
): Promise<Map<string, SessionReplayChunkAgg>> {
|
||||
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<string, SessionReplayChunkAgg>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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 <PageClient initialReplayId={params.replayId} />;
|
||||
}
|
||||
@ -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<typeof useAdminApp> & {
|
||||
items: RecordingRow[],
|
||||
nextCursor: string | null,
|
||||
}>,
|
||||
getSessionReplay: (sessionReplayId: string) => Promise<RecordingRow>,
|
||||
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<ReplayFilters>(EMPTY_FILTERS);
|
||||
const [clickCountsByReplayId, setClickCountsByReplayId] = useState<Map<string, number>>(new Map());
|
||||
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
|
||||
const [standaloneReplay, setStandaloneReplay] = useState<RecordingRow | null>(null);
|
||||
const [standaloneReplayError, setStandaloneReplayError] = useState<string | null>(null);
|
||||
|
||||
const listBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(null);
|
||||
const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(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 (
|
||||
<AppEnabledGuard appId="analytics">
|
||||
<PageLayout title="Session Replays" fillWidth>
|
||||
<PageLayout
|
||||
title={isStandaloneReplayPage ? "Session Replay" : "Session Replays"}
|
||||
description={isStandaloneReplayPage ? (
|
||||
<StyledLink
|
||||
href={`/projects/${encodeURIComponent(adminApp.projectId)}/analytics/replays`}
|
||||
className="inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
Back to all replays
|
||||
</StyledLink>
|
||||
) : undefined}
|
||||
fillWidth
|
||||
>
|
||||
<PanelGroup data-walkthrough="analytics-replays" direction="horizontal" className="!h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
|
||||
<Panel defaultSize={25} minSize={16}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-border/30 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 h-8">
|
||||
<Typography className="text-sm font-medium">
|
||||
Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""}
|
||||
</Typography>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
>
|
||||
<FunnelSimpleIcon className="h-3.5 w-3.5 mr-1" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="ml-1 rounded-full bg-foreground/10 px-1.5 py-0 text-[10px]">
|
||||
{activeFilterCount}
|
||||
{!isStandaloneReplayPage && (
|
||||
<>
|
||||
<Panel defaultSize={25} minSize={16}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-border/30 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 h-8">
|
||||
<Typography className="text-sm font-medium">
|
||||
Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""}
|
||||
</Typography>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
>
|
||||
<FunnelSimpleIcon className="h-3.5 w-3.5 mr-1" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="ml-1 rounded-full bg-foreground/10 px-1.5 py-0 text-[10px]">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("user")); }}>
|
||||
User
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("team")); }}>
|
||||
Team
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("duration")); }}>
|
||||
Duration
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("lastActive")); }}>
|
||||
Last active
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("clicks")); }}>
|
||||
Click count
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{appliedFilters.userId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
user:{appliedFilters.userLabel || "selected"}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("user")); }}>
|
||||
User
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("team")); }}>
|
||||
Team
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("duration")); }}>
|
||||
Duration
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("lastActive")); }}>
|
||||
Last active
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("clicks")); }}>
|
||||
Click count
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{appliedFilters.userId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
user:{appliedFilters.userLabel || "selected"}
|
||||
</span>
|
||||
{appliedFilters.teamId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
team:{appliedFilters.teamLabel || "selected"}
|
||||
</span>
|
||||
)}
|
||||
{(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
duration
|
||||
</span>
|
||||
)}
|
||||
{appliedFilters.lastActivePreset && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
last active: {appliedFilters.lastActivePreset}
|
||||
</span>
|
||||
)}
|
||||
{appliedFilters.clickCountMin && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
clicks
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors hover:transition-none"
|
||||
onClick={() => setAppliedFilters(EMPTY_FILTERS)}
|
||||
>
|
||||
<XIcon className="h-2.5 w-2.5" />
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{appliedFilters.teamId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
team:{appliedFilters.teamLabel || "selected"}
|
||||
</span>
|
||||
)}
|
||||
{(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
duration
|
||||
</span>
|
||||
)}
|
||||
{appliedFilters.lastActivePreset && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
last active: {appliedFilters.lastActivePreset}
|
||||
</span>
|
||||
)}
|
||||
{appliedFilters.clickCountMin && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
|
||||
clicks
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors hover:transition-none"
|
||||
onClick={() => setAppliedFilters(EMPTY_FILTERS)}
|
||||
>
|
||||
<XIcon className="h-2.5 w-2.5" />
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={activeFilterDialog === "user"} onOpenChange={(open) => setActiveFilterDialog(open ? "user" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>User Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<UserSearchPicker
|
||||
action={(user) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
<Dialog open={activeFilterDialog === "user"} onOpenChange={(open) => setActiveFilterDialog(open ? "user" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>User Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<UserSearchPicker
|
||||
action={(user) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedFilters((prev) => ({
|
||||
...prev,
|
||||
userId: user.id,
|
||||
userLabel: user.displayName ?? user.primaryEmail ?? user.id,
|
||||
}));
|
||||
setActiveFilterDialog(null);
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setAppliedFilters((prev) => ({ ...prev, userId: "", userLabel: "" }));
|
||||
setActiveFilterDialog(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={activeFilterDialog === "team"} onOpenChange={(open) => setActiveFilterDialog(open ? "team" : null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Team Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 pt-2">
|
||||
<TeamSearchTable
|
||||
action={(team) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
<Dialog open={activeFilterDialog === "team"} onOpenChange={(open) => setActiveFilterDialog(open ? "team" : null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Team Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 pt-2">
|
||||
<TeamSearchTable
|
||||
action={(team) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedFilters((prev) => ({
|
||||
...prev,
|
||||
teamId: team.id,
|
||||
teamLabel: team.displayName,
|
||||
}));
|
||||
setActiveFilterDialog(null);
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setAppliedFilters((prev) => ({ ...prev, teamId: "", teamLabel: "" }));
|
||||
setActiveFilterDialog(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={activeFilterDialog === "duration"} onOpenChange={(open) => setActiveFilterDialog(open ? "duration" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duration Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(e) => {
|
||||
<Dialog open={activeFilterDialog === "duration"} onOpenChange={(open) => setActiveFilterDialog(open ? "duration" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duration Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyDraftFilters();
|
||||
}}>
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<label className="space-y-1">
|
||||
<Typography className="text-xs text-muted-foreground">Min (seconds)</Typography>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
|
||||
value={draftFilters.durationMinSeconds}
|
||||
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMinSeconds: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<Typography className="text-xs text-muted-foreground">Max (seconds)</Typography>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
|
||||
value={draftFilters.durationMaxSeconds}
|
||||
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMaxSeconds: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="pt-3 flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="h-8">Apply</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}>
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<label className="space-y-1">
|
||||
<Typography className="text-xs text-muted-foreground">Min (seconds)</Typography>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
|
||||
value={draftFilters.durationMinSeconds}
|
||||
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMinSeconds: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<Typography className="text-xs text-muted-foreground">Max (seconds)</Typography>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
|
||||
value={draftFilters.durationMaxSeconds}
|
||||
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMaxSeconds: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="pt-3 flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="h-8">Apply</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={activeFilterDialog === "lastActive"} onOpenChange={(open) => setActiveFilterDialog(open ? "lastActive" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Last Active Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={appliedFilters.lastActivePreset === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
<Dialog open={activeFilterDialog === "lastActive"} onOpenChange={(open) => setActiveFilterDialog(open ? "lastActive" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Last Active Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={appliedFilters.lastActivePreset === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setAppliedFilters((prev) => ({ ...prev, lastActivePreset: value }));
|
||||
setActiveFilterDialog(null);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-1 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-1 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setAppliedFilters((prev) => ({ ...prev, lastActivePreset: "" }));
|
||||
setActiveFilterDialog(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={activeFilterDialog === "clicks"} onOpenChange={(open) => setActiveFilterDialog(open ? "clicks" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Click Count Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(e) => {
|
||||
<Dialog open={activeFilterDialog === "clicks"} onOpenChange={(open) => setActiveFilterDialog(open ? "clicks" : null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Click Count Filter</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyDraftFilters();
|
||||
}}>
|
||||
<div className="space-y-3 pt-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
|
||||
value={draftFilters.clickCountMin}
|
||||
onChange={(e) => setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))}
|
||||
placeholder="Minimum click count"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-3 flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="h-8">Apply</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}>
|
||||
<div className="space-y-3 pt-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
|
||||
value={draftFilters.clickCountMin}
|
||||
onChange={(e) => setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))}
|
||||
placeholder="Minimum click count"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-3 flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="h-8">Apply</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{listError && (
|
||||
<div className="p-3">
|
||||
<Alert variant="destructive">{listError}</Alert>
|
||||
</div>
|
||||
)}
|
||||
{listError && (
|
||||
<div className="p-3">
|
||||
<Alert variant="destructive">{listError}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={listBoxRef}
|
||||
onScroll={onListScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{loadingInitial ? (
|
||||
<div className="p-2 space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg p-3">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="mt-1.5 h-3 w-24" />
|
||||
<div
|
||||
ref={listBoxRef}
|
||||
onScroll={onListScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{loadingInitial ? (
|
||||
<div className="p-2 space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg p-3">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="mt-1.5 h-3 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recordings.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<Typography className="text-sm text-muted-foreground">
|
||||
{activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."}
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-0.5">
|
||||
{recordings.map((r) => {
|
||||
const isSelected = r.id === selectedRecordingId;
|
||||
const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime();
|
||||
const duration = formatDurationMs(durationMs);
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setSelectedRecordingId(r.id)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5",
|
||||
"transition-colors hover:transition-none",
|
||||
isSelected ? "bg-muted/60 ring-1 ring-border/40" : "hover:bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{getRecordingTitle(r)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{duration}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<DisplayDate date={r.lastEventAt} />
|
||||
{(clickCountsByReplayId.get(r.id) ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70">
|
||||
<CursorClickIcon className="h-3 w-3" />
|
||||
{clickCountsByReplayId.get(r.id)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
) : recordings.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<Typography className="text-sm text-muted-foreground">
|
||||
{activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."}
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-0.5">
|
||||
{recordings.map((r) => {
|
||||
const isSelected = r.id === selectedRecordingId;
|
||||
const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime();
|
||||
const duration = formatDurationMs(durationMs);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
isSelected ? "bg-muted/60 ring-1 ring-border/40" : "hover:bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedRecordingId(r.id)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5",
|
||||
"transition-colors hover:transition-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{getRecordingTitle(r)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{duration}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<DisplayDate date={r.lastEventAt} />
|
||||
{(clickCountsByReplayId.get(r.id) ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70">
|
||||
<CursorClickIcon className="h-3 w-3" />
|
||||
{clickCountsByReplayId.get(r.id)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{loadingMore && (
|
||||
<div className="rounded-lg p-3">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="mt-1.5 h-3 w-24" />
|
||||
{loadingMore && (
|
||||
<div className="rounded-lg p-3">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="mt-1.5 h-3 w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="w-px bg-border/40 hover:bg-border transition-colors hover:transition-none" />
|
||||
<PanelResizeHandle className="w-px bg-border/40 hover:bg-border transition-colors hover:transition-none" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Panel defaultSize={75} minSize={35}>
|
||||
<Panel defaultSize={isStandaloneReplayPage ? 100 : 75} minSize={35}>
|
||||
<div className="h-full flex flex-col">
|
||||
{(ms.downloadError || ms.playerError) && (
|
||||
{(standaloneReplayError || ms.downloadError || ms.playerError) && (
|
||||
<div className="p-3 space-y-2">
|
||||
{standaloneReplayError && <Alert variant="destructive">{standaloneReplayError}</Alert>}
|
||||
{ms.downloadError && <Alert variant="destructive">{ms.downloadError}</Alert>}
|
||||
{ms.playerError && <Alert variant="destructive">{ms.playerError}</Alert>}
|
||||
</div>
|
||||
@ -1749,16 +1825,43 @@ export default function PageClient() {
|
||||
>
|
||||
{getRecordingTitle(selectedRecording)}
|
||||
</StyledLink>
|
||||
) : isStandaloneReplayPage && selectedRecordingId ? (
|
||||
<Typography className="text-sm font-medium truncate font-mono">
|
||||
Replay {selectedRecordingId}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography className="text-sm font-medium truncate" />
|
||||
)}
|
||||
<ReplaySettingsButton
|
||||
settings={ms.settings}
|
||||
onSettingsChange={(updates) => actRef.current({ type: "UPDATE_SETTINGS", updates })}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedRecordingId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label={replayShareLinkCopied ? "Link copied" : "Copy link to replay"}
|
||||
title={replayShareLinkCopied ? "Copied!" : "Copy link to replay"}
|
||||
onClick={() => runAsynchronouslyWithAlert(async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}/projects/${encodeURIComponent(adminApp.projectId)}/analytics/replays/${encodeURIComponent(selectedRecordingId)}`,
|
||||
);
|
||||
setReplayShareLinkCopied(true);
|
||||
})}
|
||||
>
|
||||
{replayShareLinkCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-emerald-600" weight="bold" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<ReplaySettingsButton
|
||||
settings={ms.settings}
|
||||
onSettingsChange={(updates) => actRef.current({ type: "UPDATE_SETTINGS", updates })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRecording ? (
|
||||
{selectedRecordingId ? (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div
|
||||
|
||||
@ -553,6 +553,140 @@ it("admin list session replays paginates without skipping items", async ({ expec
|
||||
expect(secondId).not.toBe(firstId);
|
||||
});
|
||||
|
||||
it("admin can fetch a single session replay by id", async ({ expect }) => {
|
||||
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": "<stripped UUID>",
|
||||
"last_event_at_millis": 1700000000250,
|
||||
"project_user": {
|
||||
"display_name": null,
|
||||
"id": "<stripped UUID>",
|
||||
"primary_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
},
|
||||
"started_at_millis": 1700000000100,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("admin get session replay returns 404 for nonexistent id", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Auth.Otp.signIn();
|
||||
|
||||
const fakeId = randomUUID();
|
||||
const res = await niceBackendFetch(`/api/v1/internal/session-replays/${fakeId}`, {
|
||||
method: "GET",
|
||||
accessType: "admin",
|
||||
});
|
||||
|
||||
expect(res).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 404,
|
||||
"body": {
|
||||
"code": "ITEM_NOT_FOUND",
|
||||
"details": { "item_id": "<stripped UUID>" },
|
||||
"error": "Item with ID \\"<stripped UUID>\\" not found.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "ITEM_NOT_FOUND",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("non-admin access cannot call single session replay endpoint", async ({ expect }) => {
|
||||
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: 1, timestamp: 1_700_000_000_100 }],
|
||||
});
|
||||
expect(upload.status).toBe(200);
|
||||
const recordingId = upload.body?.session_replay_id;
|
||||
expect(typeof recordingId).toBe("string");
|
||||
|
||||
const clientRes = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}`, {
|
||||
method: "GET",
|
||||
accessType: "client",
|
||||
});
|
||||
expect(clientRes).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": {
|
||||
"code": "INSUFFICIENT_ACCESS_TYPE",
|
||||
"details": {
|
||||
"actual_access_type": "client",
|
||||
"allowed_access_types": ["admin"],
|
||||
},
|
||||
"error": "The x-stack-access-type header must be 'admin', but was 'client'.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
const serverRes = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}`, {
|
||||
method: "GET",
|
||||
accessType: "server",
|
||||
});
|
||||
expect(serverRes).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": {
|
||||
"code": "INSUFFICIENT_ACCESS_TYPE",
|
||||
"details": {
|
||||
"actual_access_type": "server",
|
||||
"allowed_access_types": ["admin"],
|
||||
},
|
||||
"error": "The x-stack-access-type header must be 'admin', but was 'server'.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("admin list session replays rejects unknown cursor", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Auth.Otp.signIn();
|
||||
|
||||
@ -13,6 +13,7 @@ import { InternalApiKeysCrud } from "./crud/internal-api-keys";
|
||||
import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions";
|
||||
import { ProjectsCrud } from "./crud/projects";
|
||||
import type {
|
||||
AdminGetSessionReplayResponse,
|
||||
AdminGetSessionReplayAllEventsResponse,
|
||||
AdminGetSessionReplayChunkEventsResponse,
|
||||
AdminListSessionReplayChunksOptions,
|
||||
@ -835,6 +836,15 @@ export class StackAdminInterface extends StackServerInterface {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getSessionReplay(sessionReplayId: string): Promise<AdminGetSessionReplayResponse> {
|
||||
const response = await this.sendAdminRequest(
|
||||
`/internal/session-replays/${encodeURIComponent(sessionReplayId)}`,
|
||||
{ method: "GET" },
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async listSessionReplayChunks(sessionReplayId: string, params?: AdminListSessionReplayChunksOptions): Promise<AdminListSessionReplayChunksResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.cursor) qs.set("cursor", params.cursor);
|
||||
|
||||
@ -28,6 +28,19 @@ export type AdminListSessionReplaysResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
export type AdminGetSessionReplayResponse = {
|
||||
id: string,
|
||||
project_user: {
|
||||
id: string,
|
||||
display_name: string | null,
|
||||
primary_email: string | null,
|
||||
},
|
||||
started_at_millis: number,
|
||||
last_event_at_millis: number,
|
||||
chunk_count: number,
|
||||
event_count: number,
|
||||
};
|
||||
|
||||
export type AdminListSessionReplayChunksOptions = {
|
||||
limit?: number,
|
||||
cursor?: string,
|
||||
|
||||
@ -33,7 +33,6 @@ import { PushedConfigSource } from "../../projects";
|
||||
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
|
||||
|
||||
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
|
||||
|
||||
/**
|
||||
* Converts a PushedConfigSource (SDK camelCase) to BranchConfigSourceApi (API snake_case).
|
||||
*/
|
||||
@ -1146,6 +1145,22 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
};
|
||||
}
|
||||
|
||||
async getSessionReplay(sessionReplayId: string): Promise<AdminSessionReplay> {
|
||||
const response = await this._interface.getSessionReplay(sessionReplayId);
|
||||
return {
|
||||
id: response.id,
|
||||
projectUser: {
|
||||
id: response.project_user.id,
|
||||
displayName: response.project_user.display_name,
|
||||
primaryEmail: response.project_user.primary_email,
|
||||
},
|
||||
startedAt: new Date(response.started_at_millis),
|
||||
lastEventAt: new Date(response.last_event_at_millis),
|
||||
chunkCount: response.chunk_count,
|
||||
eventCount: response.event_count,
|
||||
};
|
||||
}
|
||||
|
||||
async listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise<ListSessionReplayChunksResult> {
|
||||
const response = await this._interface.listSessionReplayChunks(sessionReplayId, {
|
||||
cursor: options?.cursor,
|
||||
|
||||
@ -52,7 +52,7 @@ export type ManagedEmailProviderListItem = {
|
||||
nameServerRecords: string[],
|
||||
};
|
||||
|
||||
import type { ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
|
||||
import type { AdminSessionReplay, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
|
||||
export type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplaysOptions, ListSessionReplaysResult, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, SessionReplayAllEventsResult } from "../../session-replays";
|
||||
|
||||
|
||||
@ -147,6 +147,7 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
|
||||
queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse>,
|
||||
|
||||
listSessionReplays(options?: ListSessionReplaysOptions): Promise<ListSessionReplaysResult>,
|
||||
getSessionReplay(sessionReplayId: string): Promise<AdminSessionReplay>,
|
||||
listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise<ListSessionReplayChunksResult>,
|
||||
getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise<AdminGetSessionReplayChunkEventsResponse>,
|
||||
getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise<SessionReplayAllEventsResult>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user