mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
POC for playwright based screenshots for heatmaps
This commit is contained in:
+1
-1
@@ -44,7 +44,7 @@ const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' ');
|
||||
|
||||
const contentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
img-src 'self' https: data:;
|
||||
img-src 'self' https: data: blob:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
connect-src ${connectSrc};
|
||||
|
||||
+1
-1
@@ -60,6 +60,7 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@prisma/extension-read-replicas": "^0.5.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@umami/react-zen": "^0.245.0",
|
||||
@@ -122,7 +123,6 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@netlify/plugin-nextjs": "^5.15.11",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rollup/plugin-alias": "^6.0.0",
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
|
||||
Generated
+3
-171
@@ -23,6 +23,9 @@ importers:
|
||||
'@hello-pangea/dnd':
|
||||
specifier: ^18.0.1
|
||||
version: 18.0.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
'@prisma/adapter-pg':
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.0
|
||||
@@ -213,9 +216,6 @@ importers:
|
||||
'@netlify/plugin-nextjs':
|
||||
specifier: ^5.15.11
|
||||
version: 5.15.11
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
'@rollup/plugin-alias':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(rollup@4.60.4)
|
||||
@@ -328,45 +328,6 @@ importers:
|
||||
specifier: ^4.1.6
|
||||
version: 4.1.6(@types/node@25.8.0)(jsdom@29.1.1)(msw@2.14.6(@types/node@25.8.0)(typescript@6.0.3))(vite@8.0.11(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.1)(tsx@4.22.1))
|
||||
|
||||
dist:
|
||||
dependencies:
|
||||
chart.js:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.1
|
||||
chartjs-adapter-date-fns:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(chart.js@4.5.1)(date-fns@4.1.0)
|
||||
colord:
|
||||
specifier: ^2.9.2
|
||||
version: 2.9.3
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.3
|
||||
lucide-react:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(react@19.2.6)
|
||||
pure-rand:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
react-simple-maps:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-use-measure:
|
||||
specifier: ^2.0.4
|
||||
version: 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-window:
|
||||
specifier: ^1.8.6
|
||||
version: 1.8.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
serialize-error:
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0
|
||||
thenby:
|
||||
specifier: ^1.3.4
|
||||
version: 1.4.1
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.1
|
||||
|
||||
packages:
|
||||
|
||||
'@adobe/css-tools@4.4.4':
|
||||
@@ -539,28 +500,24 @@ packages:
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.15':
|
||||
resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.15':
|
||||
resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.15':
|
||||
resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.15':
|
||||
resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==}
|
||||
@@ -1541,105 +1498,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==}
|
||||
@@ -1781,28 +1722,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.6':
|
||||
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.6':
|
||||
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.6':
|
||||
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.6':
|
||||
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
|
||||
@@ -1872,42 +1809,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==}
|
||||
@@ -2222,42 +2153,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==}
|
||||
@@ -2404,79 +2329,66 @@ packages:
|
||||
resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.4':
|
||||
resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.4':
|
||||
resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.4':
|
||||
resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.4':
|
||||
resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.4':
|
||||
resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.4':
|
||||
resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.4':
|
||||
resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.4':
|
||||
resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.4':
|
||||
resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.4':
|
||||
resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.4':
|
||||
resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.4':
|
||||
resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.4':
|
||||
resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
|
||||
@@ -2629,42 +2541,36 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.33':
|
||||
resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.33':
|
||||
resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.33':
|
||||
resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.33':
|
||||
resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.33':
|
||||
resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.33':
|
||||
resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==}
|
||||
@@ -4193,28 +4099,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -4309,11 +4211,6 @@ packages:
|
||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
lucide-react@0.542.0:
|
||||
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lucide-react@0.555.0:
|
||||
resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==}
|
||||
peerDependencies:
|
||||
@@ -4354,9 +4251,6 @@ packages:
|
||||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
|
||||
memorystream@0.3.1:
|
||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
@@ -5232,9 +5126,6 @@ packages:
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
pure-rand@7.0.1:
|
||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||
|
||||
pure-rand@8.4.0:
|
||||
resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==}
|
||||
|
||||
@@ -5299,13 +5190,6 @@ packages:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-simple-maps@2.3.0:
|
||||
resolution: {integrity: sha512-IZVeiPSRZKwD6I/2NvXpQ2uENYGDGZp8DvZjkapcxuJ/LQHTfl+Byb+KNgY7s+iatRA2ad8LnZ3AgqcjziCCsw==}
|
||||
peerDependencies:
|
||||
prop-types: ^15.7.2
|
||||
react: ^16.8.0 || 17.x
|
||||
react-dom: ^16.8.0 || 17.x
|
||||
|
||||
react-simple-maps@3.0.0:
|
||||
resolution: {integrity: sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==}
|
||||
peerDependencies:
|
||||
@@ -5327,13 +5211,6 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-window@1.8.11:
|
||||
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
|
||||
engines: {node: '>8.0.0'}
|
||||
peerDependencies:
|
||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-window@2.2.7:
|
||||
resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==}
|
||||
peerDependencies:
|
||||
@@ -5539,10 +5416,6 @@ packages:
|
||||
seq-queue@0.0.5:
|
||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
||||
|
||||
serialize-error@12.0.0:
|
||||
resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
serialize-error@13.0.1:
|
||||
resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -5939,10 +5812,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
type-fest@4.41.0:
|
||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
type-fest@5.6.0:
|
||||
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6025,10 +5894,6 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@14.0.0:
|
||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||
hasBin: true
|
||||
@@ -9933,10 +9798,6 @@ snapshots:
|
||||
|
||||
lru.min@1.1.4: {}
|
||||
|
||||
lucide-react@0.542.0(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
lucide-react@0.555.0(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -9968,8 +9829,6 @@ snapshots:
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
memoize-one@5.2.1: {}
|
||||
|
||||
memorystream@0.3.1: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -10881,8 +10740,6 @@ snapshots:
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
pure-rand@7.0.1: {}
|
||||
|
||||
pure-rand@8.4.0: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -10950,16 +10807,6 @@ snapshots:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-simple-maps@2.3.0(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
d3-geo: 2.0.2
|
||||
d3-selection: 2.0.0
|
||||
d3-zoom: 2.0.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
topojson-client: 3.1.0
|
||||
|
||||
react-simple-maps@3.0.0(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
d3-geo: 2.0.2
|
||||
@@ -10986,13 +10833,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-window@1.8.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
memoize-one: 5.2.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-window@2.2.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -11268,10 +11108,6 @@ snapshots:
|
||||
|
||||
seq-queue@0.0.5: {}
|
||||
|
||||
serialize-error@12.0.0:
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
|
||||
serialize-error@13.0.1:
|
||||
dependencies:
|
||||
non-error: 0.1.0
|
||||
@@ -11712,8 +11548,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
type-fest@4.41.0: {}
|
||||
|
||||
type-fest@5.6.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
@@ -11806,8 +11640,6 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@14.0.0: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
@@ -30,3 +30,30 @@ CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id");
|
||||
CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_visit_id_replay_chunk_index_replay_event_index_idx" ON "heatmap_event"("website_id", "visit_id", "replay_chunk_index", "replay_event_index");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "heatmap_snapshot" (
|
||||
"snapshot_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"url_path" VARCHAR(500) NOT NULL,
|
||||
"viewport_w" INTEGER NOT NULL,
|
||||
"viewport_h" INTEGER NOT NULL,
|
||||
"page_w" INTEGER NOT NULL,
|
||||
"page_h" INTEGER NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"mime_type" VARCHAR(100),
|
||||
"image_data" BYTEA,
|
||||
"image_size" INTEGER,
|
||||
"error" VARCHAR(500),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "heatmap_snapshot_pkey" PRIMARY KEY ("snapshot_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "heatmap_snapshot_website_id_url_path_viewport_w_viewport_h_key"
|
||||
ON "heatmap_snapshot"("website_id", "url_path", "viewport_w", "viewport_h");
|
||||
CREATE INDEX "heatmap_snapshot_website_id_idx" ON "heatmap_snapshot"("website_id");
|
||||
CREATE INDEX "heatmap_snapshot_website_id_url_path_idx" ON "heatmap_snapshot"("website_id", "url_path");
|
||||
CREATE INDEX "heatmap_snapshot_website_id_updated_at_idx" ON "heatmap_snapshot"("website_id", "updated_at");
|
||||
|
||||
@@ -89,6 +89,7 @@ model Website {
|
||||
sessionReplays SessionReplay[]
|
||||
sessionReplaysSaved SessionReplaySaved[]
|
||||
heatmapEvents HeatmapEvent[]
|
||||
heatmapSnapshots HeatmapSnapshot[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@ -432,3 +433,28 @@ model HeatmapEvent {
|
||||
@@index([websiteId, visitId, replayChunkIndex, replayEventIndex])
|
||||
@@map("heatmap_event")
|
||||
}
|
||||
|
||||
model HeatmapSnapshot {
|
||||
id String @id() @map("snapshot_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
viewportW Int @map("viewport_w") @db.Integer
|
||||
viewportH Int @map("viewport_h") @db.Integer
|
||||
pageW Int @map("page_w") @db.Integer
|
||||
pageH Int @map("page_h") @db.Integer
|
||||
status String @db.VarChar(20)
|
||||
mimeType String? @map("mime_type") @db.VarChar(100)
|
||||
imageData Bytes? @map("image_data")
|
||||
imageSize Int? @map("image_size") @db.Integer
|
||||
error String? @db.VarChar(500)
|
||||
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, urlPath, viewportW, viewportH])
|
||||
@@index([websiteId])
|
||||
@@index([websiteId, urlPath])
|
||||
@@index([websiteId, updatedAt])
|
||||
@@map("heatmap_snapshot")
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.snapshotMessage {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.scrollBand {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -87,6 +91,7 @@
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 2px 8px;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.scrollBandLabel {
|
||||
@@ -104,7 +109,8 @@
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@@ -139,27 +145,20 @@
|
||||
|
||||
.snapshot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snapshot :global(.replayer-wrapper) {
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.snapshot :global(iframe) {
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snapshot :global(.replayer-mouse),
|
||||
.snapshot :global(.replayer-mouse-tail) {
|
||||
display: none !important;
|
||||
.snapshotImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { notFound, unauthorized } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { getHeatmapSnapshotImage } from '@/queries/sql/heatmap/ensureHeatmapSnapshot';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; snapshotId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
const { websiteId, snapshotId } = await params;
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const snapshot = await getHeatmapSnapshotImage(websiteId, snapshotId);
|
||||
|
||||
if (!snapshot) {
|
||||
return notFound({ message: 'Snapshot not found.' });
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(snapshot.imageData), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': snapshot.mimeType,
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import prisma from '@/lib/prisma';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
|
||||
const SNAPSHOT_STATUS = {
|
||||
pending: 'pending',
|
||||
ready: 'ready',
|
||||
failed: 'failed',
|
||||
} as const;
|
||||
|
||||
const SNAPSHOT_RETRY_DELAY_MS = 15 * 60 * 1000;
|
||||
const SNAPSHOT_PENDING_WINDOW_MS = 30 * 1000;
|
||||
|
||||
export type HeatmapSnapshotStatus = (typeof SNAPSHOT_STATUS)[keyof typeof SNAPSHOT_STATUS];
|
||||
|
||||
export interface HeatmapSnapshotImage {
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SnapshotRecord {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
hasImage: boolean;
|
||||
updatedAt: Date | string | null;
|
||||
}
|
||||
|
||||
interface EnsureHeatmapSnapshotOptions {
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
imageData: Buffer;
|
||||
mimeType: string;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
}
|
||||
|
||||
async function measurePage(page: any) {
|
||||
return page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
const body = document.body;
|
||||
const root = document.scrollingElement || doc || body;
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
|
||||
if (body) {
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode as Element | null;
|
||||
|
||||
while (node) {
|
||||
const rect = node.getBoundingClientRect?.();
|
||||
|
||||
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
}
|
||||
|
||||
node = walker.nextNode() as Element | null;
|
||||
}
|
||||
}
|
||||
|
||||
const pageW = Math.max(
|
||||
window.innerWidth,
|
||||
root?.scrollWidth || 0,
|
||||
doc?.scrollWidth || 0,
|
||||
body?.scrollWidth || 0,
|
||||
Math.ceil(maxRight),
|
||||
);
|
||||
const pageH = Math.max(
|
||||
window.innerHeight,
|
||||
root?.scrollHeight || 0,
|
||||
doc?.scrollHeight || 0,
|
||||
body?.scrollHeight || 0,
|
||||
Math.ceil(maxBottom),
|
||||
);
|
||||
|
||||
return {
|
||||
pageW: Math.ceil(pageW),
|
||||
pageH: Math.ceil(pageH),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSchema() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const connectionUrl = new URL(databaseUrl);
|
||||
|
||||
return connectionUrl.searchParams.get('schema');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rawExecute(sql: string, data: Record<string, any> = {}) {
|
||||
const params: any[] = [];
|
||||
const schema = getSchema();
|
||||
|
||||
if (schema) {
|
||||
await prisma.client.$executeRawUnsafe(`SET search_path TO "${schema}";`);
|
||||
}
|
||||
|
||||
const query = sql.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
|
||||
const [, name, type] = args;
|
||||
|
||||
params.push(data[name]);
|
||||
|
||||
return `$${params.length}${type ?? ''}`;
|
||||
});
|
||||
|
||||
return prisma.client.$executeRawUnsafe(query, ...params);
|
||||
}
|
||||
|
||||
async function findSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
const rows = await prisma.rawQuery(
|
||||
`
|
||||
select
|
||||
snapshot_id as id,
|
||||
website_id as "websiteId",
|
||||
url_path as "urlPath",
|
||||
viewport_w as "viewportW",
|
||||
viewport_h as "viewportH",
|
||||
page_w as "pageW",
|
||||
page_h as "pageH",
|
||||
status,
|
||||
mime_type as "mimeType",
|
||||
image_size as "imageSize",
|
||||
error,
|
||||
image_data is not null as "hasImage",
|
||||
updated_at as "updatedAt"
|
||||
from heatmap_snapshot
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and url_path = {{urlPath}}
|
||||
and viewport_w = {{viewportW}}
|
||||
and viewport_h = {{viewportH}}
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, urlPath, viewportW, viewportH },
|
||||
'findHeatmapSnapshot',
|
||||
);
|
||||
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
function getSnapshotImageUrl(websiteId: string, snapshotId: string) {
|
||||
return `/api/websites/${websiteId}/heatmaps/snapshots/${snapshotId}`;
|
||||
}
|
||||
|
||||
function mapSnapshot(websiteId: string, row: SnapshotRecord): HeatmapSnapshotImage {
|
||||
return {
|
||||
id: row.id,
|
||||
imageUrl: row.status === SNAPSHOT_STATUS.ready && row.hasImage ? getSnapshotImageUrl(websiteId, row.id) : null,
|
||||
status: row.status,
|
||||
mimeType: row.mimeType,
|
||||
pageW: Number(row.pageW),
|
||||
pageH: Number(row.pageH),
|
||||
viewportW: Number(row.viewportW),
|
||||
viewportH: Number(row.viewportH),
|
||||
error: row.error,
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstDomain(domain?: string | null) {
|
||||
return domain?.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function getWebsiteOrigin(domain?: string | null) {
|
||||
const host = getFirstDomain(domain);
|
||||
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.startsWith('http://') || host.startsWith('https://')) {
|
||||
return new URL(host);
|
||||
}
|
||||
|
||||
const protocol =
|
||||
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]')
|
||||
? 'http'
|
||||
: 'https';
|
||||
|
||||
return new URL(`${protocol}://${host}`);
|
||||
}
|
||||
|
||||
function buildCaptureUrl(domain: string | null | undefined, urlPath: string) {
|
||||
const origin = getWebsiteOrigin(domain);
|
||||
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(urlPath || '/', origin).toString();
|
||||
}
|
||||
|
||||
export function shouldSkipSnapshot(urlPath: string) {
|
||||
// Internal Umami app routes cannot be rendered from the tracked website domain.
|
||||
return urlPath.startsWith('/teams/');
|
||||
}
|
||||
|
||||
async function upsertSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
imageData: Buffer | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
return rawExecute(
|
||||
`
|
||||
insert into heatmap_snapshot (
|
||||
snapshot_id,
|
||||
website_id,
|
||||
url_path,
|
||||
viewport_w,
|
||||
viewport_h,
|
||||
page_w,
|
||||
page_h,
|
||||
status,
|
||||
mime_type,
|
||||
image_data,
|
||||
image_size,
|
||||
error,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
{{id::uuid}},
|
||||
{{websiteId::uuid}},
|
||||
{{urlPath}},
|
||||
{{viewportW}},
|
||||
{{viewportH}},
|
||||
{{pageW}},
|
||||
{{pageH}},
|
||||
{{status}},
|
||||
{{mimeType}},
|
||||
{{imageData}},
|
||||
{{imageSize}},
|
||||
{{error}},
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (website_id, url_path, viewport_w, viewport_h) do update
|
||||
set
|
||||
page_w = excluded.page_w,
|
||||
page_h = excluded.page_h,
|
||||
status = excluded.status,
|
||||
mime_type = excluded.mime_type,
|
||||
image_data = excluded.image_data,
|
||||
image_size = excluded.image_size,
|
||||
error = excluded.error,
|
||||
updated_at = now()
|
||||
`,
|
||||
{
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
imageSize: imageData?.byteLength ?? null,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function captureSnapshot(
|
||||
url: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
pageW?: number,
|
||||
): Promise<CaptureResult> {
|
||||
const { chromium } = await import('@playwright/test');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const initialViewportW = Math.max(viewportW, pageW || 0);
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: initialViewportW, height: viewportH },
|
||||
screen: { width: initialViewportW, height: viewportH },
|
||||
deviceScaleFactor: 1,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
let dimensions = await measurePage(page);
|
||||
let currentWidth = initialViewportW;
|
||||
let captureWidth = Math.max(initialViewportW, dimensions.pageW);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (captureWidth <= currentWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentWidth = captureWidth;
|
||||
|
||||
await page.setViewportSize({
|
||||
width: currentWidth,
|
||||
height: viewportH,
|
||||
});
|
||||
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
dimensions = await measurePage(page);
|
||||
captureWidth = Math.max(currentWidth, dimensions.pageW);
|
||||
}
|
||||
|
||||
const imageData = Buffer.from(await page.screenshot({ fullPage: true, type: 'png' }));
|
||||
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
imageData,
|
||||
mimeType: 'image/png',
|
||||
pageW: dimensions.pageW,
|
||||
pageH: dimensions.pageH,
|
||||
};
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
}: EnsureHeatmapSnapshotOptions): Promise<HeatmapSnapshotImage | null> {
|
||||
if (!urlPath || !viewportW || !viewportH || !pageW || !pageH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldSkipSnapshot(urlPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
if (existing?.status === SNAPSHOT_STATUS.ready && existing.hasImage) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null;
|
||||
const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (
|
||||
existing?.status === SNAPSHOT_STATUS.pending &&
|
||||
ageMs < SNAPSHOT_PENDING_WINDOW_MS
|
||||
) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
if (
|
||||
existing?.status === SNAPSHOT_STATUS.failed &&
|
||||
ageMs < SNAPSHOT_RETRY_DELAY_MS
|
||||
) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
const snapshotId = existing?.id ?? uuid();
|
||||
const website = await getWebsite(websiteId);
|
||||
const captureUrl = buildCaptureUrl(website?.domain, urlPath);
|
||||
|
||||
if (!captureUrl) {
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.failed,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: 'Website domain is not configured for screenshot capture.',
|
||||
});
|
||||
|
||||
const failed = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
return failed ? mapSnapshot(websiteId, failed) : null;
|
||||
}
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.pending,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const capture = await captureSnapshot(captureUrl, viewportW, viewportH, pageW);
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW: capture.pageW,
|
||||
pageH: capture.pageH,
|
||||
status: SNAPSHOT_STATUS.ready,
|
||||
mimeType: capture.mimeType,
|
||||
imageData: capture.imageData,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.failed,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: error instanceof Error ? error.message.slice(0, 500) : 'Screenshot capture failed.',
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
return snapshot ? mapSnapshot(websiteId, snapshot) : null;
|
||||
}
|
||||
|
||||
export async function getHeatmapSnapshotImage(
|
||||
websiteId: string,
|
||||
snapshotId: string,
|
||||
): Promise<{ mimeType: string; imageData: Buffer } | null> {
|
||||
const rows = await prisma.rawQuery(
|
||||
`
|
||||
select
|
||||
mime_type as "mimeType",
|
||||
image_data as "imageData"
|
||||
from heatmap_snapshot
|
||||
where snapshot_id = {{snapshotId::uuid}}
|
||||
and website_id = {{websiteId::uuid}}
|
||||
and status = 'ready'
|
||||
and image_data is not null
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, snapshotId },
|
||||
'getHeatmapSnapshotImage',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row?.imageData || !row?.mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType: row.mimeType,
|
||||
imageData: Buffer.from(row.imageData),
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,17 @@ import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
import {
|
||||
ensureHeatmapSnapshot,
|
||||
shouldSkipSnapshot,
|
||||
type HeatmapSnapshotImage,
|
||||
} from './ensureHeatmapSnapshot';
|
||||
|
||||
const FUNCTION_NAME = 'getHeatmap';
|
||||
|
||||
const POINT_LIMIT = 5000;
|
||||
const PAGE_LIMIT = 100;
|
||||
const SCROLL_BUCKET_SIZE = 5;
|
||||
const SCROLL_BUCKET_SIZE = 10;
|
||||
|
||||
export type HeatmapMode = 'click' | 'scroll';
|
||||
|
||||
@@ -41,12 +46,7 @@ export interface HeatmapScrollBucket {
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface HeatmapSnapshot {
|
||||
replayId: string;
|
||||
timestamp: number;
|
||||
chunkIndex: number | null;
|
||||
eventIndex: number | null;
|
||||
}
|
||||
export type HeatmapSnapshot = HeatmapSnapshotImage;
|
||||
|
||||
export interface HeatmapResult {
|
||||
mode: HeatmapMode;
|
||||
@@ -73,13 +73,6 @@ export async function getHeatmap(
|
||||
});
|
||||
}
|
||||
|
||||
interface SnapshotRow {
|
||||
replayId: string;
|
||||
timestamp: number | string;
|
||||
chunkIndex: number | string | null;
|
||||
eventIndex: number | string | null;
|
||||
}
|
||||
|
||||
interface HeatmapFilterContext {
|
||||
joinQuery: string;
|
||||
queryParams: Record<string, any>;
|
||||
@@ -107,7 +100,7 @@ async function relationalQuery(
|
||||
`
|
||||
: '';
|
||||
|
||||
const pages: HeatmapPage[] = await rawQuery(
|
||||
const rawPages: HeatmapPage[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.url_path as "urlPath",
|
||||
@@ -126,6 +119,7 @@ async function relationalQuery(
|
||||
{ ...filterContext.queryParams, websiteId, eventType, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
const pages = rawPages.filter(page => !shouldSkipSnapshot(page.urlPath));
|
||||
|
||||
if (!urlPath) {
|
||||
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
|
||||
@@ -190,17 +184,13 @@ async function relationalQuery(
|
||||
viewportW: dim?.viewportW ?? null,
|
||||
viewportH: dim?.viewportH ?? null,
|
||||
};
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
filterContext,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -257,156 +247,18 @@ async function relationalQuery(
|
||||
);
|
||||
|
||||
const viewport = pickSnapshotViewport(rawPoints);
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: null,
|
||||
pageH: null,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
filterContext,
|
||||
pageW: viewport?.pageW ?? null,
|
||||
pageH: viewport?.pageH ?? null,
|
||||
});
|
||||
|
||||
return { mode, pages, points: rawPoints, snapshot, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
async function getRelationalSnapshot(
|
||||
rawQuery: typeof prisma.rawQuery,
|
||||
{
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventType: number;
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const pageFilter =
|
||||
pageW && pageH
|
||||
? `
|
||||
and h.page_w = {{pageW}}
|
||||
and h.page_h = {{pageH}}
|
||||
`
|
||||
: '';
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
and h.viewport_w = {{viewportW}}
|
||||
and h.viewport_h = {{viewportH}}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows: SnapshotRow[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.visit_id as "replayId",
|
||||
coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp",
|
||||
h.replay_chunk_index as "chunkIndex",
|
||||
h.replay_event_index as "eventIndex"
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {{websiteId::uuid}}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {{websiteId::uuid}}
|
||||
and h.event_type = {{eventType}}
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
and h.replay_chunk_index is not null
|
||||
and h.replay_event_index is not null
|
||||
and h.replay_time_ms is not null
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
order by
|
||||
h.replay_chunk_index asc nulls last,
|
||||
h.replay_event_index asc nulls last,
|
||||
h.replay_time_ms asc,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return mapSnapshot(rows[0]);
|
||||
}
|
||||
|
||||
const fallbackRows: SnapshotRow[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.visit_id as "replayId",
|
||||
coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp",
|
||||
h.replay_chunk_index as "chunkIndex",
|
||||
h.replay_event_index as "eventIndex"
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {{websiteId::uuid}}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {{websiteId::uuid}}
|
||||
and h.event_type = {{eventType}}
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
order by
|
||||
h.replay_chunk_index asc nulls last,
|
||||
h.replay_event_index asc nulls last,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return mapSnapshot(fallbackRows[0]);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
parameters: HeatmapParameters,
|
||||
@@ -451,11 +303,13 @@ async function clickhouseQuery(
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const pages: HeatmapPage[] = pageRows.map(p => ({
|
||||
urlPath: p.urlPath,
|
||||
count: Number(p.count),
|
||||
sessions: Number(p.sessions),
|
||||
}));
|
||||
const pages: HeatmapPage[] = pageRows
|
||||
.map(p => ({
|
||||
urlPath: p.urlPath,
|
||||
count: Number(p.count),
|
||||
sessions: Number(p.sessions),
|
||||
}))
|
||||
.filter(page => !shouldSkipSnapshot(page.urlPath));
|
||||
|
||||
if (!urlPath) {
|
||||
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
|
||||
@@ -524,17 +378,13 @@ async function clickhouseQuery(
|
||||
viewportH:
|
||||
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
|
||||
};
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
filterContext,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -617,156 +467,18 @@ async function clickhouseQuery(
|
||||
}));
|
||||
|
||||
const viewport = pickSnapshotViewport(points);
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW: null,
|
||||
pageH: null,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
filterContext,
|
||||
pageW: viewport?.pageW ?? null,
|
||||
pageH: viewport?.pageH ?? null,
|
||||
});
|
||||
|
||||
return { mode, pages, points, snapshot, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
async function getClickhouseSnapshot(
|
||||
rawQuery: typeof clickhouse.rawQuery,
|
||||
{
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventType: number;
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const pageFilter =
|
||||
pageW && pageH
|
||||
? `
|
||||
and h.page_w = {pageW:UInt32}
|
||||
and h.page_h = {pageH:UInt32}
|
||||
`
|
||||
: '';
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
and h.viewport_w = {viewportW:UInt32}
|
||||
and h.viewport_h = {viewportH:UInt32}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows = await rawQuery<SnapshotRow[]>(
|
||||
`
|
||||
select
|
||||
toString(h.visit_id) as replayId,
|
||||
ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp,
|
||||
h.replay_chunk_index as chunkIndex,
|
||||
h.replay_event_index as eventIndex
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {websiteId:UUID}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {websiteId:UUID}
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and h.replay_chunk_index is not null
|
||||
and h.replay_event_index is not null
|
||||
and h.replay_time_ms is not null
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
order by
|
||||
h.replay_chunk_index asc,
|
||||
h.replay_event_index asc,
|
||||
h.replay_time_ms asc,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return mapSnapshot(rows[0]);
|
||||
}
|
||||
|
||||
const fallbackRows = await rawQuery<SnapshotRow[]>(
|
||||
`
|
||||
select
|
||||
toString(h.visit_id) as replayId,
|
||||
ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp,
|
||||
h.replay_chunk_index as chunkIndex,
|
||||
h.replay_event_index as eventIndex
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {websiteId:UUID}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {websiteId:UUID}
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${pageFilter}
|
||||
${viewportFilter}
|
||||
order by
|
||||
h.replay_chunk_index asc,
|
||||
h.replay_event_index asc,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW,
|
||||
viewportH,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return mapSnapshot(fallbackRows[0]);
|
||||
}
|
||||
|
||||
function emptyScroll(): HeatmapResult['scroll'] {
|
||||
return {
|
||||
buckets: [],
|
||||
@@ -839,21 +551,6 @@ function pickSnapshotViewport(
|
||||
};
|
||||
}
|
||||
|
||||
function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
replayId: row.replayId,
|
||||
timestamp: Number(row.timestamp),
|
||||
chunkIndex:
|
||||
row.chunkIndex === null || row.chunkIndex === undefined ? null : Number(row.chunkIndex),
|
||||
eventIndex:
|
||||
row.eventIndex === null || row.eventIndex === undefined ? null : Number(row.eventIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function getRelationalHeatmapFilterContext(
|
||||
websiteId: string,
|
||||
filters: QueryFilters,
|
||||
|
||||
@@ -29,12 +29,38 @@ export interface HeatmapEventRow {
|
||||
export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
|
||||
if (!rows?.length) return;
|
||||
|
||||
const normalizedRows = rows.map(r => ({
|
||||
...r,
|
||||
nodeId: toInt(r.nodeId),
|
||||
x: toInt(r.x),
|
||||
y: toInt(r.y),
|
||||
pageX: toInt(r.pageX),
|
||||
pageY: toInt(r.pageY),
|
||||
pageW: toInt(r.pageW),
|
||||
viewportW: toInt(r.viewportW),
|
||||
viewportH: toInt(r.viewportH),
|
||||
pageH: toInt(r.pageH),
|
||||
scrollPct: toScrollPct(r.scrollPct),
|
||||
}));
|
||||
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(rows),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(rows),
|
||||
[PRISMA]: () => relationalQuery(normalizedRows),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(normalizedRows),
|
||||
});
|
||||
}
|
||||
|
||||
function toInt(value: number | null) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
||||
}
|
||||
|
||||
function toScrollPct(value: number | null) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
return prisma.client.heatmapEvent.createMany({
|
||||
data: rows.map(r => ({
|
||||
|
||||
@@ -244,6 +244,7 @@ import { record } from 'rrweb';
|
||||
|
||||
let scrollUrl = location.href;
|
||||
let maxScrollPct = 0;
|
||||
let lastFlushedScrollPct = 0;
|
||||
let scrollTimer = null;
|
||||
|
||||
const computePageMetrics = ({ includeBounds = false } = {}) => {
|
||||
@@ -286,7 +287,7 @@ import { record } from 'rrweb';
|
||||
};
|
||||
|
||||
const flushScroll = () => {
|
||||
if (maxScrollPct <= 0) return;
|
||||
if (maxScrollPct <= 0 || maxScrollPct <= lastFlushedScrollPct) return;
|
||||
const { pageW, pageH } = computePageMetrics({ includeBounds: true });
|
||||
|
||||
addCustomEvent('scroll-progress', {
|
||||
@@ -297,6 +298,7 @@ import { record } from 'rrweb';
|
||||
pageW,
|
||||
pageH,
|
||||
});
|
||||
lastFlushedScrollPct = maxScrollPct;
|
||||
maxScrollPct = 0;
|
||||
};
|
||||
|
||||
@@ -336,14 +338,16 @@ import { record } from 'rrweb';
|
||||
scrollTimer = setTimeout(() => {
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
flushScroll();
|
||||
scrollTimer = null;
|
||||
}, 200);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const onUrlChange = () => {
|
||||
if (location.href === scrollUrl) return;
|
||||
flushScroll();
|
||||
scrollUrl = location.href;
|
||||
lastFlushedScrollPct = 0;
|
||||
addCustomEvent('url-change', { url: scrollUrl });
|
||||
};
|
||||
|
||||
@@ -365,6 +369,7 @@ import { record } from 'rrweb';
|
||||
{
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
flushScroll();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
|
||||
Reference in New Issue
Block a user