saved session replay implementation checkpoint

This commit is contained in:
Francis Cao
2026-03-09 13:51:08 -07:00
parent 81fd8e63a1
commit 1bffb3cbf3
21 changed files with 392 additions and 78 deletions
-49
View File
@@ -334,8 +334,6 @@ importers:
specifier: ^5.9.3
version: 5.9.3
dist: {}
packages:
'@ampproject/remapping@2.3.0':
@@ -533,28 +531,24 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.4.2':
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.4.2':
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.4.2':
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.2':
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
@@ -1318,105 +1312,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1617,28 +1595,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@@ -1693,42 +1667,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -2604,79 +2572,66 @@ packages:
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
@@ -2841,28 +2796,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.11':
resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.15.11':
resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.11':
resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.11':
resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==}
@@ -25,3 +25,21 @@ CREATE INDEX "session_replay_website_id_session_id_idx" ON "session_replay"("web
CREATE INDEX "session_replay_website_id_visit_id_idx" ON "session_replay"("website_id", "visit_id");
CREATE INDEX "session_replay_website_id_created_at_idx" ON "session_replay"("website_id", "created_at");
CREATE INDEX "session_replay_session_id_chunk_index_idx" ON "session_replay"("session_id", "chunk_index");
-- CreateTable
CREATE TABLE "session_replay_saved" (
"saved_replay_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"website_id" UUID NOT NULL,
"visit_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "session_replay_saved_pkey" PRIMARY KEY ("saved_replay_id"),
CONSTRAINT "session_replay_saved_website_id_visit_id_key" UNIQUE ("website_id", "visit_id")
);
-- CreateIndex
CREATE INDEX "session_replay_saved_website_id_idx" ON "session_replay_saved"("website_id");
CREATE INDEX "session_replay_saved_visit_id_idx" ON "session_replay_saved"("visit_id");
CREATE INDEX "session_replay_saved_website_id_created_at_idx" ON "session_replay_saved"("website_id", "created_at");
+27 -9
View File
@@ -78,15 +78,16 @@ model Website {
replayEnabled Boolean @default(false) @map("replay_enabled")
replayConfig Json? @map("replay_config")
user User? @relation("user", fields: [userId], references: [id])
createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[]
reports Report[]
revenue Revenue[]
segments Segment[]
sessionData SessionData[]
sessionReplays SessionReplay[]
user User? @relation("user", fields: [userId], references: [id])
createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[]
reports Report[]
revenue Revenue[]
segments Segment[]
sessionData SessionData[]
sessionReplays SessionReplay[]
sessionReplaysSaved SessionReplaySaved[]
@@index([userId])
@@index([teamId])
@@ -383,3 +384,20 @@ model SessionReplay {
@@index([sessionId, chunkIndex])
@@map("session_replay")
}
model SessionReplaySaved {
id String @id() @map("saved_replay_id") @db.Uuid
name String @db.VarChar(100)
websiteId String @map("website_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
@@unique([websiteId, visitId])
@@index([websiteId])
@@index([visitId])
@@index([websiteId, createdAt])
@@map("session_replay_saved")
}
+2
View File
@@ -270,6 +270,7 @@
"run-query": "Run query",
"save": "Save",
"save-cohort": "Save cohort",
"save-replay": "Save replay",
"save-segment": "Save segment",
"sample-rate": "Sample rate",
"sample-size": "Sample size",
@@ -344,6 +345,7 @@
"uniqueCustomers": "Unique Customers",
"customers": "Customers",
"orders": "Orders",
"saved": "Saved",
"unknown": "Unknown",
"untitled": "Untitled",
"update": "Update",
@@ -1,17 +1,42 @@
'use client';
import { Column } from '@umami/react-zen';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { getItem, setItem } from '@/lib/storage';
import { ReplayModal } from './ReplayModal';
import { ReplaysDataTable } from './ReplaysDataTable';
import { SavedReplaysDataTable } from './SavedReplaysDataTable';
const KEY_NAME = 'umami.replays.tab';
export function ReplaysPage({ websiteId }: { websiteId: string }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'replays');
const { t, labels } = useMessages();
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
setTab(value);
};
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<Panel>
<ReplaysDataTable websiteId={websiteId} />
<Tabs selectedKey={tab} onSelectionChange={handleSelect}>
<TabList>
<Tab id="replays">{t(labels.replays)}</Tab>
<Tab id="saved">{t(labels.saved)}</Tab>
</TabList>
<TabPanel id="replays">
<ReplaysDataTable websiteId={websiteId} />
</TabPanel>
<TabPanel id="saved">
<SavedReplaysDataTable websiteId={websiteId} />
</TabPanel>
</Tabs>
</Panel>
<SessionModal websiteId={websiteId} />
<ReplayModal websiteId={websiteId} />
@@ -69,7 +69,7 @@ export function ReplaysTable({ ...props }: DataTableProps) {
</TypeIcon>
)}
</DataColumn>
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="140px">
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="160px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
</DataTable>
@@ -0,0 +1,15 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useSavedReplaysQuery } from '@/components/hooks';
import { SavedReplaysTable } from './SavedReplaysTable';
export function SavedReplaysDataTable({ websiteId }: { websiteId: string }) {
const queryResult = useSavedReplaysQuery(websiteId);
return (
<DataGrid query={queryResult} allowPaging allowSearch>
{({ data }) => {
return <SavedReplaysTable data={data} />;
}}
</DataGrid>
);
}
@@ -0,0 +1,30 @@
import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
import { Play } from 'lucide-react';
import { useMessages, useNavigation } from '@/components/hooks';
export function SavedReplaysTable({ ...props }: DataTableProps) {
const { t, labels } = useMessages();
const { router, updateParams } = useNavigation();
return (
<DataTable {...props}>
<DataColumn id="play" label="" width="80px">
{(row: any) => (
<Button
variant="quiet"
onClick={() => router.push(updateParams({ replay: row.visitId }))}
>
<Icon>
<Play />
</Icon>
</Button>
)}
</DataColumn>
<DataColumn id="name" label={t(labels.name)} />
<DataColumn id="visitId" label={t(labels.replayId)} />
<DataColumn id="createdAt" label={t(labels.created)} width="160px">
{(row: any) => new Date(row.createdAt).toLocaleString()}
</DataColumn>
</DataTable>
);
}
@@ -1,11 +1,19 @@
'use client';
import { Button, Column, Icon, Row, Text } from '@umami/react-zen';
import { X } from 'lucide-react';
import { Button, Column, Dialog, DialogTrigger, Icon, Popover, Row, Text } from '@umami/react-zen';
import { Bookmark, X } from 'lucide-react';
import { useState } from 'react';
import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo';
import { Avatar } from '@/components/common/Avatar';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks';
import {
useMessages,
useReplayQuery,
useUpdateQuery,
useWebsiteSessionQuery,
} from '@/components/hooks';
import { touch } from '@/components/hooks/useModified';
import { ReplayPlayer } from './ReplayPlayer';
import { ReplaySaveForm } from './ReplaySaveForm';
export function ReplayPlayback({
websiteId,
@@ -19,6 +27,15 @@ export function ReplayPlayback({
const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId);
const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId);
const { t, labels } = useMessages();
const [isSaved, setIsSaved] = useState<boolean | null>(null);
const { mutate } = useUpdateQuery(`/websites/${websiteId}/replays/${replayId}`);
const saved = isSaved ?? replay?.isSaved ?? false;
const handleUnsave = () => {
setIsSaved(false);
mutate({ isSaved: false }, { onSuccess: () => touch('replays') });
};
return (
<LoadingPanel
@@ -41,13 +58,45 @@ export function ReplayPlayback({
</Text>
</Column>
</Row>
{onClose && (
<Button onPress={onClose} variant="quiet">
<Icon>
<X />
</Icon>
</Button>
)}
<Row gap="2">
{saved ? (
<Button onPress={handleUnsave} variant="quiet">
<Icon>
<Bookmark fill="currentColor" />
</Icon>
</Button>
) : (
<DialogTrigger>
<Button variant="quiet">
<Icon>
<Bookmark fill="none" />
</Icon>
</Button>
<Popover placement="bottom end">
<Dialog title={t(labels.saveReplay)} style={{ width: '300px' }}>
{({ close }) => (
<ReplaySaveForm
websiteId={websiteId}
replayId={replayId}
onSave={() => {
setIsSaved(true);
touch('replays');
}}
onClose={close}
/>
)}
</Dialog>
</Popover>
</DialogTrigger>
)}
{onClose && (
<Button onPress={onClose} variant="quiet">
<Icon>
<X />
</Icon>
</Button>
)}
</Row>
</Row>
)}
<ReplayPlayer events={replay.events} />
@@ -0,0 +1,55 @@
'use client';
import {
Button,
Form,
FormButtons,
FormField,
FormSubmitButton,
TextField,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
export function ReplaySaveForm({
websiteId,
replayId,
onSave,
onClose,
}: {
websiteId: string;
replayId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { t, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery(
`/websites/${websiteId}/replays/${replayId}`,
);
const handleSubmit = async (formData: { name: string }) => {
await mutateAsync(
{ isSaved: true, name: formData.name },
{
onSuccess: () => {
onSave?.();
onClose?.();
},
},
);
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField autoFocus />
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}
@@ -36,7 +36,7 @@ export function SessionReplaysTable({
{(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">
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="160px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
</DataTable>
@@ -1,6 +1,12 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsite } from '@/permissions';
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import {
createReplaySaved,
deleteReplaySaved,
getReplaySaved,
} from '@/queries/prisma/sessionReplay';
import { getReplayChunks } from '@/queries/sql';
export async function GET(
@@ -19,7 +25,10 @@ export async function GET(
return unauthorized();
}
const chunks = await getReplayChunks(websiteId, replayId);
const [chunks, isSaved] = await Promise.all([
getReplayChunks(websiteId, replayId),
getReplaySaved(websiteId, replayId),
]);
const allEvents = chunks.flatMap(chunk => chunk.events);
const sessionId = chunks.length > 0 ? chunks[0].sessionId : null;
@@ -33,5 +42,35 @@ export async function GET(
endedAt,
eventCount: allEvents.length,
chunkCount: chunks.length,
isSaved,
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string; replayId: string }> },
) {
const schema = z.object({
isSaved: z.boolean(),
name: z.string().max(100).optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId, replayId } = await params;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
if (body.isSaved) {
await createReplaySaved(websiteId, replayId, body.name ?? '');
} else {
await deleteReplaySaved(websiteId, replayId);
}
return json({ ok: true });
}
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getSavedReplays } from '@/queries/prisma/sessionReplay';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...pagingParams,
...searchParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getSavedReplays(websiteId, query);
return json(data);
}
+1
View File
@@ -35,6 +35,7 @@ export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useResultQuery';
export * from './queries/useRevenueSessionsQuery';
export * from './queries/useSavedReplaysQuery';
export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataPropertiesQuery';
export * from './queries/useSessionDataQuery';
@@ -0,0 +1,18 @@
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useSavedReplaysQuery(websiteId: string) {
const { get } = useApi();
const { modified } = useModified('replays');
return usePagedQuery({
queryKey: ['replays:saved', { websiteId, modified }],
queryFn: pageParams => {
return get(`/websites/${websiteId}/replays/saved`, {
...pageParams,
pageSize: 20,
});
},
});
}
+2
View File
@@ -343,6 +343,7 @@ export const labels: Record<string, string> = {
version: 'label.version',
saveSegment: 'label.save-segment',
saveCohort: 'label.save-cohort',
saveReplay: 'label.save-replay',
analysis: 'label.analysis',
destinationUrl: 'label.destination-url',
slug: 'label.slug',
@@ -366,6 +367,7 @@ export const labels: Record<string, string> = {
sampleSize: 'label.sample-size',
play: 'label.play',
replays: 'label.replays',
saved: 'label.saved',
replay: 'label.replay',
replayId: 'label.replay-id',
replayEnabled: 'label.replay-enabled',
+7 -3
View File
@@ -72,7 +72,7 @@ export function ListTable({
alignItems="center"
justifyContent="space-between"
paddingLeft="2"
columns={`1fr ${showPercentage ? '100px' : '150px'}`}
columns={'1fr 100px'}
>
<Text weight="bold">{title}</Text>
<Text weight="bold" align="center">
@@ -117,7 +117,7 @@ const AnimatedRow = ({
return (
<Grid
columns="1fr 50px 50px"
columns={showPercentage ? '1fr 50px 50px' : '1fr 100px'}
paddingLeft="2"
alignItems="center"
borderRadius
@@ -129,7 +129,11 @@ const AnimatedRow = ({
{label}
</Text>
</Row>
<Row alignItems="center" height="30px" justifyContent="flex-end">
<Row
alignItems="center"
height="30px"
justifyContent={showPercentage ? 'flex-end' : 'center'}
>
{change}
<Text weight="bold">
<AnimatedDiv title={props?.y as any}>
+47
View File
@@ -1,5 +1,6 @@
import { uuid } from '@/lib/crypto';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
export interface CreateReplayChunkArgs {
websiteId: string;
@@ -62,3 +63,49 @@ export async function deleteReplaysByWebsite(websiteId: string) {
where: { websiteId },
});
}
export async function getReplaySaved(websiteId: string, visitId: string): Promise<boolean> {
const record = await prisma.client.sessionReplaySaved.findUnique({
where: { websiteId_visitId: { websiteId, visitId } },
select: { id: true },
});
return record !== null;
}
export async function createReplaySaved(websiteId: string, visitId: string, name: string) {
return prisma.client.sessionReplaySaved.create({
data: { id: uuid(), websiteId, visitId, name },
});
}
export async function updateReplaySaved(websiteId: string, visitId: string, name: string) {
return prisma.client.sessionReplaySaved.updateMany({
where: { websiteId, visitId },
data: { name },
});
}
export async function deleteReplaySaved(websiteId: string, visitId: string) {
return prisma.client.sessionReplaySaved.deleteMany({
where: { websiteId, visitId },
});
}
export async function getSavedReplays(websiteId: string, filters: QueryFilters) {
const { search } = filters;
const { getSearchParameters, pagedQuery } = prisma;
const where = {
websiteId,
...getSearchParameters(search, [{ name: 'contains' }, { visitId: 'contains' }]),
};
return pagedQuery(
'sessionReplaySaved',
{
where,
orderBy: { createdAt: 'desc' },
},
filters,
);
}
+8
View File
@@ -136,6 +136,10 @@ export async function resetWebsite(websiteId: string) {
return transaction(
async tx => {
await tx.sessionReplaySaved.deleteMany({
where: { websiteId },
});
await tx.sessionReplay.deleteMany({
where: { websiteId },
});
@@ -187,6 +191,10 @@ export async function deleteWebsite(websiteId: string) {
return transaction(
async tx => {
await tx.sessionReplaySaved.deleteMany({
where: { websiteId },
});
await tx.sessionReplay.deleteMany({
where: { websiteId },
});
+1 -1
View File
@@ -132,7 +132,7 @@ async function clickhouseQuery(
),
user_activities AS (
select distinct
website_event.session_id,
website_event.session_id as session_id,
toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number
from website_event
join cohort_items