195 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
931ae55a1c fix: iframe resize #224 2025-11-10 20:29:02 +01:00
Carl-Gerhard Lindesvärd
bbd30ca6e0 bump: web sdk 1.0.2 2025-11-10 19:44:09 +01:00
Carl-Gerhard Lindesvärd
7e38176422 fix: nextjs 16 support and web logging 2025-11-10 19:40:36 +01:00
Carl-Gerhard Lindesvärd
720b56aba6 public: add supporter 2025-11-08 22:45:22 +01:00
Carl-Gerhard Lindesvärd
49d2c7512a docs: add swift and kotlin 2025-11-08 22:07:01 +01:00
Carl-Gerhard Lindesvärd
c13ea0d044 docs: fix 404 2025-11-08 21:56:15 +01:00
Carl-Gerhard Lindesvärd
437111cfec docs: improve self-hosting docs 2025-11-07 19:51:28 +01:00
Carl-Gerhard Lindesvärd
0ddd1f6d06 fix: broken properties selector 2025-11-07 15:30:57 +01:00
Carl-Gerhard Lindesvärd
ea029648a5 fix: stroke color on pie and default report name 2025-11-07 15:00:39 +01:00
Carl-Gerhard Lindesvärd
e511ae5b9b fix: pie chart 2025-11-07 14:25:41 +01:00
Carl-Gerhard Lindesvärd
c762bd7c95 fix: a lot of minor improvements for dashboard 2025-11-07 12:28:54 +01:00
Carl-Gerhard Lindesvärd
5b1c582023 fix: mixpanel crash 2025-11-07 07:25:17 +01:00
Carl-Gerhard Lindesvärd
a9b9ffa029 fix: add retries with backoff to mixpanel api 2025-11-05 21:41:50 +01:00
Carl-Gerhard Lindesvärd
f67ec2bb6a fix: reduce share errors 2025-11-05 19:03:59 +01:00
Carl-Gerhard Lindesvärd
0663ce6ac8 fix: backfill sessions for imports 2025-11-05 12:52:57 +01:00
Carl-Gerhard Lindesvärd
b8cb13a854 fix: add getReplicatedTableName 2025-11-05 12:13:26 +01:00
Carl-Gerhard Lindesvärd
93e1a2d037 fix: read from primary 2025-11-05 12:03:45 +01:00
Carl-Gerhard Lindesvärd
70682c218f remove async insert 2025-11-05 11:30:26 +01:00
Carl-Gerhard Lindesvärd
212254d31a feat: new importer (#214) 2025-11-05 09:49:36 +01:00
Carl-Gerhard Lindesvärd
b51bc8f3f6 fix: improvements for frontend 2025-11-04 12:26:52 +01:00
Carl-Gerhard Lindesvärd
3474fbd12d feat: duplicate report 2025-11-03 22:14:10 +01:00
Carl-Gerhard Lindesvärd
74754cf65b fix: invalidate dashboard after new report is created 2025-11-03 22:07:12 +01:00
Carl-Gerhard Lindesvärd
7287a05697 fix: handle profiles which are not identified but the event has profile_id 2025-11-03 22:04:25 +01:00
Carl-Gerhard Lindesvärd
c67f7f9578 fix: error when session expired 2025-11-01 12:35:23 +01:00
Carl-Gerhard Lindesvärd
4d7f3e4473 fix: 30 minutes realtime histogram on overview 2025-10-31 12:27:01 +01:00
Carl-Gerhard Lindesvärd
76b68ba5d7 fix: funnel (filter on profile properties) 2025-10-31 12:19:46 +01:00
Carl-Gerhard Lindesvärd
cf6b9f4ab8 docs: improve article 2025-10-31 11:25:07 +01:00
Carl-Gerhard Lindesvärd
322f2c36fd docs: add articles 2025-10-31 10:45:25 +01:00
Carl-Gerhard Lindesvärd
f454449365 fix: read-after-write issues (#215)
* fix: read-after-write issues

* fix: coderabbit comments

* fix: clear cache on invite

* fix: use primary after a read
2025-10-31 09:56:07 +01:00
Carl-Gerhard Lindesvärd
abacf66155 docs: add article 2025-10-30 22:34:53 +01:00
Carl-Gerhard Lindesvärd
ebdf29e196 fix: public 2025-10-30 19:36:21 +01:00
Carl-Gerhard Lindesvärd
931188a8ab fix: timezone issue + improvements for funnel and conversion charts 2025-10-30 11:28:08 +01:00
Carl-Gerhard Lindesvärd
ddc99e9850 feat: ignore secret or cors for client 2025-10-29 21:14:43 +01:00
Carl-Gerhard Lindesvärd
a399209947 fix: conversion chart 2025-10-29 20:43:56 +01:00
Carl-Gerhard Lindesvärd
98b3f50917 fix: share overview 2025-10-29 20:43:49 +01:00
Carl-Gerhard Lindesvärd
e33de4d00e fix: only tag main! 2025-10-29 19:24:59 +01:00
Mr. Will
540de2cd53 fix(dashboard): Remove extra semicolon in settings/members page (#213)
* Remove extra semicolon in settings/members page

* Remove unnecessary semicolon in settings/notifications page
2025-10-27 21:50:21 +01:00
Carl-Gerhard Lindesvärd
b8ad8dfede fix: view event details without session 2025-10-23 15:13:01 +02:00
Carl-Gerhard Lindesvärd
07762211d2 chore: github tags on builds 2025-10-23 12:56:20 +02:00
Carl-Gerhard Lindesvärd
bcfb4f25fb fix: option to allow cluster migrations without relying on self hosting env 2025-10-23 10:50:20 +02:00
Carl-Gerhard Lindesvärd
411021ee04 fix: refetch issues 2025-10-22 18:24:50 +02:00
Carl-Gerhard Lindesvärd
1206f94cf3 fix: refetch and navigation when creating integration 2025-10-22 17:52:27 +02:00
Carl-Gerhard Lindesvärd
e3d3627c16 fix: broken integration 2025-10-22 17:18:49 +02:00
Carl-Gerhard Lindesvärd
49a4f5b8ae fix: self-hosting 2025-10-22 11:58:21 +02:00
Carl-Gerhard Lindesvärd
42d0fb8572 fix self-hosting 2025-10-22 11:38:37 +02:00
Carl-Gerhard Lindesvärd
9790ba8937 feat: prepare supporter self-hosting 2025-10-22 09:44:56 +02:00
Carl-Gerhard Lindesvärd
f958230a66 fix: improve cookie store 2025-10-20 10:29:38 +02:00
Carl-Gerhard Lindesvärd
90c1a813af chore: add SKIP_HOOKS in pre-push 2025-10-20 09:21:54 +02:00
Carl-Gerhard Lindesvärd
87d4ec2f33 fix: use cookie store for theme as well 2025-10-19 21:31:53 +02:00
Carl-Gerhard Lindesvärd
d8a297edf2 fix: fix project access cache issue 2025-10-18 15:00:00 +02:00
Carl-Gerhard Lindesvärd
ea483d41a0 fix: prefetch project and not events on onboarding 2025-10-18 13:35:50 +02:00
Carl-Gerhard Lindesvärd
84a3552daf fix: log session as well 2025-10-18 11:37:15 +02:00
Carl-Gerhard Lindesvärd
95de29dfed fix: auth 2025-10-18 11:33:25 +02:00
Carl-Gerhard Lindesvärd
967a155d5e debug: auth 2025-10-18 11:08:40 +02:00
Carl-Gerhard Lindesvärd
93a3c9b0a9 fix: current selected item on billing 2025-10-18 11:03:43 +02:00
Carl-Gerhard Lindesvärd
2928857389 fix: more links 2025-10-18 10:33:09 +02:00
Carl-Gerhard Lindesvärd
21fc076368 fix: checkout 2025-10-18 10:28:56 +02:00
Carl-Gerhard Lindesvärd
4b831204b7 fix: sign up images 2025-10-18 10:06:05 +02:00
Carl-Gerhard Lindesvärd
17f30fc9a3 fix: optimize images 2025-10-17 21:27:17 +02:00
Carl-Gerhard Lindesvärd
42361a0caa fix: remove dev credentials 2025-10-17 21:19:43 +02:00
Carl-Gerhard Lindesvärd
077a47a263 fix: improvements in the dashboard 2025-10-17 18:51:55 +02:00
Carl-Gerhard Lindesvärd
c8bea685db fix: invalidate queries better 2025-10-17 11:01:20 +02:00
Carl-Gerhard Lindesvärd
4ccabc5fa3 fix: overview metrics 2025-10-16 14:42:07 +02:00
Carl-Gerhard Lindesvärd
1187bcac0a fix: better previous on overview 2025-10-16 14:34:32 +02:00
Carl-Gerhard Lindesvärd
56ad8854eb chore: enable worker logs 2025-10-16 14:21:12 +02:00
Carl-Gerhard Lindesvärd
7a63885d38 fix: remove unused 2025-10-16 13:14:43 +02:00
Carl-Gerhard Lindesvärd
4d060cb7d2 fix: trpc 2025-10-16 13:14:33 +02:00
Carl-Gerhard Lindesvärd
afd3f24f70 fix: worker 2025-10-16 13:08:25 +02:00
Carl-Gerhard Lindesvärd
44e51938cc fix: changelog... 2025-10-16 13:06:46 +02:00
Carl-Gerhard Lindesvärd
b59c054ac0 chore: update readme 2025-10-16 12:41:16 +02:00
Carl-Gerhard Lindesvärd
81a7e5d62e feat: dashboard v2, esm, upgrades (#211)
* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
2025-10-16 12:27:44 +02:00
Carl-Gerhard Lindesvärd
436e81ecc9 fix(api): allow multiple cors origins 2025-10-10 15:42:52 +02:00
Carl-Gerhard Lindesvärd
7d4a4c1944 bump groupmq 2025-10-09 10:10:30 +02:00
Carl-Gerhard Lindesvärd
e7c21bc92c fix: remove old event queue, cleaned up session handling, remove hacks 2025-10-09 09:25:52 +02:00
Carl-Gerhard Lindesvärd
a11f87dc3c improve: logging for session handling 2025-10-08 10:44:02 +02:00
Carl-Gerhard Lindesvärd
5b2f09f29c fix: always use currentDeviceId on client request to make sure they arrive in same group 2025-10-08 07:46:50 +02:00
Carl-Gerhard Lindesvärd
174a30d515 bump groupmq 2025-10-07 18:35:12 +02:00
Carl-Gerhard Lindesvärd
6bc4f6fbd2 bump groupmq 2025-10-07 12:59:13 +02:00
Carl-Gerhard Lindesvärd
e4bc21fc2e fix: set random groupId if events from a server without a profile id 2025-10-07 09:57:12 +02:00
Carl-Gerhard Lindesvärd
af580333b4 fix: bump groupmq, fix stalled sessions in buffers, improve heavy event count 2025-10-06 22:20:14 +02:00
Carl-Gerhard Lindesvärd
b3e06e985d chore: update bots and series icons 2025-10-05 12:01:06 +02:00
Carl-Gerhard Lindesvärd
d06589831d chore: update groupmq 2025-10-05 12:00:50 +02:00
Carl-Gerhard Lindesvärd
280065c2c4 fix: parse cookie domain (#185)
* chore: update bots and referrers

* fix: handle cookie domain better
2025-10-04 21:22:09 +02:00
Carl-Gerhard Lindesvärd
0b4fcbad69 feat: use groupmq instead of bullmq for incoming events (#206)
* wip

* wip working group queue

* wip

* wip

* wip

* fix: groupmq package (tests failed)

* minor fixes

* fix: zero is fine for duration

* add logger

* fix: make buffers more lightweight

* bump groupmq

* new buffers and bump groupmq

* fix: buffers based on comments

* fix: use profileId as groupId if exists

* bump groupmq

* add concurrency env for only events
2025-10-04 21:07:55 +02:00
Carl-Gerhard Lindesvärd
ca4a880acd feat: graceful shutdown (#205)
* feat: graceful shutdown

* comments by coderabbit

* fix
2025-10-02 11:03:54 +02:00
Mouhamadou Tidiane El Bachir Diop
5092b6ae51 fix(sdk+express): add support for express V5 (#201)
* add support for express V5

* Add Support for v4 to v5 [major changes between versions]

* Update packages/sdks/express/package.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-26 20:32:58 +02:00
Ashutosh Singh
c10b89dcf7 feat: log limit added (#202) 2025-09-26 20:29:46 +02:00
Carl-Gerhard Lindesvärd
a145bd6cfc fix(docs): add insights to menu 2025-09-09 07:23:21 +02:00
Carl-Gerhard Lindesvärd
df32bb04a0 feat(api): add insights endpoints 2025-09-08 22:07:09 +02:00
Carl-Gerhard Lindesvärd
d4a1eb88b8 fix: handle new depends_on object instead of array #195 2025-09-04 09:57:57 +02:00
Carl-Gerhard Lindesvärd
52b86682e2 fix: ensure we dont bloat notification table with any other notification than app 2025-09-03 21:39:21 +02:00
Carl-Gerhard Lindesvärd
e5cacb73df fix(api): ensure we always have profile in cache (before inserted to clickhouse) 2025-08-13 20:57:42 +02:00
Carl-Gerhard Lindesvärd
31ccfb8b5f improve: add profile to notifications templates eg {{profile.email}} 2025-08-08 22:38:03 +02:00
Carl-Gerhard Lindesvärd
26efb7a94d fix: display name for events 2025-08-08 14:13:56 +02:00
Carl-Gerhard Lindesvärd
113ab0a28d chore: update bots and referrers 2025-07-02 12:16:01 +02:00
Carl-Gerhard Lindesvärd
a6993abb6f fix: self-hosting had trial 2025-06-28 06:59:57 +02:00
Carl-Gerhard Lindesvärd
7bda9adf86 improve(public): description 2025-06-26 22:48:54 +02:00
Carl-Gerhard Lindesvärd
f96c9b4769 improve(public): list all prices instead of slider + rewrite article 2025-06-26 22:41:09 +02:00
Carl-Gerhard Lindesvärd
77cd74816c sdk(express): add user-agent as well 2025-06-25 12:27:28 +02:00
Carl-Gerhard Lindesvärd
28f36da68e fix(coolify): bump version 2025-06-23 22:53:08 +02:00
Carl-Gerhard Lindesvärd
92d62c3e5c improve: prepare for coolify and general self-hosting improvements (#175)
* fix(self-hosting): improve docker compose, add healthchecks, rename env SELF_HOSTED

* improve(db): improve initial migration when no data exists

* fix(db): misstakes were made

* improve(dashboard): better curl preview depending on project type

* fix(db): fix migrations

* fix(onboarding): ensure we publish event correctly

* wip

* fix: curl preview

* add coolify template

* fix(dashboard): page -> route

* fix

* fix env
2025-06-23 22:21:11 +02:00
Carl-Gerhard Lindesvärd
4a2dbc5c4d fix(public): add robots.txt 2025-06-20 09:07:26 +02:00
Carl-Gerhard Lindesvärd
bbe0192a19 fix(public): ts issue 2025-06-18 14:50:30 +02:00
Carl-Gerhard Lindesvärd
5c6d71f176 improve(public): re-design landing page a bit 2025-06-18 14:38:14 +02:00
Carl-Gerhard Lindesvärd
9b16bbaccd feature: prepare for demo mode 2025-06-17 23:10:23 +02:00
Carl-Gerhard Lindesvärd
02be728499 fix(docs): remove alias from docs for now 2025-06-12 12:04:34 +02:00
Carl-Gerhard Lindesvärd
ad8dfb511d fix(dashboard): ui glitch in event details #169 2025-06-11 22:31:27 +02:00
Carl-Gerhard Lindesvärd
e3e9e60b25 fix(api): handle profile filters/breakdowns better 2025-06-11 22:31:27 +02:00
Carl-Gerhard Lindesvärd
82239a7d9a improve(dashboard): the flow of creating a project 2025-06-11 22:31:27 +02:00
T04435
5e023d0227 chore(dashboard): added report name on save report toast (#174) 2025-06-10 22:38:51 +02:00
Carl-Gerhard Lindesvärd
3560276095 fix(dashboard): make new events combobox case sensitive 2025-06-10 08:43:26 +02:00
Carl-Gerhard Lindesvärd
1cfd7e7e1b fix(dashboard): dockerfile + other self-hosting issues 2025-06-09 17:22:56 +02:00
Carl-Gerhard Lindesvärd
5445d6309e test: add vitest
* feature(root): add vitest and some basic tests

* fix(test): after rebase + added referrars test and more sites

* fix(test): test broken after rebase

* fix(test): provide db url to make prisma happy

* fix tests
2025-06-06 19:14:18 +02:00
Carl-Gerhard Lindesvärd
09c83ddeb4 fix(dashboard): scroll issues in modals 2025-06-06 14:20:49 +02:00
Carl-Gerhard Lindesvärd
34414e1d3e feat(geo): make geo a package instead of service (#161) 2025-06-06 05:56:54 +02:00
Carl-Gerhard Lindesvärd
f59bcfba3c improve(dashboard): better event selector and other improvements 2025-06-05 11:28:06 +02:00
Carl-Gerhard Lindesvärd
cd5dce02b8 fix(dashboard): add property selector 2025-06-04 13:12:27 +02:00
Carl-Gerhard Lindesvärd
5b1e94e9ad fix(api): filter empty values when using sum and average, added min and max 2025-06-04 12:45:26 +02:00
Carl-Gerhard Lindesvärd
5c5154ee86 fix: clear cache for organizations when subscriptions updates 2025-06-04 12:00:45 +02:00
Carl-Gerhard Lindesvärd
92210c1b3f fix(api): broken overview when filter path 2025-06-04 11:32:48 +02:00
steffenabel
e08718724e fix: Fix broken discord link (#163) 2025-06-04 10:11:47 +02:00
Carl-Gerhard Lindesvärd
0d58a5bf0c improve(queue): how we handle incoming events and session ends 2025-06-03 21:19:01 +02:00
Carl-Gerhard Lindesvärd
39775142e2 fix(api): ensure we get correct date format 2025-05-27 09:32:45 +02:00
Carl-Gerhard Lindesvärd
95a30a660c fix(api): ensure we use correct date format to get chart (export) 2025-05-23 12:00:24 +02:00
Carl-Gerhard Lindesvärd
680727355b feature(dashboard,api): add timezone support
* feat(dashboard): add support for today, yesterday etc (timezones)

* fix(db): escape js dates

* fix(dashboard): ensure we support default timezone

* final fixes

* remove complete series and add sql with fill instead
2025-05-23 11:26:44 +02:00
Carl-Gerhard Lindesvärd
46bfeee131 fix(dashboard): speed up project cards on project list 2025-05-21 22:06:28 +02:00
Carl-Gerhard Lindesvärd
ce184b157f fix(api): filter events by profileId for export route 2025-05-21 20:52:26 +02:00
Carl-Gerhard Lindesvärd
af7146f555 fix(dashboard): add weekly interval #154 2025-05-14 22:28:14 +02:00
Carl-Gerhard Lindesvärd
0eed1e168f fix(dashboard): fill empty days for project card series 2025-05-14 21:04:47 +02:00
Carl-Gerhard Lindesvärd
023a2852c7 fix(dashboard): event field value cant have key as prop 2025-05-14 09:36:28 +02:00
Carl-Gerhard Lindesvärd
e0c356701f fix(dashboard): add typing for data table meta 2025-05-14 09:20:34 +02:00
Carl-Gerhard Lindesvärd
ab2f711880 fix(dashboard): fix previous data for time series 2025-05-13 23:10:08 +02:00
Carl-Gerhard Lindesvärd
2cd358e1bb improve(dashboard): better event details 2025-05-13 23:09:37 +02:00
Carl-Gerhard Lindesvärd
dd39ff70a9 feature(dashboard): customize event columns 2025-05-09 21:00:27 +02:00
Carl-Gerhard Lindesvärd
584a6d21f1 fix(dashboard): minor adjustments here and there 2025-05-09 21:00:27 +02:00
Akash Prasad
60ed005fe1 docs: fix typo in Vue SDK documentation (react -> vue) (#136) 2025-05-08 21:20:46 +02:00
Pablo Lopez
4adebf40ac added missing TrackProperties import on web and updated OpenPanelMethods to fix type errors (#149) 2025-05-08 21:20:22 +02:00
henri
eaab2aad22 docs(nextjs): fix small example mistake (#152) 2025-05-08 21:19:54 +02:00
Carl-Gerhard Lindesvärd
d4c2f9ca9f improve(public): ensure to use next/image 2025-05-06 22:34:36 +02:00
Carl-Gerhard Lindesvärd
2d8f6f36f6 sdk(astro,nextjs): add astro sdk and ensure window.op always first on nextjs 2025-05-06 22:18:21 +02:00
Carl-Gerhard Lindesvärd
0189b922f2 test: edge headers 2025-04-29 09:36:33 +02:00
Carl-Gerhard Lindesvärd
d0e90dfa79 improve(dashboard): make pages page better (ux and features) 2025-04-17 09:27:50 +02:00
Carl-Gerhard Lindesvärd
89ab8d08de improve(api): increase limit 1000, allow both projectId and project_id 2025-04-16 21:40:22 +02:00
Carl-Gerhard Lindesvärd
e2254e78a9 fix(dashboard): breakdowns on profile properties 2025-04-16 20:52:22 +02:00
Carl-Gerhard Lindesvärd
bfa1ee70e6 fix(dashboard): able to filter on event names 2025-04-16 11:36:54 +02:00
Carl-Gerhard Lindesvärd
be3c18b677 feature(dashboard): filter on profile properties and support drag n drop for events 2025-04-16 11:08:58 +02:00
Carl-Gerhard Lindesvärd
34769a5d58 feat(ai): add ai chat to dashboard 2025-04-15 14:30:21 +02:00
Carl-Gerhard Lindesvärd
804a9c8056 improve(dashboard): add settings to conversions and add average line 2025-04-07 23:46:39 +02:00
Carl-Gerhard Lindesvärd
c3199e12e3 fix(api): use left join on sessions when doing funnels 2025-04-07 23:03:54 +02:00
Christian Alares
62dbe7e7c6 fix(docs): edit imported type in docs for custom filter (#139) 2025-04-02 11:39:13 +02:00
Carl-Gerhard Lindesvärd
c5d25779c6 fix(dashboard): broken funnels on profile_id 2025-04-02 11:21:51 +02:00
Carl-Gerhard Lindesvärd
a6762b90ca improve(payments): handling free products and subscriptions 2025-04-01 21:27:11 +02:00
Carl-Gerhard Lindesvärd
1e99c1843a fix(docs): broken link 2025-04-01 12:41:00 +02:00
Carl-Gerhard Lindesvärd
d38ccb4717 fix(worker): better deletion of project 2025-04-01 10:40:59 +02:00
Carl-Gerhard Lindesvärd
58c4a6a741 add(public): new funnel article 2025-03-31 23:09:51 +02:00
Carl-Gerhard Lindesvärd
e58e898683 fix(db): read invites from primary instead of replica (avoid race condition) #134 2025-03-31 19:55:14 +02:00
Thejana Weththasinghe
6ae85a1fe8 fix(docs): code block max height (#138) 2025-03-31 13:51:47 +02:00
Carl-Gerhard Lindesvärd
fe87b65237 fix(dashboard): hydrate issues 2025-03-30 23:00:01 +02:00
Carl-Gerhard Lindesvärd
ec5207947b fix(dashboard): crashes when trial expired for some intervals 2025-03-30 22:59:49 +02:00
Carl-Gerhard Lindesvärd
a9c664dcfb feat(email): send trial ending soon mails 2025-03-30 20:58:17 +02:00
Carl-Gerhard Lindesvärd
0f0bb13107 fix(dashboard): restrict data if trial ended 2025-03-30 19:53:08 +02:00
Carl-Gerhard Lindesvärd
ecda9a7d1b fix(dashboard,api): show correct percentage on retention 2025-03-28 22:36:08 +01:00
Carl-Gerhard Lindesvärd
e9133aa5a8 trigger(build) 2025-03-28 09:37:24 +01:00
Carl-Gerhard Lindesvärd
d4fcc82fc3 fix(ts) 2025-03-28 09:35:39 +01:00
Carl-Gerhard Lindesvärd
56c7283ec6 chore(root): add git hooks 2025-03-28 09:27:50 +01:00
Carl-Gerhard Lindesvärd
8a21fadc0d feature(dashboard): add conversion rate graph 2025-03-28 09:21:10 +01:00
Carl-Gerhard Lindesvärd
be358ea886 fix(dashboard): use correct link to billing 2025-03-27 23:08:25 +01:00
Carl-Gerhard Lindesvärd
e6a65d694a fix(dashboard): css issues with large modals on overview 2025-03-27 00:42:34 +01:00
Carl-Gerhard Lindesvärd
d4c1c15174 feature(dashboard): add trial ended popup 2025-03-27 00:20:21 +01:00
Carl-Gerhard Lindesvärd
ee80b47b0d fix(dashboard): remove self-hosting tag 🤦 2025-03-26 19:34:15 +01:00
Carl-Gerhard Lindesvärd
c540778825 feature(public): add supporter page 2025-03-26 14:48:36 +01:00
Carl-Gerhard Lindesvärd
1ce8ab8fc8 trigger build 2025-03-24 19:17:44 +01:00
Carl-Gerhard Lindesvärd
f06f1b7b8f fix(ts) 2025-03-24 19:06:38 +01:00
Carl-Gerhard Lindesvärd
b77ee71445 fix(dashboard): cancel subscription before new checkout if free 2025-03-24 18:49:17 +01:00
Carl-Gerhard Lindesvärd
a26e64e80d fix(dashboard): format currency better on all places 2025-03-24 11:23:49 +01:00
Carl-Gerhard Lindesvärd
490d12b24d fix(dashboard): correct prices 2025-03-24 11:22:20 +01:00
Carl-Gerhard Lindesvärd
7ab869ff45 fix: remove free tier 2025-03-24 10:57:20 +01:00
Carl-Gerhard Lindesvärd
76239314dd fix(dashboard): css on billing table 2025-03-24 07:27:14 +01:00
Carl-Gerhard Lindesvärd
f313356096 fix(funnel): fallback to 0 if nan for funnel 2025-03-23 22:25:05 +01:00
Carl-Gerhard Lindesvärd
aeb9abcb13 fix(ts) 2025-03-23 22:10:09 +01:00
Carl-Gerhard Lindesvärd
c03ee3f617 fix(dashboard): show hours when needed and remove bugs for filters 2025-03-23 21:46:03 +01:00
Carl-Gerhard Lindesvärd
584c787799 fix(buffer): ensure we only set path and origin if its set and no previous value was set 2025-03-23 21:16:08 +01:00
Carl-Gerhard Lindesvärd
800a484ad4 improve(api): make retention fast again 2025-03-23 21:14:43 +01:00
Carl-Gerhard Lindesvärd
1257381bf2 feature(dashboard): improved funnels 2025-03-23 21:13:32 +01:00
Carl-Gerhard Lindesvärd
bb018d55ca fix(dashboard): use correct overview metrics on share page 2025-03-21 19:30:44 +01:00
Carl-Gerhard Lindesvärd
d3ef034a5d fix(dashboard): force prefetch for project links 2025-03-20 20:54:03 +01:00
Carl-Gerhard Lindesvärd
38ff55f203 fix(dashboard): set initial size for map 2025-03-20 20:52:49 +01:00
Carl-Gerhard Lindesvärd
b59216fb7d fix(session): use correct batch size 2025-03-20 10:55:03 +01:00
Carl-Gerhard Lindesvärd
8d50213ed9 fix(session): negative duration #2 2025-03-20 10:38:41 +01:00
Carl-Gerhard Lindesvärd
cf01d7a545 fix(session): negative duration 2025-03-20 10:31:19 +01:00
Carl-Gerhard Lindesvärd
7c1d36a9f3 fix(worker): add validation to worker dockerfile 2025-03-20 10:13:47 +01:00
Carl-Gerhard Lindesvärd
563551992d trigger(build) 2025-03-20 09:47:48 +01:00
Carl-Gerhard Lindesvärd
a1eb4a296f feature(dashboard): refactor overview
fix(lint)
2025-03-20 09:40:01 +01:00
Carl-Gerhard Lindesvärd
b035c0d586 fix(polar): filter out custom products 2025-03-19 09:36:07 +01:00
Carl-Gerhard Lindesvärd
1784a48bfc fix(dashboard): ensure we only pass dates to formatdate 2025-03-13 21:17:20 +01:00
1028 changed files with 130130 additions and 57790 deletions

3
.cursorrules Normal file
View File

@@ -0,0 +1,3 @@
- When we write clickhouse queries you should always use the custom query builder we have in
- `./packages/db/src/clickhouse/query-builder.ts`
- `./packages/db/src/clickhouse/query-functions.ts`

View File

@@ -1,19 +1,20 @@
name: Docker Build and Push
on:
workflow_dispatch:
push:
branches: [ "main" ]
# branches: [ "main" ]
paths:
- 'apps/api/**'
- 'apps/worker/**'
- 'apps/public/**'
- 'packages/**'
- '!packages/sdks/**'
- '**Dockerfile'
- '.github/workflows/**'
- "apps/api/**"
- "apps/worker/**"
- "apps/public/**"
- "packages/**"
- "!packages/sdks/**"
- "**Dockerfile"
- ".github/workflows/**"
env:
repo_owner: 'openpanel-dev'
repo_owner: "openpanel-dev"
jobs:
changes:
@@ -22,12 +23,13 @@ jobs:
api: ${{ steps.filter.outputs.api }}
worker: ${{ steps.filter.outputs.worker }}
public: ${{ steps.filter.outputs.public }}
dashboard: ${{ steps.filter.outputs.dashboard }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
base: 'main'
base: "main"
filters: |
api:
- 'apps/api/**'
@@ -41,22 +43,36 @@ jobs:
- 'apps/public/**'
- 'packages/**'
- '.github/workflows/**'
dashboard:
- 'apps/start/**'
- 'packages/**'
- '.github/workflows/**'
lint-and-test:
needs: changes
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' }}
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' || needs.changes.outputs.dashboard == 'true' }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping || exit 1"
--health-interval 5s
--health-timeout 3s
--health-retries 20
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: "20"
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
shell: bash
run: |
@@ -69,32 +85,43 @@ jobs:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Codegen
run: pnpm codegen
# - name: Run Biome
# run: pnpm lint
- name: Run TypeScript checks
run: pnpm typecheck
# - name: Run TypeScript checks
# run: pnpm typecheck
# - name: Run tests
# run: pnpm test
build-and-push-api:
permissions:
packages: write
contents: write
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.api == 'true' }}
runs-on: ubuntu-latest
steps:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate tags
id: tags
run: |
# Sanitize branch name by replacing / with -
BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
# Get first 4 characters of commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4)
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -114,21 +141,48 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/api:latest
ghcr.io/${{ env.repo_owner }}/api:${{ github.sha }}
ghcr.io/${{ env.repo_owner }}/api:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
build-args: |
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
- name: Create/Update API tag
if: github.ref == 'refs/heads/main'
run: |
# Delete existing tag if it exists
if git tag -l "api" | grep -q "api"; then
git tag -d "api"
echo "Deleted local tag: api"
fi
# Create new tag
git tag "api" "${{ github.sha }}"
echo "Created tag: api"
# Push tag to remote
git push origin "api" --force
echo "Pushed tag: api"
build-and-push-worker:
permissions:
packages: write
contents: write
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.worker == 'true' }}
runs-on: ubuntu-latest
steps:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate tags
id: tags
run: |
# Sanitize branch name by replacing / with -
BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
# Get first 4 characters of commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4)
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -148,7 +202,84 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/worker:latest
ghcr.io/${{ env.repo_owner }}/worker:${{ github.sha }}
ghcr.io/${{ env.repo_owner }}/worker:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
build-args: |
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
- name: Create/Update Worker tag
if: github.ref == 'refs/heads/main'
run: |
# Delete existing tag if it exists
if git tag -l "worker" | grep -q "worker"; then
git tag -d "worker"
echo "Deleted local tag: worker"
fi
# Create new tag
git tag "worker" "${{ github.sha }}"
echo "Created tag: worker"
# Push tag to remote
git push origin "worker" --force
echo "Pushed tag: worker"
build-and-push-dashboard:
permissions:
packages: write
contents: write
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.dashboard == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate tags
id: tags
run: |
# Sanitize branch name by replacing / with -
BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
# Get first 4 characters of commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4)
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: apps/start/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/dashboard:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
build-args: |
NO_CLOUDFLARE=1
- name: Create/Update Dashboard tag
if: github.ref == 'refs/heads/main'
run: |
# Delete existing tag if it exists
if git tag -l "dashboard" | grep -q "dashboard"; then
git tag -d "dashboard"
echo "Deleted local tag: dashboard"
fi
# Create new tag
git tag "dashboard" "${{ github.sha }}"
echo "Created tag: dashboard"
# Push tag to remote
git push origin "dashboard" --force
echo "Pushed tag: dashboard"

5
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.secrets
packages/db/src/generated/prisma
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt
@@ -8,6 +9,7 @@ dump-*
.sql
tmp
docker/data*
*.mmdb
# Logs
@@ -167,6 +169,9 @@ dist
.vscode-test
# Wrangler build artifacts and cache
.wrangler/
# yarn v2
.yarn/cache

View File

@@ -23,11 +23,42 @@
<br />
</p>
Openpanel is a powerful analytics platform that captures and visualizes user behavior across web, mobile apps, and backend services. It combines the power of Mixpanel with the simplicity of Plausible.
Openpanel is an open-source web and product analytics platform that combines the power of Mixpanel with the ease of Plausible and one of the best Google Analytics replacements.
## Disclaimer
## ✨ Features
> Hey folks 👋🏻 Just a friendly heads-up: we're still in the early stages of this project. We have migrated from pages to app dir and made some major changes during the development of Openpanel, so everything is not perfect.
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
- **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts
- **🌍 Privacy-First**: Cookieless tracking and GDPR compliance
- **🚀 Developer-Friendly**: Comprehensive SDKs and API access
- **📦 Self-Hosted**: Full control over your data and infrastructure
- **💸 Transparent Pricing**: No hidden costs or usage limits
- **🛠️ Custom Dashboards**: Flexible chart creation and data visualization
- **📱 Multi-Platform**: Web, mobile (iOS/Android), and server-side tracking
## 📊 Analytics Platform Comparison
| Feature | OpenPanel | Mixpanel | GA4 | Plausible |
|----------------------------------------|-----------|----------|-----------|-----------|
| ✅ Open-source | ✅ | ❌ | ❌ | ✅ |
| 🧩 Self-hosting supported | ✅ | ❌ | ❌ | ✅ |
| 🔒 Cookieless by default | ✅ | ❌ | ❌ | ✅ |
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
| 📦 SDKs (Web, Swift, Kotlin, ReactNative) | ✅ | ✅ | ✅ | ❌ |
| 💸 Transparent pricing | ✅ | ❌ | ✅* | ✅ |
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅*** Plausible has simple goals
## Stack
@@ -37,6 +68,7 @@ Openpanel is a powerful analytics platform that captures and visualizes user beh
- **Clickhouse** - storing events
- **Redis** - cache layer, pub/sub and queue
- **BullMQ** - queue
- **GroupMQ** - for grouped queue
- **Resend** - email
- **Arctic** - oauth
- **Oslo** - auth
@@ -63,15 +95,6 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
- Node
- pnpm
### Setup
Add the following to your hosts file (`/etc/hosts` on mac/linux or `C:\Windows\System32\drivers\etc\hosts` on windows). This will be your local domain.
```
127.0.0.1 op.local
127.0.0.1 api.op.local
```
### Start
```bash
@@ -83,8 +106,8 @@ pnpm dev
You can now access the following:
- Dashboard: https://op.local
- API: https://api.op.local
- Dashboard: https://localhost:3000
- API: https://api.localhost:3333
- Bullboard (queue): http://localhost:9999
- `pnpm dock:ch` to access clickhouse terminal
- `pnpm dock:redis` to access redis terminal

View File

@@ -1,4 +1,4 @@
ARG NODE_VERSION=20.15.1
ARG NODE_VERSION=22.20.0
FROM node:${NODE_VERSION}-slim AS base
@@ -28,6 +28,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json ./apps/api/
# Packages
COPY packages/db/package.json packages/db/
COPY packages/geo/package.json packages/geo/
COPY packages/trpc/package.json packages/trpc/
COPY packages/auth/package.json packages/auth/
COPY packages/json/package.json packages/json/
@@ -42,6 +43,7 @@ COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY patches ./patches
# BUILD
FROM base AS build
@@ -59,12 +61,14 @@ COPY apps/api ./apps/api
COPY packages ./packages
COPY tooling ./tooling
RUN pnpm db:codegen && \
RUN pnpm codegen && \
pnpm --filter api run build
# PROD
FROM base AS prod
ENV npm_config_build_from_source=true
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
@@ -74,12 +78,14 @@ WORKDIR /app
COPY --from=build /app/package.json ./
COPY --from=build /app/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod && \
pnpm rebuild && \
pnpm store prune
# FINAL
FROM base AS runner
ENV NODE_ENV=production
ENV npm_config_build_from_source=true
WORKDIR /app
@@ -91,6 +97,7 @@ COPY --from=build /app/apps/api ./apps/api
# Packages
COPY --from=build /app/packages/db ./packages/db
COPY --from=build /app/packages/geo ./packages/geo
COPY --from=build /app/packages/auth ./packages/auth
COPY --from=build /app/packages/trpc ./packages/trpc
COPY --from=build /app/packages/json ./packages/json
@@ -104,6 +111,7 @@ COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen
WORKDIR /app/apps/api

View File

@@ -1,16 +1,18 @@
{
"name": "@openpanel/api",
"version": "0.0.1",
"version": "0.0.4",
"type": "module",
"scripts": {
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsdown",
"testing": "API_PORT=3333 pnpm dev",
"start": "node dist/index.js",
"build": "rm -rf dist && tsup",
"gen:referrers": "jiti scripts/get-referrers.ts && biome format --write src/referrers/index.ts",
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/openai": "^1.3.12",
"@fastify/compress": "^8.0.1",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0",
@@ -19,7 +21,9 @@
"@node-rs/argon2": "^2.0.2",
"@openpanel/auth": "workspace:^",
"@openpanel/common": "workspace:*",
"@openpanel/constants": "workspace:*",
"@openpanel/db": "workspace:*",
"@openpanel/geo": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
@@ -28,13 +32,13 @@
"@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*",
"@trpc/server": "^10.45.2",
"bcrypt": "^5.1.1",
"@trpc/server": "^11.6.0",
"ai": "^4.2.10",
"fast-json-stable-hash": "^1.0.3",
"fastify": "^5.2.1",
"fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0",
"ico-to-png": "^0.2.2",
"groupmq": "1.0.0-next.19",
"jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1",
"request-ip": "^3.3.0",
@@ -45,7 +49,7 @@
"svix": "^1.24.0",
"url-metadata": "^4.1.1",
"uuid": "^9.0.1",
"zod": "^3.22.4"
"zod": "catalog:"
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
@@ -60,7 +64,7 @@
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.14",
"js-yaml": "^4.1.0",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
"tsdown": "0.14.2",
"typescript": "catalog:"
}
}

View File

@@ -1,5 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import yaml from 'js-yaml';
async function main() {

View File

@@ -1,56 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
// extras
const extraReferrers = {
'bsky.app': { type: 'social', name: 'Bluesky' },
};
function transform(data: any) {
const obj: Record<string, unknown> = {};
for (const type in data) {
for (const name in data[type]) {
const domains = data[type][name].domains ?? [];
for (const domain of domains) {
obj[domain] = {
type,
name,
};
}
}
}
return obj;
}
async function main() {
// Get document, or throw exception on error
try {
const data = await fetch(
'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json',
).then((res) => res.json());
fs.writeFileSync(
path.resolve(__dirname, '../src/referrers/index.ts'),
[
'// This file is generated by the script get-referrers.ts',
'',
'// The data is fetch from snowplow-referer-parser https://github.com/snowplow-referer-parser/referer-parser',
`// The orginal referers.yml is based on Piwik's SearchEngines.php and Socials.php, copyright 2012 Matthieu Aubry and available under the GNU General Public License v3.`,
'',
`const referrers: Record<string, { type: string, name: string }> = ${JSON.stringify(
{
...transform(data),
...extraReferrers,
},
)} as const;`,
'export default referrers;',
].join('\n'),
'utf-8',
);
} catch (e) {
console.log(e);
}
}
main();

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import * as faker from '@faker-js/faker';
import { generateId } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server';
import { ClientType, db } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis';
import { v4 as uuidv4 } from 'uuid';
const DOMAIN_COUNT = 5;
@@ -17,11 +18,7 @@ interface Track {
type: 'track';
payload: {
name: string;
properties: {
__referrer: string;
__path: string;
__title: string;
};
properties: Record<string, string>;
};
}
@@ -264,25 +261,228 @@ function insertFakeEvents(events: Event[]) {
}
async function simultaneousRequests() {
const events = require('./mock-basic.json');
const screenView = events[0]!;
const event = JSON.parse(JSON.stringify(events[0]));
event.track.payload.name = 'click_button';
delete event.track.payload.properties.__referrer;
await getRedisCache().flushdb();
await new Promise((resolve) => setTimeout(resolve, 1000));
const sessions: {
ip: string;
referrer: string;
userAgent: string;
track: Record<string, string>[];
}[] = [
{
ip: '122.168.1.101',
referrer: 'https://www.google.com',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
track: [
{ name: 'screen_view', path: '/home', parallel: '1' },
{ name: 'button_click', element: 'signup', parallel: '1' },
{ name: 'article_viewed', articleId: '123', parallel: '1' },
{ name: 'screen_view', path: '/pricing', parallel: '1' },
{ name: 'screen_view', path: '/blog', parallel: '1' },
],
},
{
ip: '192.168.1.101',
referrer: 'https://www.bing.com',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
track: [{ name: 'screen_view', path: '/landing' }],
},
{
ip: '192.168.1.102',
referrer: 'https://www.google.com',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15',
track: [{ name: 'screen_view', path: '/about' }],
},
{
ip: '192.168.1.103',
referrer: '',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1',
track: [
{ name: 'screen_view', path: '/home' },
{ name: 'form_submit', form: 'contact' },
],
},
{
ip: '192.168.1.104',
referrer: '',
userAgent:
'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36',
track: [{ name: 'screen_view', path: '/products' }],
},
{
ip: '203.0.113.101',
referrer: 'https://www.facebook.com',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0',
track: [
{ name: 'video_play', videoId: 'abc123' },
{ name: 'button_click', element: 'subscribe' },
],
},
{
ip: '203.0.113.55',
referrer: 'https://www.twitter.com',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1',
track: [
{ name: 'screen_view', path: '/blog' },
{ name: 'scroll', depth: '50%' },
],
},
{
ip: '198.51.100.20',
referrer: 'https://www.linkedin.com',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.902.62 Safari/537.36 Edg/92.0.902.62',
track: [{ name: 'button_click', element: 'download' }],
},
{
ip: '198.51.100.21',
referrer: 'https://www.google.com',
userAgent:
'Mozilla/5.0 (Linux; Android 10; SM-A505FN) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36',
track: [
{ name: 'screen_view', path: '/services' },
{ name: 'button_click', element: 'learn_more' },
],
},
{
ip: '203.0.113.60',
referrer: '',
userAgent:
'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15A5341f Safari/604.1',
track: [{ name: 'form_submit', form: 'feedback' }],
},
{
ip: '208.22.132.143',
referrer: '',
userAgent:
'Mozilla/5.0 (Linux; arm_64; Android 10; MAR-LX1H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 YaBrowser/20.4.4.24.00 (alpha) SA/0 Mobile Safari/537.36',
track: [
{ name: 'screen_view', path: '/landing' },
{ name: 'screen_view', path: '/pricing' },
{ name: 'screen_view', path: '/blog' },
{ name: 'screen_view', path: '/blog/post-1', parallel: '1' },
{ name: 'screen_view', path: '/blog/post-2', parallel: '1' },
{ name: 'button_click', element: 'learn_more', parallel: '1' },
{ name: 'screen_view', path: '/blog/post-3' },
{ name: 'screen_view', path: '/blog/post-4' },
],
},
{
ip: '34.187.95.236',
referrer: 'https://chatgpt.com',
userAgent:
'Mozilla/5.0 (Linux; U; Android 9; ar-eg; Redmi 7 Build/PKQ1.181021.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.141 Mobile Safari/537.36 XiaoMi/MiuiBrowser/12.8.3-gn',
track: [
{ name: 'screen_view', path: '/blog' },
{ name: 'screen_view', path: '/blog/post-1' },
],
},
];
await Promise.all([
trackit(event),
trackit({
...event,
track: {
...event.track,
payload: {
...event.track.payload,
name: 'text',
},
const screenView: Event = {
headers: {
'openpanel-client-id': 'ef38d50e-7d8e-4041-9c62-46d4c3b3bb01',
'x-client-ip': '',
'user-agent': '',
origin: 'https://openpanel.dev',
},
track: {
type: 'track',
payload: {
name: 'screen_view',
properties: {},
},
}),
]);
},
};
for (const session of sessions) {
// Group tracks by parallel flag
const trackGroups: { parallel?: string; tracks: any[] }[] = [];
let currentGroup: { parallel?: string; tracks: any[] } = { tracks: [] };
for (const track of session.track) {
if (track.parallel) {
// If this track has a parallel flag
if (currentGroup.parallel === track.parallel) {
// Same parallel group, add to current group
currentGroup.tracks.push(track);
} else {
// Different parallel group, finish current group and start new one
if (currentGroup.tracks.length > 0) {
trackGroups.push(currentGroup);
}
currentGroup = { parallel: track.parallel, tracks: [track] };
}
} else {
// No parallel flag, finish any parallel group and start individual track
if (currentGroup.tracks.length > 0) {
trackGroups.push(currentGroup);
}
currentGroup = { tracks: [track] };
}
}
// Add the last group
if (currentGroup.tracks.length > 0) {
trackGroups.push(currentGroup);
}
// Process each group
for (const group of trackGroups) {
if (group.parallel && group.tracks.length > 1) {
// Parallel execution for same-flagged tracks
console.log(
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`,
);
const promises = group.tracks.map(async (track) => {
const { name, parallel, ...properties } = track;
const event = JSON.parse(JSON.stringify(screenView));
event.track.payload.name = name ?? '';
event.track.payload.properties.__referrer = session.referrer ?? '';
if (name === 'screen_view') {
event.track.payload.properties.__path =
(event.headers.origin ?? '') + (properties.path ?? '');
} else {
event.track.payload.name = track.name ?? '';
event.track.payload.properties = properties;
}
event.headers['x-client-ip'] = session.ip;
event.headers['user-agent'] = session.userAgent;
return trackit(event);
});
await Promise.all(promises);
console.log(`Completed ${group.tracks.length} parallel requests`);
} else {
// Sequential execution for individual tracks
for (const track of group.tracks) {
const { name, parallel, ...properties } = track;
screenView.track.payload.name = name ?? '';
screenView.track.payload.properties.__referrer =
session.referrer ?? '';
if (name === 'screen_view') {
screenView.track.payload.properties.__path =
(screenView.headers.origin ?? '') + (properties.path ?? '');
} else {
screenView.track.payload.name = track.name ?? '';
screenView.track.payload.properties = properties;
}
screenView.headers['x-client-ip'] = session.ip;
screenView.headers['user-agent'] = session.userAgent;
await trackit(screenView);
}
}
// Add delay between groups (not within parallel groups)
// await new Promise((resolve) => setTimeout(resolve, Math.random() * 100));
}
}
}
const exit = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -293,9 +493,11 @@ async function main() {
const [type, file = 'mock-basic.json'] = process.argv.slice(2);
switch (type) {
case 'send':
await triggerEvents(require(`./${file}`));
case 'send': {
const data = await import(`./${file}`, { assert: { type: 'json' } });
await triggerEvents(data.default);
break;
}
case 'sim':
await simultaneousRequests();
break;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
import { getChatModel, getChatSystemPrompt } from '@/utils/ai';
import {
getAllEventNames,
getConversionReport,
getFunnelReport,
getProfile,
getProfiles,
getReport,
} from '@/utils/ai-tools';
import { HttpError } from '@/utils/errors';
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import { getProjectAccess } from '@openpanel/trpc/src/access';
import { type Message, appendResponseMessages, streamText } from 'ai';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function chat(
request: FastifyRequest<{
Querystring: {
projectId: string;
};
Body: {
messages: Message[];
};
}>,
reply: FastifyReply,
) {
const { session } = request.session;
const { messages } = request.body;
const { projectId } = request.query;
if (!session?.userId) {
return reply.status(401).send('Unauthorized');
}
if (!projectId) {
return reply.status(400).send('Missing projectId');
}
const organization = await getOrganizationByProjectIdCached(projectId);
const access = await getProjectAccess({
projectId,
userId: session.userId,
});
if (!organization) {
throw new HttpError('Organization not found', {
status: 404,
});
}
if (!access) {
throw new HttpError('You are not allowed to access this project', {
status: 403,
});
}
if (organization?.isExceeded) {
throw new HttpError('Organization has exceeded its limits', {
status: 403,
});
}
if (organization?.isCanceled) {
throw new HttpError('Organization has been canceled', {
status: 403,
});
}
const systemPrompt = getChatSystemPrompt({
projectId,
});
try {
const result = streamText({
model: getChatModel(),
messages: messages.slice(-4),
maxSteps: 2,
tools: {
getAllEventNames: getAllEventNames({
projectId,
}),
getReport: getReport({
projectId,
}),
getConversionReport: getConversionReport({
projectId,
}),
getFunnelReport: getFunnelReport({
projectId,
}),
getProfiles: getProfiles({
projectId,
}),
getProfile: getProfile({
projectId,
}),
},
toolCallStreaming: false,
system: systemPrompt,
onFinish: async ({ response, usage }) => {
request.log.info('chat usage', { usage });
const messagesToSave = appendResponseMessages({
messages,
responseMessages: response.messages,
});
await db.chat.deleteMany({
where: {
projectId,
},
});
await db.chat.create({
data: {
messages: messagesToSave.slice(-10) as any,
projectId,
},
});
},
onError: async (error) => {
request.log.error('chat error', { error });
},
});
reply.header('X-Vercel-AI-Data-Stream', 'v1');
reply.header('Content-Type', 'text/plain; charset=utf-8');
return reply.send(result.toDataStream());
} catch (error) {
throw new HttpError('Error during stream processing', {
error,
});
}
}

View File

@@ -1,13 +1,14 @@
import { getClientIp, parseIp } from '@/utils/parse-ip';
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId } from '@openpanel/common/server';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { eventsQueue } from '@openpanel/queue';
import { getLock } from '@openpanel/redis';
import { eventsGroupQueue } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo';
import { getStringHeaders, getTimestamp } from './track.controller';
export async function postEvent(
@@ -16,17 +17,21 @@ export async function postEvent(
}>,
reply: FastifyReply,
) {
const timestamp = getTimestamp(request.timestamp, request.body);
const { timestamp, isTimestampFromThePast } = getTimestamp(
request.timestamp,
request.body,
);
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers);
if (!projectId) {
reply.status(400).send('missing origin');
return;
}
const [salts, geo] = await Promise.all([getSalts(), parseIp(ip)]);
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = generateDeviceId({
salt: salts.current,
origin: projectId,
@@ -55,45 +60,28 @@ export async function postEvent(
return;
}
const isScreenView = request.body.name === 'screen_view';
// this will ensure that we don't have multiple events creating sessions
const LOCK_DURATION = 1000;
const locked = await getLock(
`request:priority:${currentDeviceId}-${previousDeviceId}:${isScreenView ? 'screen_view' : 'other'}`,
'locked',
LOCK_DURATION,
);
await eventsQueue.add(
'event',
{
type: 'incomingEvent',
payload: {
projectId,
headers: getStringHeaders(request.headers),
event: {
...request.body,
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
},
geo,
currentDeviceId,
previousDeviceId,
priority: locked,
const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer
? request.body?.profileId
? `${projectId}:${request.body?.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
await eventsGroupQueue.add({
orderMs: new Date(timestamp).getTime(),
data: {
projectId,
headers,
event: {
...request.body,
timestamp,
isTimestampFromThePast,
},
geo,
currentDeviceId,
previousDeviceId,
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 200,
},
// Prioritize 'screen_view' events by setting no delay
// This ensures that session starts are created from 'screen_view' events
// rather than other events, maintaining accurate session tracking
delay: isScreenView ? undefined : LOCK_DURATION - 100,
},
);
groupId,
});
reply.status(202).send('ok');
}

View File

@@ -2,19 +2,18 @@ import { parseQueryString } from '@/utils/parse-zod-query-string';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { DateTime } from '@openpanel/common';
import type { GetEventListOptions } from '@openpanel/db';
import {
ClientType,
db,
getEventList,
getEventsCountCached,
getSettingsForProject,
} from '@openpanel/db';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import {
zChartEvent,
zChartEventFilter,
zChartInput,
} from '@openpanel/validation';
import { zChartEvent, zChartInput } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -33,11 +32,9 @@ async function getProjectId(
request.client?.type === ClientType.read &&
request.client?.projectId !== projectId
) {
reply.status(403).send({
error: 'Forbidden',
message: 'You do not have access to this project',
throw new HttpError('You do not have access to this project', {
status: 403,
});
return '';
}
const project = await db.project.findUnique({
@@ -48,11 +45,9 @@ async function getProjectId(
});
if (!project) {
reply.status(404).send({
error: 'Not Found',
message: 'Project not found',
throw new HttpError('Project not found', {
status: 404,
});
return '';
}
}
@@ -61,11 +56,9 @@ async function getProjectId(
}
if (!projectId) {
reply.status(400).send({
error: 'Bad Request',
message: 'project_id is required',
throw new HttpError('project_id or projectId is required', {
status: 400,
});
return '';
}
return projectId;
@@ -74,6 +67,7 @@ async function getProjectId(
const eventsScheme = z.object({
project_id: z.string().optional(),
projectId: z.string().optional(),
profileId: z.string().optional(),
event: z.union([z.string(), z.array(z.string())]).optional(),
start: z.coerce.string().optional(),
end: z.coerce.string().optional(),
@@ -106,7 +100,7 @@ export async function events(
const projectId = await getProjectId(request, reply);
const limit = query.data.limit;
const page = Math.max(query.data.page, 1);
const take = Math.max(Math.min(limit, 50), 1);
const take = Math.max(Math.min(limit, 1000), 1);
const cursor = page - 1;
const options: GetEventListOptions = {
projectId,
@@ -118,6 +112,7 @@ export async function events(
endDate: query.data.end ? new Date(query.data.end) : undefined,
cursor,
take,
profileId: query.data.profileId,
select: {
profile: false,
meta: false,
@@ -147,7 +142,6 @@ export async function events(
const chartSchemeFull = zChartInput
.pick({
breakdowns: true,
projectId: true,
interval: true,
range: true,
previous: true,
@@ -155,6 +149,8 @@ const chartSchemeFull = zChartInput
endDate: true,
})
.extend({
project_id: z.string().optional(),
projectId: z.string().optional(),
events: z.array(
z.object({
name: z.string(),
@@ -181,10 +177,23 @@ export async function charts(
});
}
const projectId = await getProjectId(request, reply);
const { timezone } = await getSettingsForProject(projectId);
const { events, ...rest } = query.data;
return getChart({
...rest,
startDate: rest.startDate
? DateTime.fromISO(rest.startDate)
.setZone(timezone)
.toFormat('yyyy-MM-dd HH:mm:ss')
: undefined,
endDate: rest.endDate
? DateTime.fromISO(rest.endDate)
.setZone(timezone)
.toFormat('yyyy-MM-dd HH:mm:ss')
: undefined,
projectId,
events: events.map((event) => ({
...event,
segment: event.segment ?? 'event',

View File

@@ -1,83 +1,60 @@
import { round } from '@openpanel/common';
import { TABLE_NAMES, chQuery, db } from '@openpanel/db';
import { eventsQueue } from '@openpanel/queue';
import { isShuttingDown } from '@/utils/graceful-shutdown';
import { chQuery, db } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify';
async function withTimings<T>(promise: Promise<T>) {
const time = performance.now();
try {
const data = await promise;
return {
time: round(performance.now() - time, 2),
data,
} as const;
} catch (e) {
return null;
}
}
// For docker compose healthcheck
export async function healthcheck(
request: FastifyRequest,
reply: FastifyReply,
) {
if (process.env.DISABLE_HEALTHCHECK) {
return reply.status(200).send({
ok: true,
});
}
const redisRes = await withTimings(getRedisCache().ping());
const dbRes = await withTimings(db.project.findFirst());
const queueRes = await withTimings(eventsQueue.getCompleted());
const chRes = await withTimings(
chQuery(
`SELECT * FROM ${TABLE_NAMES.events} WHERE created_at > now() - INTERVAL 10 MINUTE LIMIT 1`,
),
);
const status = redisRes && dbRes && queueRes && chRes ? 200 : 500;
try {
const redisRes = await getRedisCache().ping();
const dbRes = await db.$executeRaw`SELECT 1`;
const chRes = await chQuery('SELECT 1');
const status = redisRes && dbRes && chRes ? 200 : 503;
reply.status(status).send({
redis: redisRes
? {
ok: redisRes.data === 'PONG',
time: `${redisRes.time}ms`,
}
: null,
db: dbRes
? {
ok: !!dbRes.data,
time: `${dbRes.time}ms`,
}
: null,
queue: queueRes
? {
ok: !!queueRes.data,
time: `${queueRes.time}ms`,
}
: null,
ch: chRes
? {
ok: !!chRes.data,
time: `${chRes.time}ms`,
}
: null,
});
}
export async function healthcheckQueue(
request: FastifyRequest,
reply: FastifyReply,
) {
const count = await eventsQueue.getWaitingCount();
if (count > 40) {
reply.status(500).send({
ok: false,
count,
reply.status(status).send({
ready: status === 200,
redis: redisRes === 'PONG',
db: !!dbRes,
ch: chRes && chRes.length > 0,
});
} else {
reply.status(200).send({
ok: true,
count,
} catch (error) {
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
});
}
}
// Kubernetes - Liveness probe - returns 200 if process is alive
export async function liveness(request: FastifyRequest, reply: FastifyReply) {
return reply.status(200).send({ live: true });
}
// Kubernetes - Readiness probe - returns 200 only when accepting requests, 503 during shutdown
export async function readiness(request: FastifyRequest, reply: FastifyReply) {
if (isShuttingDown()) {
return reply.status(503).send({ ready: false, reason: 'shutting down' });
}
// Perform lightweight dependency checks for readiness
const redisRes = await getRedisCache().ping();
const dbRes = await db.project.findFirst();
const chRes = await chQuery('SELECT 1');
const isReady = redisRes && dbRes && chRes;
if (!isReady) {
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
redis: redisRes === 'PONG',
db: !!dbRes,
ch: chRes && chRes.length > 0,
});
}
return reply.status(200).send({ ready: true });
}

View File

@@ -0,0 +1,178 @@
import { parseQueryString } from '@/utils/parse-zod-query-string';
import { getDefaultIntervalByDates } from '@openpanel/constants';
import {
eventBuffer,
getChartStartEndDate,
getSettingsForProject,
overviewService,
} from '@openpanel/db';
import { zChartEventFilter, zRange } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
const zGetMetricsQuery = z.object({
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange.default('7d'),
filters: z.array(zChartEventFilter).default([]),
});
// Website stats - main metrics overview
export async function getMetrics(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: z.infer<typeof zGetMetricsQuery>;
}>,
reply: FastifyReply,
) {
const { timezone } = await getSettingsForProject(request.params.projectId);
const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query));
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid query parameters',
details: parsed.error,
});
}
const { startDate, endDate } = getChartStartEndDate(parsed.data, timezone);
reply.send(
await overviewService.getMetrics({
projectId: request.params.projectId,
filters: parsed.data.filters,
startDate: startDate,
endDate: endDate,
interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day',
timezone,
}),
);
}
// Live visitors (real-time)
export async function getLiveVisitors(
request: FastifyRequest<{
Params: { projectId: string };
}>,
reply: FastifyReply,
) {
reply.send({
visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId),
});
}
export const zGetTopPagesQuery = z.object({
filters: z.array(zChartEventFilter).default([]),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange.default('7d'),
cursor: z.number().optional(),
limit: z.number().default(10),
});
// Page views with top pages
export async function getPages(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: z.infer<typeof zGetTopPagesQuery>;
}>,
reply: FastifyReply,
) {
const { timezone } = await getSettingsForProject(request.params.projectId);
const { startDate, endDate } = getChartStartEndDate(request.query, timezone);
const parsed = zGetTopPagesQuery.safeParse(parseQueryString(request.query));
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid query parameters',
details: parsed.error,
});
}
return overviewService.getTopPages({
projectId: request.params.projectId,
filters: parsed.data.filters,
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
});
}
const zGetOverviewGenericQuery = z.object({
filters: z.array(zChartEventFilter).default([]),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange.default('7d'),
column: z.enum([
// Referrers
'referrer',
'referrer_name',
'referrer_type',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
// Geo
'region',
'country',
'city',
// Device
'device',
'brand',
'model',
'browser',
'browser_version',
'os',
'os_version',
]),
cursor: z.number().optional(),
limit: z.number().default(10),
});
export function getOverviewGeneric(
column: z.infer<typeof zGetOverviewGenericQuery>['column'],
) {
return async (
request: FastifyRequest<{
Params: { projectId: string; key: string };
Querystring: z.infer<typeof zGetOverviewGenericQuery>;
}>,
reply: FastifyReply,
) => {
const { timezone } = await getSettingsForProject(request.params.projectId);
const { startDate, endDate } = getChartStartEndDate(
request.query,
timezone,
);
const parsed = zGetOverviewGenericQuery.safeParse({
...parseQueryString(request.query),
column,
});
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid query parameters',
details: parsed.error,
});
}
// TODO: Implement overview generic endpoint
reply.send(
await overviewService.getTopGeneric({
column,
projectId: request.params.projectId,
filters: parsed.data.filters,
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
}),
);
};
}

View File

@@ -1,51 +1,231 @@
import crypto from 'node:crypto';
import { logger } from '@/utils/logger';
import { parseUrlMeta } from '@/utils/parseUrlMeta';
import type { FastifyReply, FastifyRequest } from 'fastify';
import icoToPng from 'ico-to-png';
import sharp from 'sharp';
import { createHash } from '@openpanel/common/server';
import { getClientIp } from '@/utils/get-client-ip';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { cacheable, getCache, getRedisCache } from '@openpanel/redis';
import { getGeoLocation } from '@openpanel/geo';
import { getCache, getRedisCache } from '@openpanel/redis';
interface GetFaviconParams {
url: string;
}
async function getImageBuffer(url: string) {
// Configuration
const TTL_SECONDS = 60 * 60 * 24; // 24h
const MAX_BYTES = 1_000_000; // 1MB cap
const USER_AGENT = 'OpenPanel-FaviconProxy/1.0 (+https://openpanel.dev)';
// Helper functions
function createCacheKey(url: string, prefix = 'favicon'): string {
const hash = crypto.createHash('sha256').update(url).digest('hex');
return `${prefix}:v2:${hash}`;
}
function validateUrl(raw?: string): URL | null {
try {
const res = await fetch(url);
const contentType = res.headers.get('content-type');
if (!raw) throw new Error('Missing ?url');
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
}
return url;
} catch (error) {
return null;
}
}
if (!contentType?.includes('image')) {
return null;
// Binary cache functions (more efficient than base64)
async function getFromCacheBinary(
key: string,
): Promise<{ buffer: Buffer; contentType: string } | null> {
const redis = getRedisCache();
const [bufferBase64, contentType] = await Promise.all([
redis.get(key),
redis.get(`${key}:ctype`),
]);
if (!bufferBase64 || !contentType) return null;
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
}
async function setToCacheBinary(
key: string,
buffer: Buffer,
contentType: string,
): Promise<void> {
const redis = getRedisCache();
await Promise.all([
redis.set(key, buffer.toString('base64'), 'EX', TTL_SECONDS),
redis.set(`${key}:ctype`, contentType, 'EX', TTL_SECONDS),
]);
}
// Fetch image with timeout and size limits
async function fetchImage(
url: URL,
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url.toString(), {
redirect: 'follow',
signal: controller.signal,
headers: {
'user-agent': USER_AGENT,
accept: 'image/*,*/*;q=0.8',
},
});
clearTimeout(timeout);
if (!response.ok) {
return {
buffer: Buffer.alloc(0),
contentType: 'text/plain',
status: response.status,
};
}
if (!res.ok) {
return null;
// Size guard
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > MAX_BYTES) {
throw new Error(`Remote file too large: ${contentLength} bytes`);
}
if (contentType === 'image/x-icon' || url.endsWith('.ico')) {
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return await icoToPng(buffer, 30);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Additional size check for actual content
if (buffer.length > MAX_BYTES) {
throw new Error('Remote file exceeded size limit');
}
return await sharp(await res.arrayBuffer())
const contentType =
response.headers.get('content-type') || 'application/octet-stream';
return { buffer, contentType, status: 200 };
} catch (error) {
clearTimeout(timeout);
return { buffer: Buffer.alloc(0), contentType: 'text/plain', status: 500 };
}
}
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
}
// Process image with Sharp (resize to 30x30 PNG)
async function processImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
): Promise<Buffer> {
// If it's an ICO file, just return it as-is (no conversion needed)
if (originalUrl && isIcoFile(originalUrl, contentType)) {
logger.info('Serving ICO file directly', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
if (originalUrl && isSvgFile(originalUrl, contentType)) {
logger.info('Serving SVG file directly', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
// If buffer isnt to big just return it as well
if (buffer.length < 5000) {
logger.info('Serving image directly without processing', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
try {
// For other formats, process with Sharp
return await sharp(buffer)
.resize(30, 30, {
fit: 'cover',
})
.png()
.toBuffer();
} catch (error) {
logger.error('Failed to get image from url', {
error,
url,
logger.warn('Sharp failed to process image, trying fallback', {
error: error instanceof Error ? error.message : 'Unknown error',
originalUrl,
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
const imageExtensions = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'];
// Create a simple transparent fallback image when Sharp can't process the original
function createFallbackImage(): Buffer {
// 1x1 transparent PNG
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
}
// Process OG image with Sharp (resize to 300px width)
async function processOgImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
): Promise<Buffer> {
// If buffer is small enough, return it as-is
if (buffer.length < 10000) {
logger.info('Serving OG image directly without processing', {
originalUrl,
bufferSize: buffer.length,
});
return buffer;
}
try {
// For OG images, process with Sharp to 300px width, maintaining aspect ratio
return await sharp(buffer)
.resize(300, null, {
fit: 'inside',
withoutEnlargement: true,
})
.png()
.toBuffer();
} catch (error) {
logger.warn('Sharp failed to process OG image, trying fallback', {
error: error instanceof Error ? error.message : 'Unknown error',
originalUrl,
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
// Check if URL is a direct image
function isDirectImage(url: URL): boolean {
const imageExtensions = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'];
return (
imageExtensions.some((ext) => url.pathname.endsWith(`.${ext}`)) ||
url.toString().includes('googleusercontent.com')
);
}
export async function getFavicon(
request: FastifyRequest<{
@@ -53,68 +233,110 @@ export async function getFavicon(
}>,
reply: FastifyReply,
) {
function sendBuffer(buffer: Buffer, cacheKey?: string) {
if (cacheKey) {
getRedisCache().set(`favicon:${cacheKey}`, buffer.toString('base64'));
try {
const url = validateUrl(request.query.url);
if (!url) {
return createFallbackImage();
}
reply.header('Cache-Control', 'public, max-age=604800');
reply.header('Expires', new Date(Date.now() + 604800000).toUTCString());
reply.type('image/png');
return reply.send(buffer);
}
if (!request.query.url) {
return reply.status(404).send('Not found');
}
const cacheKey = createCacheKey(url.toString());
const url = decodeURIComponent(request.query.url);
if (imageExtensions.find((ext) => url.endsWith(ext))) {
const cacheKey = createHash(url, 32);
const cache = await getRedisCache().get(`favicon:${cacheKey}`);
if (cache) {
return sendBuffer(Buffer.from(cache, 'base64'));
// Check cache first
const cached = await getFromCacheBinary(cacheKey);
if (cached) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(cached.buffer);
}
const buffer = await getImageBuffer(url);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, cacheKey);
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
}
}
}
const { hostname } = new URL(url);
const cache = await getRedisCache().get(`favicon:${hostname}`);
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
if (cache) {
return sendBuffer(Buffer.from(cache, 'base64'));
}
const meta = await parseUrlMeta(url);
if (meta?.favicon) {
const buffer = await getImageBuffer(meta.favicon);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, hostname);
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
}
}
const buffer = await getImageBuffer(
'https://www.iconsdb.com/icons/download/orange/warning-128.png',
);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, hostname);
}
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
const processedBuffer = await processImage(
buffer,
imageUrl.toString(),
contentType,
);
return reply.status(404).send('Not found');
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const responseContentType = isIco ? 'image/x-icon' : contentType;
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
reply.header('Content-Type', responseContentType);
reply.header('Cache-Control', 'public, max-age=3600, immutable');
return reply.send(processedBuffer);
} catch (error: any) {
logger.error('Favicon fetch error', {
error: error.message,
url: request.query.url,
});
const message =
process.env.NODE_ENV === 'production'
? 'Bad request'
: (error?.message ?? 'Error');
reply.header('Cache-Control', 'no-store');
return reply.status(400).send(message);
}
}
export async function clearFavicons(
request: FastifyRequest,
reply: FastifyReply,
) {
const keys = await getRedisCache().keys('favicon:*');
const redis = getRedisCache();
const keys = await redis.keys('favicon:*');
// Delete both the binary data and content-type keys
for (const key of keys) {
await getRedisCache().del(key);
await redis.del(key);
await redis.del(`${key}:ctype`);
}
return reply.status(404).send('OK');
return reply.status(200).send('OK');
}
export async function clearOgImages(
request: FastifyRequest,
reply: FastifyReply,
) {
const redis = getRedisCache();
const keys = await redis.keys('og:*');
// Delete both the binary data and content-type keys
for (const key of keys) {
await redis.del(key);
await redis.del(`${key}:ctype`);
}
return reply.status(200).send('OK');
}
export async function ping(
@@ -170,3 +392,86 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
eventsLast24hCount: res.last24hCount,
});
}
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
const ip = getClientIp(request);
if (!ip) {
return reply.status(400).send('Bad Request');
}
const geo = await getGeoLocation(ip);
return reply.status(200).send(geo);
}
export async function getOgImage(
request: FastifyRequest<{
Querystring: {
url: string;
};
}>,
reply: FastifyReply,
) {
try {
const url = validateUrl(request.query.url);
if (!url) {
return getFavicon(request, reply);
}
const cacheKey = createCacheKey(url.toString(), 'og');
// Check cache first
const cached = await getFromCacheBinary(cacheKey);
if (cached) {
reply.header('Content-Type', cached.contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(cached.buffer);
}
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
// For website URLs, extract OG image from HTML
const meta = await parseUrlMeta(url.toString());
if (meta?.ogImage) {
imageUrl = new URL(meta.ogImage);
} else {
// No OG image found, return a fallback
return getFavicon(request, reply);
}
}
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
if (status !== 200 || buffer.length === 0) {
return getFavicon(request, reply);
}
// Process the image (resize to 1200x630 for OG standards, or serve as-is if reasonable size)
const processedBuffer = await processOgImage(
buffer,
imageUrl.toString(),
contentType,
);
// Cache the result
await setToCacheBinary(cacheKey, processedBuffer, 'image/png');
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600, immutable');
return reply.send(processedBuffer);
} catch (error: any) {
logger.error('OG image fetch error', {
error: error.message,
url: request.query.url,
});
const message =
process.env.NODE_ENV === 'production'
? 'Bad request'
: (error?.message ?? 'Error');
reply.header('Cache-Control', 'no-store');
return reply.status(400).send(message);
}
}

View File

@@ -76,7 +76,9 @@ async function handleExistingUser({
sessionToken,
session.expiresAt,
);
return reply.redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
}
async function handleNewUser({
@@ -138,7 +140,9 @@ async function handleNewUser({
sessionToken,
session.expiresAt,
);
return reply.redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
}
// Provider-specific user fetching
@@ -348,7 +352,9 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
url.pathname = '/login';
if (error instanceof LogError) {
url.searchParams.set('error', error.message);

View File

@@ -1,10 +1,11 @@
import { getClientIp, parseIp } from '@/utils/parse-ip';
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda';
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import type {
IncrementProfilePayload,
UpdateProfilePayload,
@@ -24,7 +25,7 @@ export async function updateProfile(
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua, properties);
const geo = await parseIp(ip);
const geo = await getGeoLocation(ip);
if (
await checkDuplicatedEvent({

View File

@@ -1,18 +1,19 @@
import type { GeoLocation } from '@/utils/parse-ip';
import { getClientIp, parseIp } from '@/utils/parse-ip';
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, assocPath, pathOr, pick } from 'ramda';
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { eventsQueue } from '@openpanel/queue';
import { getLock } from '@openpanel/redis';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { eventsGroupQueue } from '@openpanel/queue';
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
TrackPayload,
} from '@openpanel/sdk';
export function getStringHeaders(headers: FastifyRequest['headers']) {
@@ -114,7 +115,7 @@ export async function handler(
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), parseIp(ip)]);
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
@@ -190,7 +191,7 @@ export async function handler(
return;
}
const geo = await parseIp(ip);
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
projectId,
@@ -260,11 +261,6 @@ export async function handler(
reply.status(200).send();
}
type TrackPayload = {
name: string;
properties?: Record<string, any>;
};
async function track({
payload,
currentDeviceId,
@@ -284,45 +280,28 @@ async function track({
timestamp: string;
isTimestampFromThePast: boolean;
}) {
const isScreenView = payload.name === 'screen_view';
// this will ensure that we don't have multiple events creating sessions
const LOCK_DURATION = 1000;
const locked = await getLock(
`request:priority:${currentDeviceId}-${previousDeviceId}:${isScreenView ? 'screen_view' : 'other'}`,
'locked',
LOCK_DURATION,
);
await eventsQueue.add(
'event',
{
type: 'incomingEvent',
payload: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
},
geo,
currentDeviceId,
previousDeviceId,
priority: locked,
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
await eventsGroupQueue.add({
orderMs: new Date(timestamp).getTime(),
data: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
},
geo,
currentDeviceId,
previousDeviceId,
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 200,
},
// Prioritize 'screen_view' events by setting no delay
// This ensures that session starts are created from 'screen_view' events
// rather than other events, maintaining accurate session tracking
delay: isScreenView ? undefined : LOCK_DURATION - 100,
},
);
groupId,
});
}
async function identify({

View File

@@ -1,6 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import { db } from '@openpanel/db';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import {
sendSlackNotification,
slackInstaller,
@@ -22,7 +27,6 @@ const paramsSchema = z.object({
const metadataSchema = z.object({
organizationId: z.string(),
projectId: z.string(),
integrationId: z.string(),
});
@@ -84,7 +88,7 @@ export async function slackWebhook(
'👋 Hello. You have successfully connected OpenPanel.dev to your Slack workspace.',
});
const { projectId, organizationId, integrationId } = parsedMetadata.data;
const { organizationId, integrationId } = parsedMetadata.data;
await db.integration.update({
where: {
@@ -100,7 +104,7 @@ export async function slackWebhook(
});
return reply.redirect(
`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/${projectId}/settings/integrations?tab=installed`,
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`,
);
} catch (err) {
request.log.error(err);
@@ -109,6 +113,17 @@ export async function slackWebhook(
}
}
async function clearOrganizationCache(organizationId: string) {
const projects = await db.project.findMany({
where: {
organizationId,
},
});
for (const project of projects) {
await getOrganizationByProjectIdCached.clear(project.id);
}
}
export async function polarWebhook(
request: FastifyRequest<{
Querystring: unknown;
@@ -137,8 +152,11 @@ export async function polarWebhook(
},
data: {
subscriptionPeriodEventsCount: 0,
subscriptionPeriodEventsCountExceededAt: null,
},
});
await clearOrganizationCache(metadata.organizationId);
}
break;
}
@@ -184,7 +202,7 @@ export async function polarWebhook(
data: {
subscriptionId: event.data.id,
subscriptionCustomerId: event.data.customer.id,
subscriptionPriceId: event.data.priceId,
subscriptionPriceId: event.data.prices[0]?.id ?? null,
subscriptionProductId: event.data.productId,
subscriptionStatus: event.data.status,
subscriptionStartsAt: event.data.currentPeriodStart,
@@ -201,6 +219,8 @@ export async function polarWebhook(
},
});
await clearOrganizationCache(metadata.organizationId);
await publishEvent('organization', 'subscription_updated', {
organizationId: metadata.organizationId,
});

View File

@@ -1,4 +1,4 @@
import { getClientIp } from '@/utils/parse-ip';
import { getClientIp } from '@/utils/get-client-ip';
import type {
FastifyReply,
FastifyRequest,

View File

@@ -1,13 +1,13 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda';
const ignoreLog = ['/healthcheck', '/metrics', '/misc'];
const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS'];
const getTrpcInput = (
request: FastifyRequest,
): Record<string, unknown> | undefined => {
const input = path(['query', 'input'], request);
const input = path<any>(['query', 'input'], request);
try {
return typeof input === 'string' ? JSON.parse(input).json : input;
} catch (e) {

View File

@@ -8,39 +8,50 @@ import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import { generateId } from '@openpanel/common';
import type { IServiceClientWithProject } from '@openpanel/db';
import { getRedisPub } from '@openpanel/redis';
import {
type IServiceClientWithProject,
runWithAlsSession,
} from '@openpanel/db';
import { getCache, getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import {
EMPTY_SESSION,
type SessionValidationResult,
decodeSessionToken,
validateSessionToken,
} from '@openpanel/auth';
import sourceMapSupport from 'source-map-support';
import {
healthcheck,
healthcheckQueue,
liveness,
readiness,
} from './controllers/healthcheck.controller';
import { fixHook } from './hooks/fix.hook';
import { ipHook } from './hooks/ip.hook';
import { requestIdHook } from './hooks/request-id.hook';
import { requestLoggingHook } from './hooks/request-logging.hook';
import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
import trackRouter from './routes/track.router';
import webhookRouter from './routes/webhook.router';
import { HttpError } from './utils/errors';
import { shutdown } from './utils/graceful-shutdown';
import { logger } from './utils/logger';
sourceMapSupport.install();
process.env.TZ = 'UTC';
declare module 'fastify' {
interface FastifyRequest {
client: IServiceClientWithProject | null;
@@ -72,15 +83,31 @@ const startServer = async () => {
callback: (error: Error | null, options: FastifyCorsOptions) => void,
) => {
// TODO: set prefix on dashboard routes
const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc'];
const corsPaths = [
'/trpc',
'/live',
'/webhook',
'/oauth',
'/misc',
'/ai',
];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path),
);
if (isPrivatePath) {
// Allow multiple dashboard domains
const allowedOrigins = [
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL,
...(process.env.API_CORS_ORIGINS?.split(',') ?? []),
].filter(Boolean);
const origin = req.headers.origin;
const isAllowed = origin && allowedOrigins.includes(origin);
return callback(null, {
origin: process.env.NEXT_PUBLIC_DASHBOARD_URL,
origin: isAllowed ? origin : false,
credentials: true,
});
}
@@ -117,10 +144,11 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const session = await validateSessionToken(req.cookies.session);
if (session.session) {
req.session = session;
}
const sessionId = decodeSessionToken(req.cookies.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
);
req.session = session;
} catch (e) {
req.session = EMPTY_SESSION;
}
@@ -135,11 +163,18 @@ const startServer = async () => {
router: appRouter,
createContext: createContext,
onError(ctx) {
if (
ctx.error.code === 'UNAUTHORIZED' &&
ctx.path === 'organization.list'
) {
return;
}
ctx.req.log.error('trpc error', {
error: ctx.error,
path: ctx.path,
input: ctx.input,
type: ctx.type,
session: ctx.ctx?.session,
});
},
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
@@ -148,6 +183,7 @@ const startServer = async () => {
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});
// Public API
@@ -157,16 +193,35 @@ const startServer = async () => {
instance.register(profileRouter, { prefix: '/profile' });
instance.register(exportRouter, { prefix: '/export' });
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
instance.get('/healthcheck/queue', healthcheckQueue);
// New Kubernetes-style health endpoints
instance.get('/healthz/live', liveness);
instance.get('/healthz/ready', readiness);
instance.get('/', (_request, reply) =>
reply.send({ name: 'openpanel sdk api' }),
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
}),
);
});
fastify.setErrorHandler((error, request, reply) => {
if (error.statusCode === 429) {
if (error instanceof HttpError) {
request.log.error(`${error.message}`, error);
if (process.env.NODE_ENV === 'production' && error.status === 500) {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} else {
reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
} else if (error.statusCode === 429) {
reply.status(429).send({
status: 429,
error: 'Too Many Requests',
@@ -185,14 +240,17 @@ const startServer = async () => {
});
if (process.env.NODE_ENV === 'production') {
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, (error) => {
logger.error(`uncaught exception detected ${signal}`, error);
fastify.close().then((error) => {
process.exit(error ? 1 : 0);
});
});
}
logger.info('Registering graceful shutdown handlers');
process.on('SIGTERM', async () => await shutdown(fastify, 'SIGTERM', 0));
process.on('SIGINT', async () => await shutdown(fastify, 'SIGINT', 0));
process.on('uncaughtException', async (error) => {
logger.error('Uncaught exception', error);
await shutdown(fastify, 'uncaughtException', 1);
});
process.on('unhandledRejection', async (reason, promise) => {
logger.error('Unhandled rejection', { reason, promise });
await shutdown(fastify, 'unhandledRejection', 1);
});
}
await fastify.listen({
@@ -215,5 +273,4 @@ const startServer = async () => {
}
};
// start
startServer();

View File

@@ -0,0 +1,28 @@
import * as controller from '@/controllers/ai.controller';
import { activateRateLimiter } from '@/utils/rate-limiter';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const aiRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter<
FastifyRequest<{
Querystring: {
projectId: string;
};
}>
>({
fastify,
max: process.env.NODE_ENV === 'production' ? 20 : 100,
timeWindow: '300 seconds',
keyGenerator: (req) => {
return req.query.projectId;
},
});
fastify.route({
method: 'POST',
url: '/chat',
handler: controller.chat,
});
};
export default aiRouter;

View File

@@ -7,7 +7,7 @@ import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const exportRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({
fastify,
max: 10,
max: 100,
timeWindow: '10 seconds',
});

View File

@@ -0,0 +1,89 @@
import * as controller from '@/controllers/insights.controller';
import { validateExportRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const insightsRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({
fastify,
max: 100,
timeWindow: '10 seconds',
});
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
try {
const client = await validateExportRequest(req.headers);
req.client = client;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'Client ID seems to be malformed',
});
}
if (e instanceof Error) {
return reply
.status(401)
.send({ error: 'Unauthorized', message: e.message });
}
return reply
.status(401)
.send({ error: 'Unauthorized', message: 'Unexpected error' });
}
});
// Website stats - main metrics overview
fastify.route({
method: 'GET',
url: '/:projectId/metrics',
handler: controller.getMetrics,
});
// Live visitors (real-time)
fastify.route({
method: 'GET',
url: '/:projectId/live',
handler: controller.getLiveVisitors,
});
// Page views with top pages
fastify.route({
method: 'GET',
url: '/:projectId/pages',
handler: controller.getPages,
});
const overviewMetrics = [
'referrer_name',
'referrer',
'referrer_type',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'device',
'browser',
'browser_version',
'os',
'os_version',
'brand',
'model',
'country',
'region',
'city',
] as const;
overviewMetrics.forEach((key) => {
fastify.route({
method: 'GET',
url: `/:projectId/${key}`,
handler: controller.getOverviewGeneric(key),
});
});
};
export default insightsRouter;

View File

@@ -20,11 +20,29 @@ const miscRouter: FastifyPluginCallback = async (fastify) => {
handler: controller.getFavicon,
});
fastify.route({
method: 'GET',
url: '/og',
handler: controller.getOgImage,
});
fastify.route({
method: 'GET',
url: '/og/clear',
handler: controller.clearOgImages,
});
fastify.route({
method: 'GET',
url: '/favicon/clear',
handler: controller.clearFavicons,
});
fastify.route({
method: 'GET',
url: '/geo',
handler: controller.getGeo,
});
};
export default miscRouter;

View File

@@ -0,0 +1,475 @@
import { chartTypes } from '@openpanel/constants';
import type { IClickhouseSession } from '@openpanel/db';
import {
type IClickhouseEvent,
type IClickhouseProfile,
TABLE_NAMES,
ch,
clix,
} from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import { zChartInputAI } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
export function getReport({
projectId,
}: {
projectId: string;
}) {
return tool({
description: `Generate a report (a chart) for
- ${chartTypes.area}
- ${chartTypes.linear}
- ${chartTypes.pie}
- ${chartTypes.histogram}
- ${chartTypes.metric}
- ${chartTypes.bar}
`,
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
report: {
...report,
projectId,
},
};
// try {
// const data = await getChart({
// ...report,
// projectId,
// });
// return {
// type: 'report',
// data: `Avg: ${data.metrics.average}, Min: ${data.metrics.min}, Max: ${data.metrics.max}, Sum: ${data.metrics.sum}
// X-Axis: ${data.series[0]?.data.map((i) => i.date).join(',')}
// Series:
// ${data.series
// .slice(0, 5)
// .map((item) => {
// return `- ${item.names.join(' ')} | Sum: ${item.metrics.sum} | Avg: ${item.metrics.average} | Min: ${item.metrics.min} | Max: ${item.metrics.max} | Data: ${item.data.map((i) => i.count).join(',')}`;
// })
// .join('\n')}
// `,
// report,
// };
// } catch (error) {
// return {
// error: 'Failed to generate report',
// };
// }
},
});
}
export function getConversionReport({
projectId,
}: {
projectId: string;
}) {
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
// data: await conversionService.getConversion(report),
report: {
...report,
projectId,
chartType: 'conversion',
},
};
},
});
}
export function getFunnelReport({
projectId,
}: {
projectId: string;
}) {
return tool({
description:
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
// data: await funnelService.getFunnel(report),
report: {
...report,
projectId,
chartType: 'funnel',
},
};
},
});
}
export function getProfiles({
projectId,
}: {
projectId: string;
}) {
return tool({
description: 'Get profiles',
parameters: z.object({
projectId: z.string(),
limit: z.number().optional(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
country: z.string().describe('ISO 3166-1 alpha-2').optional(),
city: z.string().optional(),
region: z.string().optional(),
device: z.string().optional(),
browser: z.string().optional(),
}),
execute: async (input) => {
const builder = clix(ch)
.select<IClickhouseProfile>([
'id',
'email',
'first_name',
'last_name',
'properties',
])
.from(TABLE_NAMES.profiles)
.where('project_id', '=', projectId);
if (input.email) {
builder.where('email', 'LIKE', `%${input.email}%`);
}
if (input.firstName) {
builder.where('first_name', 'LIKE', `%${input.firstName}%`);
}
if (input.lastName) {
builder.where('last_name', 'LIKE', `%${input.lastName}%`);
}
if (input.country) {
builder.where(`properties['country']`, '=', input.country);
}
if (input.city) {
builder.where(`properties['city']`, '=', input.city);
}
if (input.region) {
builder.where(`properties['region']`, '=', input.region);
}
if (input.device) {
builder.where(`properties['device']`, '=', input.device);
}
if (input.browser) {
builder.where(`properties['browser']`, '=', input.browser);
}
const profiles = await builder.limit(input.limit ?? 5).execute();
return profiles;
},
});
}
export function getProfile({
projectId,
}: {
projectId: string;
}) {
return tool({
description: 'Get a specific profile',
parameters: z.object({
projectId: z.string(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
country: z.string().describe('ISO 3166-1 alpha-2').optional(),
city: z.string().optional(),
region: z.string().optional(),
device: z.string().optional(),
browser: z.string().optional(),
}),
execute: async (input) => {
const builder = clix(ch)
.select<IClickhouseProfile>([
'id',
'email',
'first_name',
'last_name',
'properties',
])
.from(TABLE_NAMES.profiles)
.where('project_id', '=', projectId);
if (input.email) {
builder.where('email', 'LIKE', `%${input.email}%`);
}
if (input.firstName) {
builder.where('first_name', 'LIKE', `%${input.firstName}%`);
}
if (input.lastName) {
builder.where('last_name', 'LIKE', `%${input.lastName}%`);
}
if (input.country) {
builder.where(`properties['country']`, '=', input.country);
}
if (input.city) {
builder.where(`properties['city']`, '=', input.city);
}
if (input.region) {
builder.where(`properties['region']`, '=', input.region);
}
if (input.device) {
builder.where(`properties['device']`, '=', input.device);
}
if (input.browser) {
builder.where(`properties['browser']`, '=', input.browser);
}
const profiles = await builder.limit(1).execute();
const profile = profiles[0];
if (!profile) {
return {
error: 'Profile not found',
};
}
const events = await clix(ch)
.select<IClickhouseEvent>([])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('profile_id', '=', profile.id)
.limit(5)
.orderBy('created_at', 'DESC')
.execute();
return {
profile,
events,
};
},
});
}
export function getEvents({
projectId,
}: {
projectId: string;
}) {
return tool({
description: 'Get events for a project or specific profile',
parameters: z.object({
projectId: z.string(),
profileId: z.string().optional(),
take: z.number().optional().default(10),
eventNames: z.array(z.string()).optional(),
referrer: z.string().optional(),
referrerName: z.string().optional(),
referrerType: z.string().optional(),
device: z.string().optional(),
country: z.string().optional(),
city: z.string().optional(),
os: z.string().optional(),
browser: z.string().optional(),
properties: z.record(z.string(), z.string()).optional(),
startDate: z.string().optional().describe('ISO date string'),
endDate: z.string().optional().describe('ISO date string'),
}),
execute: async (input) => {
const builder = clix(ch)
.select<IClickhouseEvent>([])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId);
if (input.profileId) {
builder.where('profile_id', '=', input.profileId);
}
if (input.eventNames) {
builder.where('name', 'IN', input.eventNames);
}
if (input.referrer) {
builder.where('referrer', '=', input.referrer);
}
if (input.referrerName) {
builder.where('referrer_name', '=', input.referrerName);
}
if (input.referrerType) {
builder.where('referrer_type', '=', input.referrerType);
}
if (input.device) {
builder.where('device', '=', input.device);
}
if (input.country) {
builder.where('country', '=', input.country);
}
if (input.city) {
builder.where('city', '=', input.city);
}
if (input.os) {
builder.where('os', '=', input.os);
}
if (input.browser) {
builder.where('browser', '=', input.browser);
}
if (input.properties) {
for (const [key, value] of Object.entries(input.properties)) {
builder.where(`properties['${key}']`, '=', value);
}
}
if (input.startDate && input.endDate) {
builder.where('created_at', 'BETWEEN', [
clix.datetime(input.startDate),
clix.datetime(input.endDate),
]);
} else {
builder.where('created_at', 'BETWEEN', [
clix.datetime(new Date(Date.now() - 1000 * 60 * 60 * 24 * 7)),
clix.datetime(new Date()),
]);
}
return await builder.limit(input.take).execute();
},
});
}
export function getSessions({
projectId,
}: {
projectId: string;
}) {
return tool({
description: 'Get sessions for a project or specific profile',
parameters: z.object({
projectId: z.string(),
profileId: z.string().optional(),
take: z.number().optional().default(10),
referrer: z.string().optional(),
referrerName: z.string().optional(),
referrerType: z.string().optional(),
device: z.string().optional(),
country: z.string().optional(),
city: z.string().optional(),
os: z.string().optional(),
browser: z.string().optional(),
properties: z.record(z.string(), z.string()).optional(),
startDate: z.string().optional().describe('ISO date string'),
endDate: z.string().optional().describe('ISO date string'),
}),
execute: async (input) => {
const builder = clix(ch)
.select<IClickhouseSession>([])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId)
.where('sign', '=', 1);
if (input.profileId) {
builder.where('profile_id', '=', input.profileId);
}
if (input.referrer) {
builder.where('referrer', '=', input.referrer);
}
if (input.referrerName) {
builder.where('referrer_name', '=', input.referrerName);
}
if (input.referrerType) {
builder.where('referrer_type', '=', input.referrerType);
}
if (input.device) {
builder.where('device', '=', input.device);
}
if (input.country) {
builder.where('country', '=', input.country);
}
if (input.city) {
builder.where('city', '=', input.city);
}
if (input.os) {
builder.where('os', '=', input.os);
}
if (input.browser) {
builder.where('browser', '=', input.browser);
}
if (input.properties) {
for (const [key, value] of Object.entries(input.properties)) {
builder.where(`properties['${key}']`, '=', value);
}
}
if (input.startDate && input.endDate) {
builder.where('created_at', 'BETWEEN', [
clix.datetime(input.startDate),
clix.datetime(input.endDate),
]);
} else {
builder.where('created_at', 'BETWEEN', [
clix.datetime(new Date(Date.now() - 1000 * 60 * 60 * 24 * 7)),
clix.datetime(new Date()),
]);
}
return await builder.limit(input.take).execute();
},
});
}
export function getAllEventNames({
projectId,
}: {
projectId: string;
}) {
return tool({
description: 'Get the top 50 event names in a comma separated list',
parameters: z.object({}),
execute: async () => {
return getCache(`top-event-names:${projectId}`, 60 * 10, async () => {
const events = await clix(ch)
.select<IClickhouseEvent>(['name', 'count() as count'])
.from(TABLE_NAMES.event_names_mv)
.where('project_id', '=', projectId)
.groupBy(['name'])
.orderBy('count', 'DESC')
.limit(50)
.execute();
return events.map((event) => event.name).join(',');
});
},
});
}

115
apps/api/src/utils/ai.ts Normal file
View File

@@ -0,0 +1,115 @@
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { chartTypes, operators, timeWindows } from '@openpanel/constants';
import { mapKeys } from '@openpanel/validation';
export const getChatModel = () => {
switch (process.env.AI_MODEL) {
case 'gpt-4o':
return openai('gpt-4o');
case 'claude-3-5':
return anthropic('claude-3-5-haiku-latest');
default:
return openai('gpt-4.1-mini');
}
};
export const getChatSystemPrompt = ({
projectId,
}: {
projectId: string;
}) => {
return `You're an product and web analytics expert. Don't generate more than the user asks for. Follow all rules listed below!
## General:
- projectId: \`${projectId}\`
- Do not hallucinate, if you can't make a report based on the user's request, just say so.
- Today is ${new Date().toISOString()}
- \`range\` should always be \`custom\`
- if range is \`custom\`, make sure to have \`startDate\` and \`endDate\`
- Available intervals: ${Object.values(timeWindows)
.map((t) => t.key)
.join(', ')}
- Try to figure out a time window, ${Object.values(timeWindows)
.map((t) => t.key)
.join(', ')}. If no match always use \`custom\` with a start and end date.
- Pick corresponding chartType from \`${Object.keys(chartTypes).join(', ')}\`, match with your best effort.
- Always add a name to the report.
- Never do a summary!
### Formatting
- Never generate images
- If you use katex, please wrap the equation in $$
- Use tables when showing lists of data.
### Events
- Tool: \`getAllEventNames\`, use this tool *before* calling any other tool if the user's request mentions an event but you are unsure of the exact event name stored in the system. Only call this once!
- \`screen_view\` is a page view event
- If you see any paths you should pick \`screen_view\` event and use a \`path\` filter
- To find referrers you can use \`referrer\`, \`referrer_name\` and \`referrer_type\` columns
- Use unique IDs for each event and each filter
### Filters
- If you see a '*' in the filters value, depending on where it is you can split it up and do 'startsWith' together with 'endsWith'. Eg: '/path/*' -> 'path startsWith /path/', or '*/path' -> 'path endsWith /path/', or '/path/*/something' -> 'path startsWith /path/ and endsWith /something'
- If user asks for several events you can use this tool once (with all events)
- Example: path is /path/*/something \`{"id":"1","name":"screen_view","displayName":"Path is something","segment":"user","filters":[{"id":"1","name":"path","operator":"startsWith","value":["/path/"]},{"id":"1","name":"path","operator":"endsWith","value":["/something"]}]}\`
- Other examples for filters:
- Available operators: ${mapKeys(operators).join(', ')}
- {"id":"1","name":"path","operator":"endsWith","value":["/foo", "/bar"]}
- {"id":"1","name":"path","operator":"isNot","value":["/","/a","/b"]}
- {"id":"1","name":"path","operator":"contains","value":["nuke"]}
- {"id":"1","name":"path","operator":"regex","value":["/onboarding/.+/verify/?"]}
- {"id":"1","name":"path","operator":"isNull","value":[]}
## Conversion Report
Tool: \`getConversionReport\`
Rules:
- Use this when ever a user wants any conversion rate over time.
- Needs two events
## Funnel Report
Tool: \`getFunnelReport\`
Rules:
- Use this when ever a user wants to see a funnel between two or more events.
- Needs two or more events
## Other reports
Tool: \`getReport\`
Rules:
- Use this when ever a user wants any other report than a conversion, funnel or retention.
### Examples
#### Active users the last 30min
\`\`\`
{"events":[{"id":"1","name":"*","displayName":"Active users","segment":"user","filters":[{"id":"1","name":"name","operator":"is","value":["screen_view","session_start"]}]}],"breakdowns":[]}
\`\`\`
#### How to get most events with breakdown by title
\`\`\`
{"events":[{"id":"1","name":"screen_view","segment":"event","filters":[{"id":"1","name":"path","operator":"is","value":["Article"]}]}],"breakdowns":[{"id":"1","name":"properties.params.title"}]}
\`\`\`
#### Get popular referrers
\`\`\`
{"events":[{"id":"1","name":"session_start","segment":"event","filters":[]}],"breakdowns":[{"id":"1","name":"referrer_name"}]}
\`\`\`
#### Popular screen views
\`\`\`
{"chartType":"bar","events":[{"id":"1","name":"screen_view","segment":"event","filters":[]}],"breakdowns":[{"id":"1","name":"path"}]}
\`\`\`
#### Popular screen views from X,Y,Z referrers
\`\`\`
{"chartType":"bar","events":[{"id":"1","name":"screen_view","segment":"event","filters":[{"id":"1","name":"referrer_name","operator":"is","value":["Google","Bing","Yahoo!"]}]}],"breakdowns":[{"id":"1","name":"path"}]}
\`\`\`
#### Bounce rate (use session_end together with formula)
\`\`\`
{"chartType":"linear","formula":"B/A*100","events":[{"id":"1","name":"session_end","segment":"event","filters":[]},{"id":"2","name":"session_end","segment":"event","filters":[{"id":"3","name":"properties.__bounce","operator":"is","value":["true"]}]}],"breakdowns":[]}
\`\`\`
`;
};

View File

@@ -104,6 +104,10 @@ export async function validateSdkRequest(
throw createError('Ingestion: Profile id is blocked by project filter');
}
if (client.ignoreCorsAndSecret) {
return client;
}
if (client.project.cors) {
const domainAllowed = client.project.cors.find((domain) => {
const cleanedDomain = cleanDomain(domain);

View File

@@ -13,3 +13,27 @@ export class LogError extends Error {
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class HttpError extends Error {
public readonly status: number;
public readonly fingerprint?: string;
public readonly extra?: Record<string, unknown>;
public readonly error?: Error | unknown;
constructor(
message: string,
options?: {
status?: number;
fingerprint?: string;
extra?: Record<string, unknown>;
error?: Error | unknown;
},
) {
super(message);
this.name = 'HttpError';
this.status = options?.status ?? 500;
this.fingerprint = options?.fingerprint;
this.extra = options?.extra;
this.error = options?.error;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -0,0 +1,8 @@
import type { FastifyRequest } from 'fastify';
import requestIp from 'request-ip';
const ignore = ['127.0.0.1', '::1'];
export function getClientIp(req: FastifyRequest) {
return requestIp.getClientIp(req);
}

View File

@@ -0,0 +1,108 @@
import { ch, db } from '@openpanel/db';
import {
cronQueue,
eventsGroupQueue,
miscQueue,
notificationQueue,
sessionsQueue,
} from '@openpanel/queue';
import {
getRedisCache,
getRedisPub,
getRedisQueue,
getRedisSub,
} from '@openpanel/redis';
import type { FastifyInstance } from 'fastify';
import { logger } from './logger';
let shuttingDown = false;
export function setShuttingDown(value: boolean) {
shuttingDown = value;
}
export function isShuttingDown() {
return shuttingDown;
}
// Graceful shutdown handler
export async function shutdown(
fastify: FastifyInstance,
signal: string,
exitCode = 0,
) {
if (isShuttingDown()) {
logger.warn('Shutdown already in progress, ignoring signal', { signal });
return;
}
logger.info('Starting graceful shutdown', { signal });
setShuttingDown(true);
// Step 2: Wait for load balancer to stop sending traffic (matches preStop sleep)
const gracePeriod = Number(process.env.SHUTDOWN_GRACE_PERIOD_MS || '5000');
await new Promise((resolve) => setTimeout(resolve, gracePeriod));
// Step 3: Close Fastify to drain in-flight requests
try {
await fastify.close();
logger.info('Fastify server closed');
} catch (error) {
logger.error('Error closing Fastify server', error);
}
// Step 4: Close database connections
try {
await db.$disconnect();
logger.info('Database connection closed');
} catch (error) {
logger.error('Error closing database connection', error);
}
// Step 5: Close ClickHouse connections
try {
await ch.close();
logger.info('ClickHouse connections closed');
} catch (error) {
logger.error('Error closing ClickHouse connections', error);
}
// Step 6: Close Bull queues (graceful shutdown of queue state)
try {
await Promise.all([
eventsGroupQueue.close(),
sessionsQueue.close(),
cronQueue.close(),
miscQueue.close(),
notificationQueue.close(),
]);
logger.info('Queue state closed');
} catch (error) {
logger.error('Error closing queue state', error);
}
// Step 7: Close Redis connections
try {
const redisConnections = [
getRedisCache(),
getRedisPub(),
getRedisSub(),
getRedisQueue(),
];
await Promise.all(
redisConnections.map(async (redis) => {
if (redis.status === 'ready') {
await redis.quit();
}
}),
);
logger.info('Redis connections closed');
} catch (error) {
logger.error('Error closing Redis connections', error);
}
logger.info('Graceful shutdown completed');
process.exit(exitCode);
}

View File

@@ -1,85 +0,0 @@
import crypto from 'node:crypto';
import { getRedisCache } from '@openpanel/redis';
import type { FastifyRequest } from 'fastify';
import requestIp from 'request-ip';
import { logger } from './logger';
interface RemoteIpLookupResponse {
country: string | undefined;
city: string | undefined;
stateprov: string | undefined;
longitude: number | undefined;
latitude: number | undefined;
}
export interface GeoLocation {
country: string | undefined;
city: string | undefined;
region: string | undefined;
longitude: number | undefined;
latitude: number | undefined;
}
const DEFAULT_GEO: GeoLocation = {
country: undefined,
city: undefined,
region: undefined,
longitude: undefined,
latitude: undefined,
};
const ignore = ['127.0.0.1', '::1'];
export function getClientIp(req: FastifyRequest) {
return requestIp.getClientIp(req);
}
export async function parseIp(ip?: string): Promise<GeoLocation> {
if (!ip || ignore.includes(ip)) {
return DEFAULT_GEO;
}
const hash = crypto.createHash('sha256').update(ip).digest('hex');
const cached = await getRedisCache()
.get(`geo:${hash}`)
.catch(() => {
logger.warn('Failed to get geo location from cache', { hash });
return null;
});
if (cached) {
return JSON.parse(cached);
}
try {
const res = await fetch(`${process.env.GEO_IP_HOST}/${ip}`, {
signal: AbortSignal.timeout(4000),
});
if (!res.ok) {
return DEFAULT_GEO;
}
const json = (await res.json()) as RemoteIpLookupResponse;
const geo = {
country: json.country,
city: json.city,
region: json.stateprov,
longitude: json.longitude,
latitude: json.latitude,
};
await getRedisCache().set(
`geo:${hash}`,
JSON.stringify(geo),
'EX',
60 * 60 * 24,
);
return geo;
} catch (error) {
logger.error('Failed to fetch geo location for ip', { error });
return DEFAULT_GEO;
}
}

View File

@@ -5,12 +5,16 @@ function fallbackFavicon(url: string) {
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {
const match = favicons.find(
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
);
const match = favicons
.sort((a, b) => {
return a.rel.length - b.rel.length;
})
.find(
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
);
if (match) {
return match.href;
@@ -18,11 +22,32 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
return null;
}
function findBestOgImage(data: UrlMetaData): string | null {
// Priority order for OG images
const candidates = [
data['og:image:secure_url'],
data['og:image:url'],
data['og:image'],
data['twitter:image:src'],
data['twitter:image'],
];
for (const candidate of candidates) {
if (candidate?.trim()) {
return candidate.trim();
}
}
return null;
}
function transform(data: UrlMetaData, url: string) {
const favicon = findBestFavicon(data.favicons);
const ogImage = findBestOgImage(data);
return {
favicon: favicon ? new URL(favicon, url).toString() : fallbackFavicon(url),
ogImage: ogImage ? new URL(ogImage, url).toString() : null,
};
}
@@ -32,6 +57,11 @@ interface UrlMetaData {
href: string;
sizes: string;
}[];
'og:image'?: string;
'og:image:url'?: string;
'og:image:secure_url'?: string;
'twitter:image'?: string;
'twitter:image:src'?: string;
}
export async function parseUrlMeta(url: string) {
@@ -42,6 +72,7 @@ export async function parseUrlMeta(url: string) {
} catch (err) {
return {
favicon: fallbackFavicon(url),
ogImage: null,
};
}
}

View File

@@ -1,14 +1,16 @@
import { getRedisCache } from '@openpanel/redis';
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
export async function activateRateLimiter({
export async function activateRateLimiter<T extends FastifyRequest>({
fastify,
max,
timeWindow,
keyGenerator,
}: {
fastify: FastifyInstance;
max: number;
timeWindow?: string;
keyGenerator?: (req: T) => string | undefined;
}) {
await fastify.register(import('@fastify/rate-limit'), {
max,
@@ -22,6 +24,12 @@ export async function activateRateLimiter({
},
redis: getRedisCache(),
keyGenerator(req) {
if (keyGenerator) {
const key = keyGenerator(req as T);
if (key) {
return key;
}
}
return (req.headers['openpanel-client-id'] ||
req.headers['x-real-ip'] ||
req.headers['x-client-ip'] ||

23
apps/api/tsdown.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'tsdown';
import type { Options } from 'tsdown';
const options: Options = {
clean: true,
entry: ['src/index.ts'],
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
external: ['@hyperdx/node-opentelemetry', 'winston', '@node-rs/argon2'],
sourcemap: true,
platform: 'node',
shims: true,
inputOptions: {
jsx: 'react',
},
};
if (process.env.WATCH) {
options.watch = ['src', '../../packages'];
options.onSuccess = 'node --enable-source-maps dist/index.js';
options.minify = false;
}
export default defineConfig(options);

View File

@@ -1,26 +0,0 @@
import { defineConfig } from 'tsup';
import type { Options } from 'tsup';
const options: Options = {
clean: true,
entry: ['src/index.ts'],
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
external: [
'@hyperdx/node-opentelemetry',
'winston',
'@node-rs/argon2',
'bcrypt',
],
ignoreWatch: ['../../**/{.git,node_modules}/**'],
sourcemap: true,
splitting: false,
};
if (process.env.WATCH) {
options.watch = ['src/**/*', '../../packages/**/*'];
options.onSuccess = 'node dist/index.js';
options.minify = false;
}
export default defineConfig(options);

View File

@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo

View File

@@ -1,3 +0,0 @@
[auth]
token=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

View File

@@ -1,98 +0,0 @@
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim AS base
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
ENV COREPACK_INTEGRITY_KEYS=0
ENV SKIP_ENV_VALIDATION="1"
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG ENABLE_INSTRUMENTATION_HOOK
ENV ENABLE_INSTRUMENTATION_HOOK=$ENABLE_INSTRUMENTATION_HOOK
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Install necessary dependencies for prisma
RUN apt-get update && apt-get install -y \
openssl \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
WORKDIR /app
ARG CACHE_BUST
RUN echo "CACHE BUSTER: $CACHE_BUST"
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/dashboard/package.json apps/dashboard/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/json/package.json packages/json/package.json
COPY packages/redis/package.json packages/redis/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/common/package.json packages/common/package.json
COPY packages/auth/package.json packages/auth/package.json
COPY packages/email/package.json packages/email/package.json
COPY packages/constants/package.json packages/constants/package.json
COPY packages/validation/package.json packages/validation/package.json
COPY packages/integrations/package.json packages/integrations/package.json
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
# BUILD
FROM base AS build
WORKDIR /app
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY apps/dashboard apps/dashboard
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/dashboard
# Will be replaced on runtime
ENV NEXT_PUBLIC_DASHBOARD_URL="__NEXT_PUBLIC_DASHBOARD_URL__"
ENV NEXT_PUBLIC_API_URL="__NEXT_PUBLIC_API_URL__"
RUN pnpm run build
# RUNNER
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Set the correct permissions for the entire /app directory
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/public ./apps/dashboard/public
# Copy and set permissions for the entrypoint script
COPY --from=build --chown=nextjs:nodejs /app/apps/dashboard/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENTRYPOINT [ "/app/entrypoint.sh", "node", "/app/apps/dashboard/server.js"]

View File

@@ -1 +0,0 @@
# Dashboard

View File

@@ -1,16 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/utils/cn"
}
}

View File

@@ -1,33 +0,0 @@
#!/bin/sh
set -e
echo "> Replace env variable placeholders with runtime values..."
# Define environment variables to check (space-separated string)
variables_to_replace="NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_API_URL"
# Replace env variable placeholders with real values
for key in $variables_to_replace; do
value=$(eval echo \$"$key")
if [ -n "$value" ]; then
echo " - Searching for $key with value $value..."
# Use standard placeholder format for all variables
placeholder="__${key}__"
# Run the replacement
find /app -type f \( -name "*.js" -o -name "*.html" \) | while read -r file; do
if grep -q "$placeholder" "$file"; then
echo " - Replacing in file: $file"
sed -i "s|$placeholder|$value|g" "$file"
fi
done
else
echo " - Skipping $key as it has no value set."
fi
done
echo "> Done!"
echo "> Running $@"
# Execute the container's main process (CMD in Dockerfile)
exec "$@"

View File

@@ -1,47 +0,0 @@
// @ts-expect-error
import { PrismaPlugin } from '@prisma/nextjs-monorepo-workaround-plugin';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
webpack: (config, { isServer }) => {
if (isServer) {
config.plugins = [...config.plugins, new PrismaPlugin()];
}
return config;
},
reactStrictMode: true,
transpilePackages: [
'@openpanel/queue',
'@openpanel/db',
'@openpanel/common',
'@openpanel/constants',
'@openpanel/redis',
'@openpanel/validation',
'@openpanel/email',
],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
// Avoid "Critical dependency: the request of a dependency is an expression"
serverComponentsExternalPackages: [
'bullmq',
'ioredis',
'@hyperdx/node-opentelemetry',
'@node-rs/argon2',
],
instrumentationHook: !!process.env.ENABLE_INSTRUMENTATION_HOOK,
},
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ['en'],
defaultLocale: 'en',
},
};
export default config;

View File

@@ -1,135 +0,0 @@
{
"name": "@openpanel/dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "rm -rf .next && pnpm with-env next dev",
"testing": "pnpm dev",
"build": "pnpm with-env next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@clickhouse/client": "^1.2.0",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@openpanel/auth": "workspace:^",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/db": "workspace:^",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/nextjs": "1.0.3",
"@openpanel/queue": "workspace:^",
"@openpanel/sdk-info": "workspace:^",
"@openpanel/validation": "workspace:^",
"@prisma/nextjs-monorepo-workaround-plugin": "^5.12.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-portal": "^1.1.1",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^1.9.7",
"@t3-oss/env-nextjs": "^0.7.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.8",
"@trpc/client": "^10.45.2",
"@trpc/next": "^10.45.2",
"@trpc/react-query": "^10.45.2",
"@trpc/server": "^10.45.2",
"@types/d3": "^7.4.3",
"bcrypt": "^5.1.1",
"bind-event-listener": "^3.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"embla-carousel-react": "8.0.0-rc22",
"flag-icons": "^7.1.0",
"framer-motion": "^11.0.28",
"geist": "^1.3.1",
"hamburger-react": "^2.5.0",
"input-otp": "^1.2.4",
"javascript-time-ago": "^2.5.9",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.451.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"next": "14.2.1",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
"nextjs-toploader": "^1.6.11",
"nuqs": "^2.0.2",
"prisma-error-enum": "^0.1.3",
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"rc-virtual-list": "^3.14.5",
"react": "18.2.0",
"react-animate-height": "^3.2.3",
"react-animated-numbers": "^0.18.0",
"react-day-picker": "^8.10.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.50.1",
"react-in-viewport": "1.0.0-alpha.30",
"react-redux": "^8.1.3",
"react-responsive": "^9.0.2",
"react-simple-maps": "3.0.0",
"react-svg-worldmap": "2.0.0-alpha.16",
"react-syntax-highlighter": "^15.5.0",
"react-use-websocket": "^4.7.0",
"react-virtualized-auto-sizer": "^1.0.22",
"recharts": "^2.12.0",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"sqlstring": "^2.3.3",
"superjson": "^1.13.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.14.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@openpanel/trpc": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@openpanel/payments": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "20.14.8",
"@types/ramda": "^0.29.10",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-simple-maps": "^3.0.4",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/sqlstring": "^2.3.2",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

@@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

View File

@@ -1,181 +0,0 @@
'use client';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { cn } from '@/utils/cn';
import {
ChevronRight,
LayoutPanelTopIcon,
MoreHorizontal,
PlusIcon,
Trash,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
timeWindows,
} from '@openpanel/constants';
import type { IServiceDashboard, getReportsByDashboardId } from '@openpanel/db';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
dashboard: IServiceDashboard;
}
export function ListReports({ reports, dashboard }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate, interval } = useOverviewOptions();
const deletion = api.report.delete.useMutation({
onError: handleError,
onSuccess() {
router.refresh();
toast('Report deleted');
},
});
return (
<>
<div className="row mb-4 items-center justify-between">
<h1 className="text-3xl font-semibold">{dashboard.name}</h1>
<div className="flex items-center justify-end gap-2">
<OverviewRange />
<OverviewInterval />
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`,
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</div>
</div>
<div className="flex max-w-6xl flex-col gap-8">
{reports.map((report) => {
const chartRange = report.range;
return (
<div className="card" key={report.id}>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex items-center justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100"
shallow
>
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange].label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>{timeWindows[range].label}</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
deletion.mutate({
reportId: report.id,
});
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
className="opacity-10 transition-opacity"
size={16}
/>
</div>
</Link>
<div
className={cn('p-4', report.chartType === 'metric' && 'p-0')}
>
<ReportChart
{...report}
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}}
/>
</div>
</div>
);
})}
{reports.length === 0 && (
<FullPageEmptyState title="No reports" icon={LayoutPanelTopIcon}>
<p>You can visualize your data with a report</p>
<Button
onClick={() =>
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`,
)
}
className="mt-14"
icon={PlusIcon}
>
Create report
</Button>
</FullPageEmptyState>
)}
</div>
</>
);
}

View File

@@ -1,32 +0,0 @@
import { Padding } from '@/components/ui/padding';
import { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
import { ListReports } from './list-reports';
interface PageProps {
params: {
projectId: string;
dashboardId: string;
};
}
export default async function Page({
params: { projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId, projectId),
getReportsByDashboardId(dashboardId),
]);
if (!dashboard) {
return notFound();
}
return (
<Padding>
<ListReports reports={reports} dashboard={dashboard} />
</Padding>
);
}

View File

@@ -1,22 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
export function HeaderDashboards() {
return (
<div className="mb-4 flex items-center justify-between">
<h1 className="text-3xl font-semibold">Dashboards</h1>
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Padding } from '@/components/ui/padding';
import withSuspense from '@/hocs/with-suspense';
import { getDashboardsByProjectId } from '@openpanel/db';
import { HeaderDashboards } from './header';
import { ListDashboards } from './list-dashboards';
interface Props {
projectId: string;
}
const ListDashboardsServer = async ({ projectId }: Props) => {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<Padding>
<HeaderDashboards />
<ListDashboards dashboards={dashboards} />
</Padding>
);
};
export default withSuspense(ListDashboardsServer, FullPageLoadingState);

View File

@@ -1,11 +0,0 @@
import ListDashboardsServer from './list-dashboards';
interface PageProps {
params: {
projectId: string;
};
}
export default function Page({ params: { projectId } }: PageProps) {
return <ListDashboardsServer projectId={projectId} />;
}

View File

@@ -1,24 +0,0 @@
'use client';
import { EventsTable } from '@/components/events/table';
import { api } from '@/trpc/client';
type Props = {
projectId: string;
profileId?: string;
};
const Conversions = ({ projectId }: Props) => {
const query = api.event.conversions.useQuery(
{
projectId,
},
{
keepPreviousData: true,
},
);
return <EventsTable query={query} />;
};
export default Conversions;

View File

@@ -1,66 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import EventListener from '@/components/events/event-listener';
import { EventsTable } from '@/components/events/table';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [eventNames] = useEventQueryNamesFilter();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const query = api.event.events.useQuery(
{
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
},
{
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<EventListener onRefresh={() => query.refetch()} />
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -1,49 +0,0 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
import Charts from './charts';
import Conversions from './conversions';
import Events from './events';
interface PageProps {
params: {
projectId: string;
};
searchParams: Record<string, string>;
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['events', 'conversions', 'charts'])
.withDefault('events')
.parseServerSide(searchParams.tab);
return (
<>
<Padding>
<div className="mb-4">
<PageTabs>
<PageTabsLink href={'?tab=events'} isActive={tab === 'events'}>
Events
</PageTabsLink>
<PageTabsLink
href={'?tab=conversions'}
isActive={tab === 'conversions'}
>
Conversions
</PageTabsLink>
<PageTabsLink href={'?tab=charts'} isActive={tab === 'charts'}>
Charts
</PageTabsLink>
</PageTabs>
</div>
{tab === 'events' && <Events projectId={projectId} />}
{tab === 'conversions' && <Conversions projectId={projectId} />}
{tab === 'charts' && <Charts projectId={projectId} />}
</Padding>
</>
);
}

View File

@@ -1,21 +0,0 @@
'use client';
import { useSelectedLayoutSegments } from 'next/navigation';
const NOT_MIGRATED_PAGES = ['reports'];
export default function LayoutContent({
children,
}: {
children: React.ReactNode;
}) {
const segments = useSelectedLayoutSegments();
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
return <div className="pb-20 transition-all lg:pl-72">{children}</div>;
}
return (
<div className="pb-20 transition-all max-lg:mt-12 lg:pl-72">{children}</div>
);
}

View File

@@ -1,204 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import {
BanknoteIcon,
ChartLineIcon,
DollarSignIcon,
GanttChartIcon,
Globe2Icon,
LayersIcon,
LayoutPanelTopIcon,
PlusIcon,
ScanEyeIcon,
ServerIcon,
UsersIcon,
WallpaperIcon,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { ProjectLink } from '@/components/links';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
import { differenceInDays, format } from 'date-fns';
function LinkWithIcon({
href,
icon: Icon,
label,
active: overrideActive,
className,
}: {
href: string;
icon: LucideIcon;
label: React.ReactNode;
active?: boolean;
className?: string;
}) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return (
<ProjectLink
className={cn(
'text-text flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200',
active && 'bg-def-200',
className,
)}
href={href}
>
<Icon size={20} />
<div className="flex-1">{label}</div>
</ProjectLink>
);
}
interface LayoutMenuProps {
dashboards: IServiceDashboards;
organization: IServiceOrganization;
}
export default function LayoutMenu({
dashboards,
organization,
}: LayoutMenuProps) {
const number = useNumber();
const {
isTrial,
isExpired,
isExceeded,
isCanceled,
subscriptionEndsAt,
subscriptionPeriodEventsCount,
subscriptionPeriodEventsLimit,
} = organization;
return (
<>
<div className="col border rounded mb-2 divide-y">
{process.env.SELF_HOSTED && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row items-center gap-2 pointer-events-none',
)}
>
<ServerIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Self-hosted</div>
</div>
</ProjectLink>
)}
{isTrial && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row items-center gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">
Free trial ends in{' '}
{differenceInDays(subscriptionEndsAt, new Date())} days
</div>
</div>
</ProjectLink>
)}
{isExpired && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Subscription expired</div>
<div className="text-sm opacity-80">
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
</div>
</div>
</ProjectLink>
)}
{isCanceled && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Subscription canceled</div>
<div className="text-sm opacity-80">
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
</div>
</div>
</ProjectLink>
)}
{isExceeded && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Events limit exceeded</div>
<div className="text-sm opacity-80">
{number.format(subscriptionPeriodEventsCount)} /{' '}
{number.format(subscriptionPeriodEventsLimit)}
</div>
</div>
</ProjectLink>
)}
<ProjectLink
href={'/reports'}
className={cn('rounded p-2 row gap-2 hover:bg-def-200')}
>
<ChartLineIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Create report</div>
</div>
<PlusIcon size={16} className="text-muted-foreground" />
</ProjectLink>
</div>
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
<LinkWithIcon
icon={LayoutPanelTopIcon}
label="Dashboards"
href={'/dashboards'}
/>
<LinkWithIcon icon={LayersIcon} label="Pages" href={'/pages'} />
<LinkWithIcon icon={Globe2Icon} label="Realtime" href={'/realtime'} />
<LinkWithIcon icon={GanttChartIcon} label="Events" href={'/events'} />
<LinkWithIcon icon={UsersIcon} label="Profiles" href={'/profiles'} />
<LinkWithIcon icon={ScanEyeIcon} label="Retention" href={'/retention'} />
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-muted-foreground">Your dashboards</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground"
onClick={() => pushModal('AddDashboard')}
>
<PlusIcon size={16} />
</Button>
</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={item.name}
href={`/dashboards/${item.id}`}
/>
))}
</div>
</div>
</>
);
}

View File

@@ -1,43 +0,0 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { Building } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { IServiceOrganization } from '@openpanel/db';
interface LayoutOrganizationSelectorProps {
organizations: IServiceOrganization[];
}
export default function LayoutOrganizationSelector({
organizations,
}: LayoutOrganizationSelectorProps) {
const params = useAppParams();
const router = useRouter();
const organization = organizations.find(
(item) => item.id === params.organizationId,
);
return (
<Combobox
className="w-full"
placeholder="Select organization"
icon={Building}
value={organization?.id}
items={
organizations
.filter((item) => item.id)
.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
onChange={(value) => {
router.push(`/${value}`);
}}
/>
);
}

View File

@@ -1,90 +0,0 @@
'use client';
import { LogoSquare } from '@/components/logo';
import SettingsToggle from '@/components/settings-toggle';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { MenuIcon, XIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import type {
IServiceDashboards,
IServiceOrganization,
getProjectsByOrganizationId,
} from '@openpanel/db';
import { useAppParams } from '@/hooks/useAppParams';
import Link from 'next/link';
import LayoutMenu from './layout-menu';
import LayoutProjectSelector from './layout-project-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
projectId: string;
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
}
export function LayoutSidebar({
organizations,
dashboards,
projects,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
const { organizationId } = useAppParams();
const organization = organizations.find((o) => o.id === organizationId)!;
useEffect(() => {
setActive(false);
}, [pathname]);
return (
<>
<button
type="button"
onClick={() => setActive(false)}
className={cn(
'fixed bottom-0 left-0 right-0 top-0 z-50 backdrop-blur-sm transition-opacity',
active
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0',
)}
/>
<div
className={cn(
'fixed left-0 top-0 z-50 flex h-screen w-72 flex-col border-r border-border bg-card transition-transform',
'-translate-x-72 lg:-translate-x-0', // responsive
active && 'translate-x-0', // force active on mobile
)}
>
<div className="absolute -right-12 flex h-16 items-center lg:hidden">
<Button
size="icon"
onClick={() => setActive((p) => !p)}
variant={'outline'}
>
{active ? <XIcon size={16} /> : <MenuIcon size={16} />}
</Button>
</div>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<Link href="/">
<LogoSquare className="max-h-8" />
</Link>
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
<LayoutMenu dashboards={dashboards} organization={organization} />
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="h-8 w-full bg-gradient-to-t from-card to-card/0" />
</div>
</div>
</>
);
}

View File

@@ -1,22 +0,0 @@
import { cn } from '@/utils/cn';
interface StickyBelowHeaderProps {
children: React.ReactNode;
className?: string;
}
export function StickyBelowHeader({
children,
className,
}: StickyBelowHeaderProps) {
return (
<div
className={cn(
'top-0 z-20 border-b border-border bg-card md:sticky',
className,
)}
>
{children}
</div>
);
}

View File

@@ -1,64 +0,0 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import {
getDashboardsByProjectId,
getOrganizations,
getProjects,
} from '@openpanel/db';
import { auth } from '@openpanel/auth/nextjs';
import LayoutContent from './layout-content';
import { LayoutSidebar } from './layout-sidebar';
import SideEffects from './side-effects';
interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationSlug: string;
projectId: string;
};
}
export default async function AppLayout({
children,
params: { organizationSlug: organizationId, projectId },
}: AppLayoutProps) {
const { userId } = await auth();
const [organizations, projects, dashboards] = await Promise.all([
getOrganizations(userId),
getProjects({ organizationId, userId }),
getDashboardsByProjectId(projectId),
]);
if (!organizations.find((item) => item.id === organizationId)) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The organization you were looking for could not be found.
</FullPageEmptyState>
);
}
if (!projects.find((item) => item.id === projectId)) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The project you were looking for could not be found.
</FullPageEmptyState>
);
}
return (
<div id="dashboard">
<LayoutSidebar
{...{
organizationId,
projectId,
organizations,
projects,
dashboards,
}}
/>
<LayoutContent>{children}</LayoutContent>
<SideEffects />
</div>
);
}

View File

@@ -1,15 +0,0 @@
interface PageLayoutProps {
title: React.ReactNode;
}
function PageLayout({ title }: PageLayoutProps) {
return (
<>
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-14 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
</div>
</>
);
}
export default PageLayout;

View File

@@ -1,34 +0,0 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
import { Pages } from './pages';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['pages', 'trends'])
.withDefault('pages')
.parseServerSide(searchParams.tab);
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink href="?tab=pages" isActive={tab === 'pages'}>
Pages
</PageTabsLink>
</PageTabs>
{tab === 'pages' && <Pages projectId={projectId} />}
</Padding>
);
}

View File

@@ -1,128 +0,0 @@
'use client';
import { ReportChart } from '@/components/report-chart';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import isEqual from 'lodash.isequal';
import { ExternalLinkIcon } from 'lucide-react';
import { memo } from 'react';
import type { IServicePage } from '@openpanel/db';
export const PagesTable = memo(
({ data }: { data: IServicePage[] }) => {
const number = useNumber();
const cell =
'flex min-h-12 whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border';
return (
<div className="overflow-x-auto rounded-md border bg-background">
<div className={cn('min-w-[800px]')}>
<div className="grid grid-cols-[0.2fr_auto_1fr] overflow-hidden rounded-t-none border-b">
<div className="center-center h-10 rounded-tl-md bg-def-100 p-4 font-semibold text-muted-foreground">
Views
</div>
<div className="flex h-10 w-80 items-center bg-def-100 p-4 font-semibold text-muted-foreground">
Path
</div>
<div className="flex h-10 items-center rounded-tr-md bg-def-100 p-4 font-semibold text-muted-foreground">
Chart
</div>
</div>
{data.map((item, index) => {
return (
<div
key={item.path + item.origin + item.title}
className="grid grid-cols-[0.2fr_auto_1fr] border-b transition-colors last:border-b-0 hover:bg-muted/50 data-[state=selected]:bg-muted"
>
<div
className={cn(
cell,
'center-center font-mono text-lg font-semibold',
index === data.length - 1 && 'rounded-bl-md',
)}
>
{number.short(item.count)}
</div>
<div
className={cn(
cell,
'flex w-80 flex-col justify-center gap-2 text-left',
)}
>
<span className="truncate font-medium">{item.title}</span>
{item.origin ? (
<a
href={item.origin + item.path}
className="truncate font-mono text-sm text-muted-foreground underline"
>
<ExternalLinkIcon className="mr-2 inline-block size-3" />
{item.path}
</a>
) : (
<span className="truncate font-mono text-sm text-muted-foreground">
{item.path}
</span>
)}
</div>
<div
className={cn(
cell,
'p-1',
index === data.length - 1 && 'rounded-br-md',
)}
>
<ReportChart
options={{
hideID: true,
hideXAxis: true,
hideYAxis: true,
aspectRatio: 0.15,
}}
report={{
lineType: 'linear',
breakdowns: [],
name: 'screen_view',
metric: 'sum',
range: '30d',
interval: 'day',
previous: true,
chartType: 'linear',
projectId: item.project_id,
events: [
{
id: 'A',
name: 'screen_view',
segment: 'event',
filters: [
{
id: 'path',
name: 'path',
value: [item.path],
operator: 'is',
},
{
id: 'origin',
name: 'origin',
value: [item.origin],
operator: 'is',
},
],
},
],
}}
/>
</div>
</div>
);
})}
</div>
</div>
);
},
(prevProps, nextProps) => {
return isEqual(prevProps.data, nextProps.data);
},
);
PagesTable.displayName = 'PagesTable';

View File

@@ -1,65 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
import { TableSkeleton } from '@/components/ui/table';
import { useDebounceValue } from '@/hooks/useDebounceValue';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { PagesTable } from './pages-table';
export function Pages({ projectId }: { projectId: string }) {
const take = 20;
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const [search, setSearch] = useQueryState('search', {
defaultValue: '',
shallow: true,
});
const debouncedSearch = useDebounceValue(search, 500);
const query = api.event.pages.useQuery(
{
projectId,
cursor,
take,
search: debouncedSearch,
},
{
keepPreviousData: true,
},
);
const data = query.data ?? [];
return (
<>
<TableButtons>
<Input
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(0);
}}
/>
</TableButtons>
{query.isLoading ? (
<TableSkeleton cols={3} />
) : (
<PagesTable data={data} />
)}
<Pagination
className="mt-2"
setCursor={setCursor}
cursor={cursor}
count={Number.POSITIVE_INFINITY}
take={take}
loading={query.isFetching}
/>
</>
);
}

View File

@@ -1,20 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import MostEvents from './most-events';
type Props = {
projectId: string;
profileId: string;
};
const MostEventsServer = async ({ projectId, profileId }: Props) => {
const data = await chQuery<{ count: number; name: string }>(
`SELECT count(*) as count, name FROM ${TABLE_NAMES.events} WHERE name NOT IN ('screen_view', 'session_start', 'session_end') AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY name ORDER BY count DESC`,
);
return <MostEvents data={data} />;
};
export default withLoadingWidget(MostEventsServer);

View File

@@ -1,80 +0,0 @@
import ClickToCopy from '@/components/click-to-copy';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Padding } from '@/components/ui/padding';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { getProfileById, getProfileByIdCached } from '@openpanel/db';
import MostEventsServer from './most-events';
import PopularRoutesServer from './popular-routes';
import ProfileActivityServer from './profile-activity';
import ProfileCharts from './profile-charts';
import Events from './profile-events';
import ProfileMetrics from './profile-metrics';
interface PageProps {
params: {
projectId: string;
profileId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
startDate: string;
endDate: string;
};
}
export default async function Page({
params: { projectId, profileId },
}: PageProps) {
const profile = await getProfileById(
decodeURIComponent(profileId),
projectId,
);
if (!profile) {
return notFound();
}
return (
<Padding>
<div className="row mb-4 items-center gap-4">
<ProfileAvatar {...profile} />
<div className="min-w-0">
<ClickToCopy value={profile.id}>
<h1 className="max-w-full truncate text-3xl font-semibold">
{getProfileName(profile)}
</h1>
</ClickToCopy>
</div>
</div>
<div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-6">
<ProfileMetrics projectId={projectId} profile={profile} />
</div>
<div className="col-span-6">
<ProfileActivityServer
profileId={profileId}
projectId={projectId}
/>
</div>
<div className="col-span-6 md:col-span-3">
<MostEventsServer profileId={profileId} projectId={projectId} />
</div>
<div className="col-span-6 md:col-span-3">
<PopularRoutesServer profileId={profileId} projectId={projectId} />
</div>
<ProfileCharts profileId={profileId} projectId={projectId} />
</div>
<div className="mt-8">
<Events profileId={profileId} projectId={projectId} />
</div>
</div>
</Padding>
);
}

View File

@@ -1,20 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import PopularRoutes from './popular-routes';
type Props = {
projectId: string;
profileId: string;
};
const PopularRoutesServer = async ({ projectId, profileId }: Props) => {
const data = await chQuery<{ count: number; path: string }>(
`SELECT count(*) as count, path FROM ${TABLE_NAMES.events} WHERE name = 'screen_view' AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY path ORDER BY count DESC`,
);
return <PopularRoutes data={data} />;
};
export default withLoadingWidget(PopularRoutesServer);

View File

@@ -1,20 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import ProfileActivity from './profile-activity';
type Props = {
projectId: string;
profileId: string;
};
const ProfileActivityServer = async ({ projectId, profileId }: Props) => {
const data = await chQuery<{ count: number; date: string }>(
`SELECT count(*) as count, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY date ORDER BY date DESC`,
);
return <ProfileActivity data={data} />;
};
export default withLoadingWidget(ProfileActivityServer);

View File

@@ -1,164 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Widget,
WidgetBody,
WidgetHead,
WidgetTitle,
} from '@/components/widget';
import { cn } from '@/utils/cn';
import {
addMonths,
eachDayOfInterval,
endOfMonth,
format,
formatISO,
isSameMonth,
startOfMonth,
subMonths,
} from 'date-fns';
import { ActivityIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { useState } from 'react';
type Props = {
data: { count: number; date: string }[];
};
const ProfileActivity = ({ data }: Props) => {
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
const endDate = endOfMonth(startDate);
return (
<Widget className="w-full">
<WidgetHead className="flex justify-between">
<WidgetTitle icon={ActivityIcon}>Activity</WidgetTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setStartDate(subMonths(startDate, 1))}
>
<ChevronLeftIcon size={14} />
</Button>
<Button
variant="outline"
size="icon"
disabled={isSameMonth(startDate, new Date())}
onClick={() => setStartDate(addMonths(startDate, 1))}
>
<ChevronRightIcon size={14} />
</Button>
</div>
</WidgetHead>
<WidgetBody>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 3), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 3)),
end: endOfMonth(subMonths(startDate, 3)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 2), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 2)),
end: endOfMonth(subMonths(startDate, 2)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 1), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 1)),
end: endOfMonth(subMonths(startDate, 1)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">{format(startDate, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startDate,
end: endDate,
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' }),
),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
</div>
</WidgetBody>
</Widget>
);
};
export default ProfileActivity;

View File

@@ -1,106 +0,0 @@
'use client';
import { ReportChart } from '@/components/report-chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { memo } from 'react';
import type { IChartProps } from '@openpanel/validation';
type Props = {
profileId: string;
projectId: string;
};
const ProfileCharts = ({ profileId, projectId }: Props) => {
const pageViewsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
],
breakdowns: [
{
id: 'path',
name: 'path',
},
],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '30d',
previous: false,
metric: 'sum',
};
const eventsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
],
breakdowns: [
{
id: 'name',
name: 'name',
},
],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '30d',
previous: false,
metric: 'sum',
};
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Page views</span>
</WidgetHead>
<WidgetBody>
<ReportChart report={pageViewsChart} />
</WidgetBody>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ReportChart report={eventsChart} />
</WidgetBody>
</Widget>
</>
);
};
// No clue why I need to check for equality here
export default memo(ProfileCharts, (a, b) => {
return a.profileId === b.profileId && a.projectId === b.projectId;
});

View File

@@ -1,66 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { EventsTable } from '@/components/events/table';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { GetEventListOptions } from '@openpanel/db';
type Props = {
projectId: string;
profileId: string;
};
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [eventNames] = useEventQueryNamesFilter();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const query = api.event.events.useQuery(
{
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
},
{
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -1,18 +0,0 @@
import withSuspense from '@/hocs/with-suspense';
import type { IServiceProfile } from '@openpanel/db';
import { getProfileMetrics } from '@openpanel/db';
import ProfileMetrics from './profile-metrics';
type Props = {
projectId: string;
profile: IServiceProfile;
};
const ProfileMetricsServer = async ({ projectId, profile }: Props) => {
const data = await getProfileMetrics(profile.id, projectId);
return <ProfileMetrics data={data} profile={profile} />;
};
export default withSuspense(ProfileMetricsServer, () => null);

View File

@@ -1,123 +0,0 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { formatDateTime, utc } from '@/utils/date';
import { formatDistanceToNow } from 'date-fns';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import type { IProfileMetrics, IServiceProfile } from '@openpanel/db';
type Props = {
data: IProfileMetrics;
profile: IServiceProfile;
};
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
<div className="text-muted-foreground">{title}</div>
<div className="truncate font-mono text-2xl font-bold">{value}</div>
</div>
);
}
function Info({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2">
<div className="capitalize text-muted-foreground">{title}</div>
<div className="truncate font-mono">
{value
? typeof value === 'string'
? value
: JSON.stringify(value)
: '-'}
</div>
</div>
);
}
const ProfileMetrics = ({ data, profile }: Props) => {
const [tab, setTab] = useQueryState(
'tab',
parseAsStringEnum(['profile', 'properties']).withDefault('profile'),
);
const number = useNumber();
return (
<div className="@container">
<div className="grid grid-cols-2 overflow-hidden whitespace-nowrap rounded-md border bg-background @xl:grid-cols-3 @4xl:grid-cols-6">
<div className="col-span-2 @xl:col-span-3 @4xl:col-span-6">
<div className="row border-b">
<button
type="button"
onClick={() => setTab('profile')}
className={cn(
'p-4',
'opacity-50',
tab === 'profile' &&
'border-b border-foreground text-foreground opacity-100',
)}
>
Profile
</button>
<div className="h-full w-px bg-border" />
<button
type="button"
onClick={() => setTab('properties')}
className={cn(
'p-4',
'opacity-50',
tab === 'properties' &&
'border-b border-foreground text-foreground opacity-100',
)}
>
Properties
</button>
</div>
<div className="grid grid-cols-2 gap-4 p-4">
{tab === 'profile' && (
<>
<Info title="ID" value={profile.id} />
<Info title="First name" value={profile.firstName} />
<Info title="Last name" value={profile.lastName} />
<Info title="Email" value={profile.email} />
<Info
title="Updated"
value={formatDateTime(new Date(profile.createdAt))}
/>
<ListPropertiesIcon {...profile.properties} />
</>
)}
{tab === 'properties' &&
Object.entries(profile.properties)
.filter(([key, value]) => value !== undefined)
.map(([key, value]) => (
<Info key={key} title={key} value={value as string} />
))}
</div>
</div>
<Card
title="First seen"
value={formatDistanceToNow(utc(data.firstSeen))}
/>
<Card
title="Last seen"
value={formatDistanceToNow(utc(data.lastSeen))}
/>
<Card title="Sessions" value={number.format(data.sessions)} />
<Card
title="Avg. Session"
value={number.formatWithUnit(data.durationAvg / 1000, 'min')}
/>
<Card
title="P90. Session"
value={number.formatWithUnit(data.durationP90 / 1000, 'min')}
/>
<Card title="Page views" value={number.format(data.screenViews)} />
</div>
</div>
);
};
export default ProfileMetrics;

View File

@@ -1,44 +0,0 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
import PowerUsers from './power-users';
import Profiles from './profiles';
interface PageProps {
params: {
projectId: string;
};
searchParams: Record<string, string>;
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['profiles', 'power-users'])
.withDefault('profiles')
.parseServerSide(searchParams.tab);
return (
<>
<Padding>
<div className="mb-4">
<PageTabs>
<PageTabsLink href={'?tab=profiles'} isActive={tab === 'profiles'}>
Profiles
</PageTabsLink>
<PageTabsLink
href={'?tab=power-users'}
isActive={tab === 'power-users'}
>
Power users
</PageTabsLink>
</PageTabs>
</div>
{tab === 'profiles' && <Profiles projectId={projectId} />}
{tab === 'power-users' && <PowerUsers projectId={projectId} />}
</Padding>
</>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import { ProfilesTable } from '@/components/profiles/table';
import { api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId }: Props) => {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const query = api.profile.powerUsers.useQuery(
{
cursor,
projectId,
take: 50,
// filters,
},
{
keepPreviousData: true,
},
);
return (
<div>
<ProfilesTable
query={query}
cursor={cursor}
setCursor={setCursor}
type="power-users"
/>
</div>
);
};
export default Events;

View File

@@ -1,68 +0,0 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { cn } from '@/utils/cn';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
interface Props {
projectId: string;
}
export default async function ProfileLastSeenServer({ projectId }: Props) {
interface Row {
days: number;
count: number;
}
// Days since last event from users
// group by days
const res = await chQuery<Row>(
`SELECT age('days',created_at, now()) as days, count(distinct profile_id) as count FROM ${TABLE_NAMES.events} where project_id = ${escape(projectId)} group by days order by days ASC LIMIT 51`,
);
const maxValue = Math.max(...res.map((x) => x.count));
const minValue = Math.min(...res.map((x) => x.count));
const calculateRatio = (currentValue: number) =>
Math.max(
0.1,
Math.min(1, (currentValue - minValue) / (maxValue - minValue)),
);
const renderItem = (item: Row) => (
<div className="flex w-1/12 flex-col items-center p-1">
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn('aspect-square w-full shrink-0 rounded bg-highlight')}
style={{
opacity: calculateRatio(item.count),
}}
/>
</TooltipTrigger>
<TooltipContent>
{item.count} profiles last seen{' '}
{item.days === 0 ? 'today' : `${item.days} days ago`}
</TooltipContent>
</Tooltip>
<div className="mt-1 text-[10px]">{item.days}</div>
</div>
);
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Last seen</div>
</WidgetHead>
<WidgetBody>
<div className="flex w-full flex-wrap items-start justify-start">
{res.map(renderItem)}
</div>
<div className="text-center text-sm text-muted-foreground">DAYS</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,51 +0,0 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { ProfilesTable } from '@/components/profiles/table';
import { Input } from '@/components/ui/input';
import { useDebounceValue } from '@/hooks/useDebounceValue';
import { api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId }: Props) => {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const [search, setSearch] = useQueryState('search', {
defaultValue: '',
shallow: true,
});
const debouncedSearch = useDebounceValue(search, 500);
const query = api.profile.list.useQuery(
{
cursor,
projectId,
take: 50,
search: debouncedSearch,
},
{
keepPreviousData: true,
},
);
return (
<div>
<TableButtons>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search profiles"
/>
</TableButtons>
<ProfilesTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -1,222 +0,0 @@
export type Coordinate = {
lat: number;
long: number;
};
export function haversineDistance(
coord1: Coordinate,
coord2: Coordinate,
): number {
const R = 6371; // Earth's radius in kilometers
const lat1Rad = coord1.lat * (Math.PI / 180);
const lat2Rad = coord2.lat * (Math.PI / 180);
const deltaLatRad = (coord2.lat - coord1.lat) * (Math.PI / 180);
const deltaLonRad = (coord2.long - coord1.long) * (Math.PI / 180);
const a =
Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) *
Math.cos(lat2Rad) *
Math.sin(deltaLonRad / 2) *
Math.sin(deltaLonRad / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
export function findFarthestPoints(
coordinates: Coordinate[],
): [Coordinate, Coordinate] {
if (coordinates.length < 2) {
throw new Error('At least two coordinates are required');
}
let maxDistance = 0;
let point1: Coordinate = coordinates[0]!;
let point2: Coordinate = coordinates[1]!;
for (let i = 0; i < coordinates.length; i++) {
for (let j = i + 1; j < coordinates.length; j++) {
const distance = haversineDistance(coordinates[i]!, coordinates[j]!);
if (distance > maxDistance) {
maxDistance = distance;
point1 = coordinates[i]!;
point2 = coordinates[j]!;
}
}
}
return [point1, point2];
}
export function getAverageCenter(coordinates: Coordinate[]): Coordinate {
if (coordinates.length === 0) {
return { long: 0, lat: 20 };
}
let sumLong = 0;
let sumLat = 0;
for (const coord of coordinates) {
sumLong += coord.long;
sumLat += coord.lat;
}
const avgLat = sumLat / coordinates.length;
const avgLong = sumLong / coordinates.length;
return { long: avgLong, lat: avgLat };
}
function sortCoordinates(a: Coordinate, b: Coordinate): number {
return a.long === b.long ? a.lat - b.lat : a.long - b.long;
}
function cross(o: Coordinate, a: Coordinate, b: Coordinate): number {
return (
(a.long - o.long) * (b.lat - o.lat) - (a.lat - o.lat) * (b.long - o.long)
);
}
// convex hull
export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
const sorted = coordinates.sort(sortCoordinates);
if (sorted.length <= 3) return sorted;
const lower: Coordinate[] = [];
for (const coord of sorted) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, coord) <= 0
) {
lower.pop();
}
lower.push(coord);
}
const upper: Coordinate[] = [];
for (let i = coordinates.length - 1; i >= 0; i--) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, sorted[i]!) <= 0
) {
upper.pop();
}
upper.push(sorted[i]!);
}
upper.pop();
lower.pop();
return lower.concat(upper);
}
export function calculateCentroid(polygon: Coordinate[]): Coordinate {
if (polygon.length < 3) {
throw new Error('At least three points are required to form a polygon.');
}
let area = 0;
let centroidLat = 0;
let centroidLong = 0;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const x0 = polygon[j]!.long;
const y0 = polygon[j]!.lat;
const x1 = polygon[i]!.long;
const y1 = polygon[i]!.lat;
const a = x0 * y1 - x1 * y0;
area += a;
centroidLong += (x0 + x1) * a;
centroidLat += (y0 + y1) * a;
}
area = area / 2;
if (area === 0) {
// This should not happen for a proper convex hull
throw new Error('Area of the polygon is zero, check the coordinates.');
}
centroidLat /= 6 * area;
centroidLong /= 6 * area;
return { lat: centroidLat, long: centroidLong };
}
export function calculateGeographicMidpoint(
coordinate: Coordinate[],
): Coordinate {
let minLat = Number.POSITIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
let minLong = Number.POSITIVE_INFINITY;
let maxLong = Number.NEGATIVE_INFINITY;
for (const { lat, long } of coordinate) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (long < minLong) minLong = long;
if (long > maxLong) maxLong = long;
}
// Handling the wrap around the international date line
let midLong: number;
if (maxLong > minLong) {
midLong = (maxLong + minLong) / 2;
} else {
// Adjust calculation when spanning the dateline
midLong = ((maxLong + 360 + minLong) / 2) % 360;
}
const midLat = (maxLat + minLat) / 2;
return { lat: midLat, long: midLong };
}
export function clusterCoordinates(coordinates: Coordinate[], radius = 25) {
const clusters: {
center: Coordinate;
count: number;
members: Coordinate[];
}[] = [];
const visited = new Set<number>();
coordinates.forEach((coord, idx) => {
if (!visited.has(idx)) {
const cluster = {
members: [coord],
center: { lat: coord.lat, long: coord.long },
count: 0,
};
coordinates.forEach((otherCoord, otherIdx) => {
if (
!visited.has(otherIdx) &&
haversineDistance(coord, otherCoord) <= radius
) {
cluster.members.push(otherCoord);
visited.add(otherIdx);
cluster.count++;
}
});
// Calculate geographic center for the cluster
cluster.center = cluster.members.reduce(
(center, cur) => {
return {
lat: center.lat + cur.lat / cluster.members.length,
long: center.long + cur.long / cluster.members.length,
};
},
{ lat: 0, long: 0 },
);
clusters.push(cluster);
}
});
return clusters.map((cluster) => ({
center: cluster.center,
count: cluster.count,
members: cluster.members,
}));
}

