Compare commits
195 Commits
feature/sd
...
feature/if
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931ae55a1c | ||
|
|
bbd30ca6e0 | ||
|
|
7e38176422 | ||
|
|
720b56aba6 | ||
|
|
49d2c7512a | ||
|
|
c13ea0d044 | ||
|
|
437111cfec | ||
|
|
0ddd1f6d06 | ||
|
|
ea029648a5 | ||
|
|
e511ae5b9b | ||
|
|
c762bd7c95 | ||
|
|
5b1c582023 | ||
|
|
a9b9ffa029 | ||
|
|
f67ec2bb6a | ||
|
|
0663ce6ac8 | ||
|
|
b8cb13a854 | ||
|
|
93e1a2d037 | ||
|
|
70682c218f | ||
|
|
212254d31a | ||
|
|
b51bc8f3f6 | ||
|
|
3474fbd12d | ||
|
|
74754cf65b | ||
|
|
7287a05697 | ||
|
|
c67f7f9578 | ||
|
|
4d7f3e4473 | ||
|
|
76b68ba5d7 | ||
|
|
cf6b9f4ab8 | ||
|
|
322f2c36fd | ||
|
|
f454449365 | ||
|
|
abacf66155 | ||
|
|
ebdf29e196 | ||
|
|
931188a8ab | ||
|
|
ddc99e9850 | ||
|
|
a399209947 | ||
|
|
98b3f50917 | ||
|
|
e33de4d00e | ||
|
|
540de2cd53 | ||
|
|
b8ad8dfede | ||
|
|
07762211d2 | ||
|
|
bcfb4f25fb | ||
|
|
411021ee04 | ||
|
|
1206f94cf3 | ||
|
|
e3d3627c16 | ||
|
|
49a4f5b8ae | ||
|
|
42d0fb8572 | ||
|
|
9790ba8937 | ||
|
|
f958230a66 | ||
|
|
90c1a813af | ||
|
|
87d4ec2f33 | ||
|
|
d8a297edf2 | ||
|
|
ea483d41a0 | ||
|
|
84a3552daf | ||
|
|
95de29dfed | ||
|
|
967a155d5e | ||
|
|
93a3c9b0a9 | ||
|
|
2928857389 | ||
|
|
21fc076368 | ||
|
|
4b831204b7 | ||
|
|
17f30fc9a3 | ||
|
|
42361a0caa | ||
|
|
077a47a263 | ||
|
|
c8bea685db | ||
|
|
4ccabc5fa3 | ||
|
|
1187bcac0a | ||
|
|
56ad8854eb | ||
|
|
7a63885d38 | ||
|
|
4d060cb7d2 | ||
|
|
afd3f24f70 | ||
|
|
44e51938cc | ||
|
|
b59c054ac0 | ||
|
|
81a7e5d62e | ||
|
|
436e81ecc9 | ||
|
|
7d4a4c1944 | ||
|
|
e7c21bc92c | ||
|
|
a11f87dc3c | ||
|
|
5b2f09f29c | ||
|
|
174a30d515 | ||
|
|
6bc4f6fbd2 | ||
|
|
e4bc21fc2e | ||
|
|
af580333b4 | ||
|
|
b3e06e985d | ||
|
|
d06589831d | ||
|
|
280065c2c4 | ||
|
|
0b4fcbad69 | ||
|
|
ca4a880acd | ||
|
|
5092b6ae51 | ||
|
|
c10b89dcf7 | ||
|
|
a145bd6cfc | ||
|
|
df32bb04a0 | ||
|
|
d4a1eb88b8 | ||
|
|
52b86682e2 | ||
|
|
e5cacb73df | ||
|
|
31ccfb8b5f | ||
|
|
26efb7a94d | ||
|
|
113ab0a28d | ||
|
|
a6993abb6f | ||
|
|
7bda9adf86 | ||
|
|
f96c9b4769 | ||
|
|
77cd74816c | ||
|
|
28f36da68e | ||
|
|
92d62c3e5c | ||
|
|
4a2dbc5c4d | ||
|
|
bbe0192a19 | ||
|
|
5c6d71f176 | ||
|
|
9b16bbaccd | ||
|
|
02be728499 | ||
|
|
ad8dfb511d | ||
|
|
e3e9e60b25 | ||
|
|
82239a7d9a | ||
|
|
5e023d0227 | ||
|
|
3560276095 | ||
|
|
1cfd7e7e1b | ||
|
|
5445d6309e | ||
|
|
09c83ddeb4 | ||
|
|
34414e1d3e | ||
|
|
f59bcfba3c | ||
|
|
cd5dce02b8 | ||
|
|
5b1e94e9ad | ||
|
|
5c5154ee86 | ||
|
|
92210c1b3f | ||
|
|
e08718724e | ||
|
|
0d58a5bf0c | ||
|
|
39775142e2 | ||
|
|
95a30a660c | ||
|
|
680727355b | ||
|
|
46bfeee131 | ||
|
|
ce184b157f | ||
|
|
af7146f555 | ||
|
|
0eed1e168f | ||
|
|
023a2852c7 | ||
|
|
e0c356701f | ||
|
|
ab2f711880 | ||
|
|
2cd358e1bb | ||
|
|
dd39ff70a9 | ||
|
|
584a6d21f1 | ||
|
|
60ed005fe1 | ||
|
|
4adebf40ac | ||
|
|
eaab2aad22 | ||
|
|
d4c2f9ca9f | ||
|
|
2d8f6f36f6 | ||
|
|
0189b922f2 | ||
|
|
d0e90dfa79 | ||
|
|
89ab8d08de | ||
|
|
e2254e78a9 | ||
|
|
bfa1ee70e6 | ||
|
|
be3c18b677 | ||
|
|
34769a5d58 | ||
|
|
804a9c8056 | ||
|
|
c3199e12e3 | ||
|
|
62dbe7e7c6 | ||
|
|
c5d25779c6 | ||
|
|
a6762b90ca | ||
|
|
1e99c1843a | ||
|
|
d38ccb4717 | ||
|
|
58c4a6a741 | ||
|
|
e58e898683 | ||
|
|
6ae85a1fe8 | ||
|
|
fe87b65237 | ||
|
|
ec5207947b | ||
|
|
a9c664dcfb | ||
|
|
0f0bb13107 | ||
|
|
ecda9a7d1b | ||
|
|
e9133aa5a8 | ||
|
|
d4fcc82fc3 | ||
|
|
56c7283ec6 | ||
|
|
8a21fadc0d | ||
|
|
be358ea886 | ||
|
|
e6a65d694a | ||
|
|
d4c1c15174 | ||
|
|
ee80b47b0d | ||
|
|
c540778825 | ||
|
|
1ce8ab8fc8 | ||
|
|
f06f1b7b8f | ||
|
|
b77ee71445 | ||
|
|
a26e64e80d | ||
|
|
490d12b24d | ||
|
|
7ab869ff45 | ||
|
|
76239314dd | ||
|
|
f313356096 | ||
|
|
aeb9abcb13 | ||
|
|
c03ee3f617 | ||
|
|
584c787799 | ||
|
|
800a484ad4 | ||
|
|
1257381bf2 | ||
|
|
bb018d55ca | ||
|
|
d3ef034a5d | ||
|
|
38ff55f203 | ||
|
|
b59216fb7d | ||
|
|
8d50213ed9 | ||
|
|
cf01d7a545 | ||
|
|
7c1d36a9f3 | ||
|
|
563551992d | ||
|
|
a1eb4a296f | ||
|
|
b035c0d586 | ||
|
|
1784a48bfc |
3
.cursorrules
Normal file
3
.cursorrules
Normal 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`
|
||||
191
.github/workflows/docker-build.yml
vendored
191
.github/workflows/docker-build.yml
vendored
@@ -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
5
.gitignore
vendored
@@ -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
|
||||
|
||||
51
README.md
51
README.md
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
134
apps/api/src/controllers/ai.controller.ts
Normal file
134
apps/api/src/controllers/ai.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
178
apps/api/src/controllers/insights.controller.ts
Normal file
178
apps/api/src/controllers/insights.controller.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getClientIp } from '@/utils/parse-ip';
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
28
apps/api/src/routes/ai.router.ts
Normal file
28
apps/api/src/routes/ai.router.ts
Normal 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;
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
89
apps/api/src/routes/insights.router.ts
Normal file
89
apps/api/src/routes/insights.router.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
475
apps/api/src/utils/ai-tools.ts
Normal file
475
apps/api/src/utils/ai-tools.ts
Normal 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
115
apps/api/src/utils/ai.ts
Normal 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":[]}
|
||||
\`\`\`
|
||||
`;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/api/src/utils/get-client-ip.ts
Normal file
8
apps/api/src/utils/get-client-ip.ts
Normal 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);
|
||||
}
|
||||
108
apps/api/src/utils/graceful-shutdown.ts
Normal file
108
apps/api/src/utils/graceful-shutdown.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
23
apps/api/tsdown.config.ts
Normal 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);
|
||||
@@ -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);
|
||||
39
apps/dashboard/.gitignore
vendored
39
apps/dashboard/.gitignore
vendored
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
[auth]
|
||||
token=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
|
||||
@@ -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"]
|
||||
@@ -1 +0,0 @@
|
||||
# Dashboard
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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
Reference in New Issue
Block a user