View File

@@ -1,20 +0,0 @@
import { subMinutes } from 'date-fns';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery, formatClickhouseDate } from '@openpanel/db';
import type { Coordinate } from './coordinates';
import Map from './map';
type Props = {
projectId: string;
};
const RealtimeMap = async ({ projectId }: Props) => {
const res = await chQuery<Coordinate>(
`SELECT DISTINCT city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`,
);
return <Map markers={res} />;
};
export default RealtimeMap;

View File

@@ -1,195 +0,0 @@
'use client';
import { useFullscreen } from '@/components/fullscreen-toggle';
import { Tooltiper } from '@/components/ui/tooltip';
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { useTheme } from 'next-themes';
import { Fragment, useEffect, useRef, useState } from 'react';
import {
ComposableMap,
Geographies,
Geography,
Marker,
} from 'react-simple-maps';
import type { Coordinate } from './coordinates';
import {
calculateGeographicMidpoint,
clusterCoordinates,
getAverageCenter,
getOuterMarkers,
} from './coordinates';
import {
CustomZoomableGroup,
GEO_MAP_URL,
determineZoom,
getBoundingBox,
useAnimatedState,
} from './map.helpers';
import { calculateMarkerSize } from './markers';
type Props = {
markers: Coordinate[];
};
const Map = ({ markers }: Props) => {
const [isFullscreen] = useFullscreen();
const showCenterMarker = false;
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
// const { markers, toggle } = useActiveMarkers(_m);
const hull = getOuterMarkers(markers);
const center =
hull.length < 2
? getAverageCenter(markers)
: calculateGeographicMidpoint(hull);
const boundingBox = getBoundingBox(hull);
const [zoom] = useAnimatedState(
markers.length === 1
? 20
: determineZoom(boundingBox, size ? size?.height / size?.width : 1),
);
const [long] = useAnimatedState(center.long);
const [lat] = useAnimatedState(center.lat);
useEffect(() => {
requestAnimationFrame(() => {
if (ref.current) {
setSize({
width: ref.current.clientWidth,
height: ref.current.clientHeight,
});
}
});
}, [isFullscreen]);
useEffect(() => {
return bind(window, {
type: 'resize',
listener() {
if (ref.current) {
setSize({
width: ref.current.clientWidth,
height: ref.current.clientHeight,
});
}
},
});
}, []);
const adjustSizeBasedOnZoom = (size: number) => {
const minMultiplier = 1;
const maxMultiplier = 7;
// Linearly interpolate the multiplier based on the zoom level
const multiplier =
maxMultiplier - ((zoom - 1) * (maxMultiplier - minMultiplier)) / (20 - 1);
return size * multiplier;
};
const theme = useTheme();
return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 top-0',
!isFullscreen && 'lg:left-72',
)}
ref={ref}
>
{size === null ? (
<></>
) : (
<>
<ComposableMap
width={size?.width}
height={size?.height}
projection="geoMercator"
projectionConfig={{
rotate: [0, 0, 0],
scale: 100 * 20,
}}
>
<CustomZoomableGroup zoom={zoom * 0.06} center={[long, lat]}>
<Geographies geography={GEO_MAP_URL}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill={theme.resolvedTheme === 'dark' ? '#000' : '#e5eef6'}
stroke={
theme.resolvedTheme === 'dark' ? '#333' : '#bcccda'
}
pointerEvents={'none'}
/>
))
}
</Geographies>
{showCenterMarker && (
<Marker coordinates={[center.long, center.lat]}>
<circle
r={adjustSizeBasedOnZoom(30)}
fill="green"
stroke="#fff"
strokeWidth={adjustSizeBasedOnZoom(2)}
/>
</Marker>
)}
{clusterCoordinates(markers).map((marker) => {
const size = adjustSizeBasedOnZoom(
calculateMarkerSize(marker.count),
);
const coordinates: [number, number] = [
marker.center.long,
marker.center.lat,
];
return (
<Fragment key={coordinates.join('-')}>
<Marker coordinates={coordinates}>
<circle
r={size}
fill={
theme.resolvedTheme === 'dark' ? '#3d79ff' : '#2266ec'
}
className="animate-ping opacity-20"
/>
</Marker>
<Tooltiper asChild content={`${marker.count} visitors`}>
<Marker coordinates={coordinates}>
<circle
r={size}
fill={
theme.resolvedTheme === 'dark'
? '#3d79ff'
: '#2266ec'
}
fillOpacity={0.5}
/>
</Marker>
</Tooltiper>
</Fragment>
);
})}
</CustomZoomableGroup>
</ComposableMap>
</>
)}
{/* <Button
className="fixed bottom-[100px] left-[320px] z-50 opacity-0"
onClick={() => {
toggle();
}}
>
Toogle
</Button> */}
</div>
);
};
export default Map;

View File

@@ -1,145 +0,0 @@
import {
Fullscreen,
FullscreenClose,
FullscreenOpen,
} from '@/components/fullscreen-toggle';
import { ReportChart } from '@/components/report-chart';
import { Suspense } from 'react';
import RealtimeMap from './map';
import RealtimeLiveEventsServer from './realtime-live-events';
import { RealtimeLiveHistogram } from './realtime-live-histogram';
import RealtimeReloader from './realtime-reloader';
type Props = {
params: {
projectId: string;
};
};
export default function Page({ params: { projectId } }: Props) {
return (
<>
<Fullscreen>
<FullscreenClose />
<RealtimeReloader projectId={projectId} />
<Suspense>
<RealtimeMap projectId={projectId} />
</Suspense>
<div className="row relative z-10 min-h-screen items-start gap-4 overflow-hidden p-8">
<FullscreenOpen />
<div className="card min-w-52 bg-card/80 p-4 md:min-w-80">
<RealtimeLiveHistogram projectId={projectId} />
</div>
<div className="col-span-2">
<RealtimeLiveEventsServer projectId={projectId} limit={5} />
</div>
</div>
<div className="relative z-10 -mt-32 grid gap-4 p-8 md:grid-cols-3">
<div className="card p-4">
<div className="mb-6">
<div className="font-bold">Pages</div>
</div>
<ReportChart
options={{
hideID: true,
}}
report={{
projectId,
events: [
{
filters: [],
segment: 'event',
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: 'minute',
name: 'Top sources',
range: '30min',
previous: false,
metric: 'sum',
}}
/>
</div>
<div className="card p-4">
<div className="mb-6">
<div className="font-bold">Cities</div>
</div>
<ReportChart
options={{
hideID: true,
}}
report={{
projectId,
events: [
{
segment: 'event',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'city',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: 'minute',
name: 'Top sources',
range: '30min',
previous: false,
metric: 'sum',
}}
/>
</div>
<div className="card p-4">
<div className="mb-6">
<div className="font-bold">Referrers</div>
</div>
<ReportChart
options={{
hideID: true,
}}
report={{
projectId,
events: [
{
segment: 'event',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: 'minute',
name: 'Top sources',
range: '30min',
previous: false,
metric: 'sum',
}}
/>
</div>
</div>
</Fullscreen>
</>
);
}

View File

@@ -1,21 +0,0 @@
import { escape } from 'sqlstring';
import { TABLE_NAMES, getEvents } from '@openpanel/db';
import LiveEvents from './live-events';
type Props = {
projectId: string;
limit?: number;
};
const RealtimeLiveEventsServer = async ({ projectId, limit = 30 }: Props) => {
const events = await getEvents(
`SELECT * FROM ${TABLE_NAMES.events} WHERE created_at > now() - INTERVAL 2 HOUR AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT ${limit}`,
{
profile: true,
},
);
return <LiveEvents events={events} projectId={projectId} limit={limit} />;
};
export default RealtimeLiveEventsServer;

View File

@@ -1,46 +0,0 @@
'use client';
import { EventListItem } from '@/components/events/event-list-item';
import useWS from '@/hooks/useWS';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
type Props = {
events: (IServiceEventMinimal | IServiceEvent)[];
projectId: string;
limit: number;
};
const RealtimeLiveEvents = ({ events, projectId, limit }: Props) => {
const [state, setState] = useState(events ?? []);
useWS<IServiceEventMinimal | IServiceEvent>(
`/live/events/${projectId}`,
(event) => {
setState((p) => [event, ...p].slice(0, limit));
},
);
return (
<AnimatePresence mode="popLayout" initial={false}>
<div className="flex gap-4">
{state.map((event) => (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, y: -200, x: 0, scale: 0.5 }}
animate={{ opacity: 1, y: 0, x: 0, scale: 1 }}
exit={{ opacity: 0, y: 0, x: 200, scale: 1.2 }}
transition={{ duration: 0.6, type: 'spring' }}
>
<div className="w-[380px]">
<EventListItem {...event} />
</div>
</motion.div>
))}
</div>
</AnimatePresence>
);
};
export default RealtimeLiveEvents;

View File

@@ -1,35 +0,0 @@
'use client';
import useWS from '@/hooks/useWS';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
type Props = {
projectId: string;
};
const RealtimeReloader = ({ projectId }: Props) => {
const client = useQueryClient();
const router = useRouter();
useWS<number>(
`/live/events/${projectId}`,
() => {
if (!document.hidden) {
client.refetchQueries({
type: 'active',
});
}
},
{
debounce: {
maxWait: 60000,
delay: 60000,
},
},
);
return null;
};
export default RealtimeReloader;

View File

@@ -1,29 +0,0 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import EditReportName from '@/components/report/edit-report-name';
import { notFound } from 'next/navigation';
import { getReportById } from '@openpanel/db';
import ReportEditor from '../report-editor';
interface PageProps {
params: {
projectId: string;
reportId: string;
};
}
export default async function Page({ params: { reportId } }: PageProps) {
const report = await getReportById(reportId);
if (!report) {
return notFound();
}
return (
<>
<PageLayout title={<EditReportName name={report.name} />} />
<ReportEditor report={report} />
</>
);
}

View File

@@ -1,13 +0,0 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import EditReportName from '@/components/report/edit-report-name';
import ReportEditor from './report-editor';
export default function Page() {
return (
<>
<PageLayout title={<EditReportName name={undefined} />} />
<ReportEditor report={null} />
</>
);
}

View File

@@ -1,110 +0,0 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
import { ReportChart } from '@/components/report-chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
changeEndDate,
changeStartDate,
ready,
reset,
setName,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { endOfDay, startOfDay } from 'date-fns';
import { GanttChartSquareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
interface ReportEditorProps {
report: IServiceReport | null;
}
export default function ReportEditor({
report: initialReport,
}: ReportEditorProps) {
const { projectId } = useAppParams();
const dispatch = useDispatch();
const report = useSelector((state) => state.report);
// Set report if reportId exists
useEffect(() => {
if (initialReport) {
dispatch(setReport(initialReport));
} else {
dispatch(ready());
}
return () => {
dispatch(reset());
};
}, [initialReport, dispatch]);
useEffect(() => {
return bind(window, {
type: 'report-name-change',
listener: (event) => {
if (event instanceof CustomEvent && typeof event.detail === 'string') {
dispatch(setName(event.detail));
}
},
});
}, [dispatch]);
return (
<Sheet>
<StickyBelowHeader className="grid grid-cols-2 gap-2 p-4 md:grid-cols-6">
<SheetTrigger asChild>
<div>
<Button icon={GanttChartSquareIcon} variant="cta">
Pick events
</Button>
</div>
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType className="min-w-0 flex-1" />
<TimeWindowPicker
className="min-w-0 flex-1"
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) =>
dispatch(changeStartDate(startOfDay(date).toISOString()))
}
onEndDateChange={(date) =>
dispatch(changeEndDate(endOfDay(date).toISOString()))
}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 row-start-1 text-right md:col-start-6">
<ReportSaveButton />
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
)}
</div>
<SheetContent className="!max-w-lg" side="left">
<ReportSidebar />
</SheetContent>
</Sheet>
);
}

View File

@@ -1,103 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type Props = {
data: { users: number; days: number }[];
};
function Tooltip(props: any) {
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div>
<div className="text-sm text-muted-foreground">
Days since last seen
</div>
<div className="text-lg font-semibold">{payload.days}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Active users</div>
<div className="text-lg font-semibold">{payload.users}</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<AreaChart data={data}>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="users"
stroke={getChartColor(0)}
strokeWidth={2}
fill={'url(#bg)'}
isAnimationActive={false}
/>
<XAxis
{...xAxisProps}
dataKey="days"
scale="auto"
type="category"
label={{
value: 'DAYS',
position: 'insideBottom',
offset: 0,
fontSize: 10,
}}
/>
<YAxis
{...yAxisProps}
label={{
value: 'USERS',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
dataKey="users"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -1,25 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRetentionLastSeenSeries } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const LastActiveUsersServer = async ({ projectId }: Props) => {
const res = await getRetentionLastSeenSeries({ projectId });
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Last time in days a user was active</span>
</WidgetHead>
<Chart data={res} />
</Widget>
);
};
export default withLoadingWidget(LastActiveUsersServer);

View File

@@ -1,65 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Padding } from '@/components/ui/padding';
import { AlertCircleIcon } from 'lucide-react';
import LastActiveUsersServer from './last-active-users';
import RollingActiveUsers from './rolling-active-users';
import UsersRetentionSeries from './users-retention-series';
import WeeklyCohortsServer from './weekly-cohorts';
type Props = {
params: {
projectId: string;
};
};
const Retention = ({ params: { projectId } }: Props) => {
return (
<Padding>
<h1 className="mb-4 text-3xl font-semibold">Retention</h1>
<div className="flex max-w-6xl flex-col gap-8">
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Experimental feature</AlertTitle>
<AlertDescription>
<p>
This page is an experimental feature and we&apos;ll be working
hard to make it even better. Stay tuned!
</p>
<p>
Please DM me on{' '}
<a
href="https://go.openpanel.dev/discord"
className="font-medium underline"
>
Discord
</a>{' '}
or{' '}
<a
href="https://x.com/OpenPanelDev"
className="font-medium underline"
>
X/Twitter
</a>{' '}
if you notice any issues.
</p>
</AlertDescription>
</Alert>
<RollingActiveUsers projectId={projectId} />
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Retention info</AlertTitle>
<AlertDescription>
This information is only relevant if you supply a user ID to the
SDK!
</AlertDescription>
</Alert>
<LastActiveUsersServer projectId={projectId} />
{/* <UsersRetentionSeries projectId={projectId} /> */}
<WeeklyCohortsServer projectId={projectId} />
</div>
</Padding>
);
};
export default Retention;

View File

@@ -1,146 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import type { IServiceRetentionRollingActiveUsers } from '@openpanel/db';
type Props = {
data: {
daily: IServiceRetentionRollingActiveUsers[];
weekly: IServiceRetentionRollingActiveUsers[];
monthly: IServiceRetentionRollingActiveUsers[];
};
};
function Tooltip(props: any) {
const payload = props.payload?.[2]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">{payload.date}</div>
<div>
<div className="text-sm text-muted-foreground">
Monthly active users
</div>
<div className="text-lg font-semibold text-chart-2">{payload.mau}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Weekly active users</div>
<div className="text-lg font-semibold text-chart-1">{payload.wau}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Daily active users</div>
<div className="text-lg font-semibold text-chart-0">{payload.dau}</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const rechartData = data.daily.map((d) => ({
date: new Date(d.date).getTime(),
dau: d.users,
wau: data.weekly.find((w) => w.date === d.date)?.users,
mau: data.monthly.find((m) => m.date === d.date)?.users,
}));
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<AreaChart data={rechartData}>
<defs>
<linearGradient id="dau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="wau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(1)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(1)}
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="mau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(2)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(2)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="dau"
stroke={getChartColor(0)}
strokeWidth={2}
fill={'url(#dau)'}
isAnimationActive={false}
/>
<Area
dataKey="wau"
stroke={getChartColor(1)}
strokeWidth={2}
fill={'url(#wau)'}
isAnimationActive={false}
/>
<Area
dataKey="mau"
stroke={getChartColor(2)}
strokeWidth={2}
fill={'url(#mau)'}
isAnimationActive={false}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis
{...yAxisProps}
label={{
value: 'UNIQUE USERS',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -1,35 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRollingActiveUsers } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const RollingActiveUsersServer = async ({ projectId }: Props) => {
const series = await Promise.all([
await getRollingActiveUsers({ projectId, days: 1 }),
await getRollingActiveUsers({ projectId, days: 7 }),
await getRollingActiveUsers({ projectId, days: 30 }),
]);
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Rolling active users</span>
</WidgetHead>
<Chart
data={{
daily: series[0],
weekly: series[1],
monthly: series[2],
}}
/>
</Widget>
);
};
export default withLoadingWidget(RollingActiveUsersServer);

View File

@@ -1,117 +0,0 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { round } from '@openpanel/common';
type Props = {
data: {
date: string;
active_users: number;
retained_users: number;
retention: number;
}[];
};
function Tooltip({ payload }: any) {
const { date, active_users, retained_users, retention } =
payload?.[0]?.payload || {};
const formatDate = useFormatDateInterval('day');
if (!date) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(date))}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Active Users</div>
<div className="text-lg font-semibold">{active_users}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Retained Users</div>
<div className="text-lg font-semibold">{retained_users}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Retention</div>
<div className="text-lg font-semibold">{round(retention, 2)}%</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<AreaChart data={data}>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="retention"
stroke={getChartColor(0)}
strokeWidth={2}
fill={'url(#bg)'}
isAnimationActive={false}
/>
<XAxis
{...xAxisProps}
dataKey="date"
tickFormatter={(m: string) => formatDate(new Date(m))}
allowDuplicatedCategory={false}
label={{
value: 'DATE',
position: 'insideBottom',
offset: 0,
fontSize: 10,
}}
/>
<YAxis
{...yAxisProps}
label={{
value: 'RETENTION (%)',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -1,25 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRetentionSeries } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const UsersRetentionSeries = async ({ projectId }: Props) => {
const res = await getRetentionSeries({ projectId });
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Stickyness / Retention (%)</span>
</WidgetHead>
<Chart data={res} />
</Widget>
);
};
export default withLoadingWidget(UsersRetentionSeries);

View File

@@ -1,132 +0,0 @@
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTableHead } from '@/components/widget-table';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { cn } from '@/utils/cn';
import { getRetentionCohortTable } from '@openpanel/db';
type Props = {
projectId: string;
};
const Cell = ({ value, ratio }: { value: number; ratio: number }) => {
return (
<td
className={cn('relative h-8 border', ratio !== 0 && 'border-background')}
>
<div
className="absolute inset-0 z-0 bg-highlight"
style={{ opacity: ratio }}
/>
<div className="relative z-10">{value}</div>
</td>
);
};
const WeeklyCohortsServer = async ({ projectId }: Props) => {
const res = await getRetentionCohortTable({ projectId });
const minValue = 0;
const maxValue = Math.max(
...res.flatMap((row) => [
row.period_0,
row.period_1,
row.period_2,
row.period_3,
row.period_4,
row.period_5,
row.period_6,
row.period_7,
row.period_8,
row.period_9,
]),
);
const calculateRatio = (currentValue: number) =>
currentValue === 0
? 0
: Math.max(
0.1,
Math.min(1, (currentValue - minValue) / (maxValue - minValue)),
);
return (
<Widget>
<WidgetHead>
<span className="title">Weekly Cohorts</span>
</WidgetHead>
<div className="overflow-hidden rounded-b-xl">
<div className="-m-px">
<table className="w-full table-fixed border-collapse text-center">
<WidgetTableHead className="[&_th]:border-b-2 [&_th]:!text-center">
<tr>
<th>Week</th>
<th>0</th>
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
<th>5</th>
<th>6</th>
<th>7</th>
<th>8</th>
<th>9</th>
</tr>
</WidgetTableHead>
<tbody>
{res.map((row) => (
<tr key={row.first_seen}>
<td className="text-def-1000 bg-def-100 font-medium">
{row.first_seen}
</td>
<Cell
value={row.period_0}
ratio={calculateRatio(row.period_0)}
/>
<Cell
value={row.period_1}
ratio={calculateRatio(row.period_1)}
/>
<Cell
value={row.period_2}
ratio={calculateRatio(row.period_2)}
/>
<Cell
value={row.period_3}
ratio={calculateRatio(row.period_3)}
/>
<Cell
value={row.period_4}
ratio={calculateRatio(row.period_4)}
/>
<Cell
value={row.period_5}
ratio={calculateRatio(row.period_5)}
/>
<Cell
value={row.period_6}
ratio={calculateRatio(row.period_6)}
/>
<Cell
value={row.period_7}
ratio={calculateRatio(row.period_7)}
/>
<Cell
value={row.period_8}
ratio={calculateRatio(row.period_8)}
/>
<Cell
value={row.period_9}
ratio={calculateRatio(row.period_9)}
/>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Widget>
);
};
export default withLoadingWidget(WeeklyCohortsServer);

View File

@@ -1,36 +0,0 @@
import { ActiveIntegrations } from '@/components/integrations/active-integrations';
import { AllIntegrations } from '@/components/integrations/all-integrations';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['installed', 'available'])
.withDefault('available')
.parseServerSide(searchParams.tab);
return (
<Padding className="col gap-8">
<div className="col gap-4">
<h2 className="text-3xl font-semibold">Your integrations</h2>
<ActiveIntegrations />
</div>
<div className="col gap-4">
<h2 className="text-3xl font-semibold">Available integrations</h2>
<AllIntegrations />
</div>
</Padding>
);
}

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,40 +0,0 @@
import { NotificationRules } from '@/components/notifications/notification-rules';
import { Notifications } from '@/components/notifications/notifications';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs/server';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['notifications', 'rules'])
.withDefault('notifications')
.parseServerSide(searchParams.tab);
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink
href="?tab=notifications"
isActive={tab === 'notifications'}
>
Notifications
</PageTabsLink>
<PageTabsLink href="?tab=rules" isActive={tab === 'rules'}>
Rules
</PageTabsLink>
</PageTabs>
{tab === 'notifications' && <Notifications />}
{tab === 'rules' && <NotificationRules />}
</Padding>
);
}

Some files were not shown because too many files have changed in this diff Show More