Compare commits
237 Commits
feature/sd
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd3b89fa3 | ||
|
|
95af86dc44 | ||
|
|
727a218e6b | ||
|
|
958ba535d6 | ||
|
|
3bbeb927cc | ||
|
|
d99335e2f4 | ||
|
|
1fa61b1ae9 | ||
|
|
548747d826 | ||
|
|
7b18544085 | ||
|
|
57697a5a39 | ||
|
|
06fb6c4f3c | ||
|
|
dd71fd4e11 | ||
|
|
00e25ed4b8 | ||
|
|
83e223a496 | ||
|
|
790801b728 | ||
|
|
d61cbf6f2c | ||
|
|
887ed09388 | ||
|
|
8ba714ce81 | ||
|
|
c8e3cf8552 | ||
|
|
18c056f3ea | ||
|
|
1562d49fd6 | ||
|
|
aa8765d627 | ||
|
|
56c74e13ff | ||
|
|
10726bf373 | ||
|
|
dcc0d0df18 | ||
|
|
da59622dce | ||
|
|
38cc53890a | ||
|
|
e4fac81d27 | ||
|
|
7719985ad1 | ||
|
|
c0cefe704b | ||
|
|
5dbb462578 | ||
|
|
2b0b62d64c | ||
|
|
87e98baeb3 | ||
|
|
2abb44831c | ||
|
|
f990cfcc18 | ||
|
|
8fbe944df0 | ||
|
|
c1801adaa2 | ||
|
|
84fd5ce22f | ||
|
|
447b7668fd | ||
|
|
e505c0ea45 | ||
|
|
e613a4e01c | ||
|
|
723ba3ef6c | ||
|
|
9cafd61b25 | ||
|
|
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"
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
@@ -8,6 +10,7 @@ dump-*
|
||||
.sql
|
||||
tmp
|
||||
docker/data*
|
||||
*.mmdb
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -167,6 +170,9 @@ dist
|
||||
|
||||
.vscode-test
|
||||
|
||||
# Wrangler build artifacts and cache
|
||||
.wrangler/
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -17,7 +17,7 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
|
||||
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
|
||||
161
admin/README.md
Normal file
161
admin/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# OpenPanel Admin CLI
|
||||
|
||||
An interactive CLI tool to help manage and lookup OpenPanel organizations, projects, and clients.
|
||||
|
||||
## Setup
|
||||
|
||||
First, install dependencies:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the CLI from the admin directory:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Or use the convenient shell script from anywhere:
|
||||
|
||||
```bash
|
||||
./admin/cli
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
The CLI provides 4 focused lookup commands for easier navigation:
|
||||
|
||||
### 🏢 Lookup by Organization
|
||||
|
||||
Search and view detailed information about an organization.
|
||||
|
||||
- Fuzzy search across all organizations by name or ID
|
||||
- Shows full organization details with all projects, clients, and members
|
||||
|
||||
### 📊 Lookup by Project
|
||||
|
||||
Search for a specific project and view its organization context.
|
||||
|
||||
- Fuzzy search across all projects by name or ID
|
||||
- Highlights the selected project in the organization view
|
||||
- Displays: `org → project`
|
||||
|
||||
### 🔑 Lookup by Client ID
|
||||
|
||||
Search for a specific client and view its full context.
|
||||
|
||||
- Fuzzy search across all clients by name or ID
|
||||
- Highlights the selected client and its project
|
||||
- Displays: `org → project → client`
|
||||
|
||||
### 📧 Lookup by Email
|
||||
|
||||
Search for a member by email address.
|
||||
|
||||
- Fuzzy search across all member emails
|
||||
- Shows which organization(s) the member belongs to
|
||||
- Displays member role (👑 owner, ⭐ admin, 👤 member)
|
||||
|
||||
**All lookups display:**
|
||||
- Organization information (ID, name, subscription status, timezone, event usage)
|
||||
- Organization members and their roles
|
||||
- All projects with their settings (domain, CORS, event counts)
|
||||
- All clients for each project (ID, name, type, credentials)
|
||||
- Deletion warnings if scheduled
|
||||
|
||||
---
|
||||
|
||||
### 🗑️ Clear Cache
|
||||
|
||||
Clear cache for an organization and all its projects.
|
||||
|
||||
- Fuzzy search to find the organization
|
||||
- Shows organization details and all projects
|
||||
- Confirms before clearing cache
|
||||
- Provides organization ID and all project IDs for cache clearing logic
|
||||
|
||||
**Use when:**
|
||||
- You need to invalidate cache after data changes
|
||||
- Troubleshooting caching issues
|
||||
- After manual database updates
|
||||
|
||||
**Note:** The cache clearing logic needs to be implemented. The command provides the organization and project data structure for you to add your cache clearing calls.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Delete Organization
|
||||
|
||||
Permanently delete an organization and all its data.
|
||||
|
||||
- Fuzzy search to find the organization
|
||||
- Shows detailed preview of what will be deleted (projects, members, events)
|
||||
- Requires **3 confirmations**:
|
||||
1. Initial confirmation
|
||||
2. Type organization name to confirm
|
||||
3. Final warning confirmation
|
||||
- Deletes from both PostgreSQL and ClickHouse
|
||||
|
||||
**Use when:**
|
||||
- Removing organizations that are no longer needed
|
||||
- Cleaning up test/demo organizations
|
||||
- Handling deletion requests
|
||||
|
||||
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
|
||||
|
||||
**What gets deleted:**
|
||||
- Organization record
|
||||
- All projects and their settings
|
||||
- All clients and credentials
|
||||
- All events and analytics data (from ClickHouse)
|
||||
- All member associations
|
||||
- All dashboards and reports
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Delete User
|
||||
|
||||
Permanently delete a user account and remove them from all organizations.
|
||||
|
||||
- Fuzzy search by email or name
|
||||
- Shows which organizations the user belongs to
|
||||
- Shows if user created any organizations (won't delete those orgs)
|
||||
- Requires **3 confirmations**:
|
||||
1. Initial confirmation
|
||||
2. Type user email to confirm
|
||||
3. Final warning confirmation
|
||||
|
||||
**Use when:**
|
||||
- Removing user accounts at user request
|
||||
- Cleaning up inactive accounts
|
||||
- Handling GDPR/data deletion requests
|
||||
|
||||
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
|
||||
|
||||
**What gets deleted:**
|
||||
- User account
|
||||
- All auth sessions and tokens
|
||||
- All memberships (removed from all orgs)
|
||||
- All personal data
|
||||
|
||||
**What is NOT deleted:**
|
||||
- Organizations created by the user (only the creator reference is removed)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Make sure you have the proper environment variables set up:
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `DATABASE_URL_REPLICA` (optional) - Read replica connection string
|
||||
|
||||
## Development
|
||||
|
||||
The CLI uses:
|
||||
- **jiti** - Direct TypeScript execution without build step
|
||||
- **inquirer** - Interactive prompts
|
||||
- **inquirer-autocomplete-prompt** - Fuzzy search functionality
|
||||
- **chalk** - Colored terminal output
|
||||
- **@openpanel/db** - Direct Prisma database access
|
||||
|
||||
25
admin/package.json
Normal file
25
admin/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@openpanel/admin",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "dotenv -e .env -c -- jiti src/cli.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"chalk": "^5.3.0",
|
||||
"fuzzy": "^0.1.3",
|
||||
"inquirer": "^9.3.5",
|
||||
"inquirer-autocomplete-prompt": "^3.0.1",
|
||||
"jiti": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/inquirer-autocomplete-prompt": "^3.0.3",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
118
admin/src/cli.ts
Normal file
118
admin/src/cli.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
import inquirer from 'inquirer';
|
||||
import { clearCache } from './commands/clear-cache';
|
||||
import { deleteOrganization } from './commands/delete-organization';
|
||||
import { deleteUser } from './commands/delete-user';
|
||||
import { lookupByClient } from './commands/lookup-client';
|
||||
import { lookupByEmail } from './commands/lookup-email';
|
||||
import { lookupByOrg } from './commands/lookup-org';
|
||||
import { lookupByProject } from './commands/lookup-project';
|
||||
|
||||
const secureEnv = (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.username && parsed.password) {
|
||||
return `${parsed.protocol}//${parsed.username}:${parsed.password.slice(0, 1)}...${parsed.password.slice(-1)}@${parsed.hostname}:${parsed.port}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔧 OpenPanel Admin CLI\n');
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
|
||||
console.error('Environment variables are not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Log environment variables for debugging
|
||||
console.log('Environment:', {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
SELF_HOSTED: process.env.SELF_HOSTED ? 'Yes' : 'No',
|
||||
DATABASE_URL: secureEnv(DATABASE_URL),
|
||||
CLICKHOUSE_URL: secureEnv(CLICKHOUSE_URL),
|
||||
REDIS_URL: secureEnv(REDIS_URL),
|
||||
});
|
||||
console.log('');
|
||||
|
||||
const { command } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'command',
|
||||
message: 'What would you like to do?',
|
||||
pageSize: 20,
|
||||
choices: [
|
||||
{
|
||||
name: '🏢 Lookup by Organization',
|
||||
value: 'lookup-org',
|
||||
},
|
||||
{
|
||||
name: '📊 Lookup by Project',
|
||||
value: 'lookup-project',
|
||||
},
|
||||
{
|
||||
name: '🔑 Lookup by Client ID',
|
||||
value: 'lookup-client',
|
||||
},
|
||||
{
|
||||
name: '📧 Lookup by Email',
|
||||
value: 'lookup-email',
|
||||
},
|
||||
{
|
||||
name: '🗑️ Clear Cache',
|
||||
value: 'clear-cache',
|
||||
},
|
||||
{ name: '─────────────────────', value: 'separator', disabled: true },
|
||||
{
|
||||
name: '🔴 Delete Organization',
|
||||
value: 'delete-org',
|
||||
},
|
||||
{
|
||||
name: '🔴 Delete User',
|
||||
value: 'delete-user',
|
||||
},
|
||||
{ name: '─────────────────────', value: 'separator', disabled: true },
|
||||
{ name: '❌ Exit', value: 'exit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
switch (command) {
|
||||
case 'lookup-org':
|
||||
await lookupByOrg();
|
||||
break;
|
||||
case 'lookup-project':
|
||||
await lookupByProject();
|
||||
break;
|
||||
case 'lookup-client':
|
||||
await lookupByClient();
|
||||
break;
|
||||
case 'lookup-email':
|
||||
await lookupByEmail();
|
||||
break;
|
||||
case 'clear-cache':
|
||||
await clearCache();
|
||||
break;
|
||||
case 'delete-org':
|
||||
await deleteOrganization();
|
||||
break;
|
||||
case 'delete-user':
|
||||
await deleteUser();
|
||||
break;
|
||||
case 'exit':
|
||||
console.log('Goodbye! 👋');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Loop back to main menu
|
||||
await main();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
162
admin/src/commands/clear-cache.ts
Normal file
162
admin/src/commands/clear-cache.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
db,
|
||||
getOrganizationAccess,
|
||||
getOrganizationByProjectIdCached,
|
||||
getProjectAccess,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
console.log(chalk.blue('\n🗑️ Clear Cache\n'));
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch organization with all projects
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('\n📋 Organization Details:\n'));
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
|
||||
|
||||
if (organization.projects.length > 0) {
|
||||
console.log(chalk.yellow('\n📊 Projects:\n'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm before clearing cache
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Clear cache for organization "${organization.name}" and all ${organization.projects.length} projects?`,
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirm) {
|
||||
console.log(chalk.yellow('\nCache clear cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.blue('\n🔄 Clearing cache...\n'));
|
||||
|
||||
for (const project of organization.projects) {
|
||||
// Clear project access cache for each member
|
||||
for (const member of organization.members) {
|
||||
if (!member.user?.id) continue;
|
||||
console.log(
|
||||
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
|
||||
);
|
||||
await getProjectAccess.clear({
|
||||
userId: member.user?.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
await getOrganizationAccess.clear({
|
||||
userId: member.user?.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Clearing cache for project: ${project.name}`);
|
||||
await getOrganizationByProjectIdCached.clear(project.id);
|
||||
await getProjectByIdCached.clear(project.id);
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Organization ID: ${organization.id}`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Example of what you might do:
|
||||
/*
|
||||
for (const project of organization.projects) {
|
||||
console.log(`Clearing cache for project: ${project.name}...`);
|
||||
// await clearProjectCache(project.id);
|
||||
// await redis.del(`project:${project.id}:*`);
|
||||
}
|
||||
|
||||
// Clear organization-level cache
|
||||
// await clearOrganizationCache(organization.id);
|
||||
// await redis.del(`organization:${organization.id}:*`);
|
||||
|
||||
console.log(chalk.green('\n✅ Cache cleared successfully!'));
|
||||
*/
|
||||
}
|
||||
215
admin/src/commands/delete-organization.ts
Normal file
215
admin/src/commands/delete-organization.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
db,
|
||||
deleteFromClickhouse,
|
||||
deleteOrganization as deleteOrg,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function deleteOrganization() {
|
||||
console.log(chalk.red('\n🗑️ Delete Organization\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
|
||||
),
|
||||
);
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
include: {
|
||||
projects: true,
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization to delete:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display what will be deleted
|
||||
console.log(chalk.red('\n⚠️ YOU ARE ABOUT TO DELETE:\n'));
|
||||
console.log(` ${chalk.bold('Organization:')} ${organization.name}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
|
||||
console.log(` ${chalk.gray('Members:')} ${organization.members.length}`);
|
||||
|
||||
if (organization.projects.length > 0) {
|
||||
console.log(chalk.red('\n Projects that will be deleted:'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.members.length > 0) {
|
||||
console.log(chalk.red('\n Members who will lose access:'));
|
||||
for (const member of organization.members) {
|
||||
const email = member.user?.email || member.email || 'Unknown';
|
||||
console.log(` - ${email} ${chalk.gray(`(${member.role})`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!',
|
||||
),
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
const { confirmFirst } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFirst) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation - type organization name
|
||||
const { confirmName } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirmName',
|
||||
message: `Type the organization name "${organization.name}" to confirm deletion:`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmName !== organization.name) {
|
||||
console.log(
|
||||
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const { confirmFinal } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFinal) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.red('\n🗑️ Deleting organization...\n'));
|
||||
|
||||
try {
|
||||
const projectIds = organization.projects.map((p) => p.id);
|
||||
|
||||
// Step 1: Delete from ClickHouse (events, profiles, etc.)
|
||||
if (projectIds.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
|
||||
),
|
||||
);
|
||||
await deleteFromClickhouse(projectIds);
|
||||
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
|
||||
}
|
||||
|
||||
// Step 2: Delete the organization from PostgreSQL (cascade will handle related records)
|
||||
console.log(chalk.yellow('Deleting organization from database...'));
|
||||
await deleteOrg(organization.id);
|
||||
console.log(chalk.green('✓ Organization deleted from database'));
|
||||
|
||||
console.log(chalk.green('\n✅ Organization deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting organization:'), error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
220
admin/src/commands/delete-user.ts
Normal file
220
admin/src/commands/delete-user.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface UserSearchItem {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function deleteUser() {
|
||||
console.log(chalk.red('\n🗑️ Delete User\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
|
||||
),
|
||||
);
|
||||
console.log('Loading users...\n');
|
||||
|
||||
const users = await db.user.findMany({
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
accounts: true,
|
||||
},
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.red('No users found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: UserSearchItem[] = users.map((user) => {
|
||||
const fullName =
|
||||
user.firstName || user.lastName
|
||||
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
|
||||
: '';
|
||||
const orgCount = user.membership.length;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
displayText: `${user.email} ${fullName ? chalk.gray(`(${fullName})`) : ''} ${chalk.cyan(`- ${orgCount} orgs`)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: UserSearchItem) =>
|
||||
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<UserSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedUser } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedUser',
|
||||
message: 'Search for a user to delete:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedUser: UserSearchItem };
|
||||
|
||||
// Fetch full user details
|
||||
const user = await db.user.findUnique({
|
||||
where: {
|
||||
id: selectedUser.id,
|
||||
},
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
accounts: true,
|
||||
createdOrganizations: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log(chalk.red('User not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display what will be deleted
|
||||
console.log(chalk.red('\n⚠️ YOU ARE ABOUT TO DELETE:\n'));
|
||||
console.log(` ${chalk.bold('User:')} ${user.email}`);
|
||||
if (user.firstName || user.lastName) {
|
||||
console.log(
|
||||
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
|
||||
);
|
||||
}
|
||||
console.log(` ${chalk.gray('ID:')} ${user.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
|
||||
);
|
||||
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
|
||||
|
||||
if (user.createdOrganizations.length > 0) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
|
||||
),
|
||||
);
|
||||
for (const org of user.createdOrganizations) {
|
||||
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
|
||||
}
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Note: These organizations will NOT be deleted, only the user reference.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (user.membership.length > 0) {
|
||||
console.log(
|
||||
chalk.red('\n Organizations where user will be removed from:'),
|
||||
);
|
||||
for (const member of user.membership) {
|
||||
console.log(
|
||||
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!',
|
||||
),
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
const { confirmFirst } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFirst) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation - type email
|
||||
const { confirmEmail } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirmEmail',
|
||||
message: `Type the user email "${user.email}" to confirm deletion:`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmEmail !== user.email) {
|
||||
console.log(chalk.red('\n❌ Email does not match. Deletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const { confirmFinal } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFinal) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.red('\n🗑️ Deleting user...\n'));
|
||||
|
||||
try {
|
||||
// Delete the user (cascade will handle related records like sessions, accounts, memberships)
|
||||
await db.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(chalk.green('\n✅ User deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting user:'), error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
104
admin/src/commands/lookup-client.ts
Normal file
104
admin/src/commands/lookup-client.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface ClientSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByClient() {
|
||||
console.log(chalk.blue('\n🔑 Lookup by Client ID\n'));
|
||||
console.log('Loading clients...\n');
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (clients.length === 0) {
|
||||
console.log(chalk.red('No clients found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: ClientSearchItem[] = clients.map((client) => ({
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
organizationId: client.organizationId,
|
||||
organizationName: client.organization.name,
|
||||
projectId: client.projectId,
|
||||
projectName: client.project?.name || null,
|
||||
displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ClientSearchItem) =>
|
||||
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<ClientSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedClient } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedClient',
|
||||
message: 'Search for a client:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedClient: ClientSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedClient.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization, {
|
||||
highlightProjectId: selectedClient.projectId || undefined,
|
||||
highlightClientId: selectedClient.id,
|
||||
});
|
||||
}
|
||||
|
||||
112
admin/src/commands/lookup-email.ts
Normal file
112
admin/src/commands/lookup-email.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface EmailSearchItem {
|
||||
email: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
role: string;
|
||||
userId: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByEmail() {
|
||||
console.log(chalk.blue('\n📧 Lookup by Email\n'));
|
||||
console.log('Loading members...\n');
|
||||
|
||||
const members = await db.member.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (members.length === 0) {
|
||||
console.log(chalk.red('No members found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by email (in case same email is in multiple orgs)
|
||||
const searchItems: EmailSearchItem[] = members.map((member) => {
|
||||
const email = member.user?.email || member.email || 'Unknown';
|
||||
const roleBadge =
|
||||
member.role === 'owner' ? '👑' : member.role === 'admin' ? '⭐' : '👤';
|
||||
|
||||
return {
|
||||
email,
|
||||
organizationId: member.organizationId,
|
||||
organizationName: member.organization.name,
|
||||
role: member.role,
|
||||
userId: member.userId,
|
||||
displayText: `${email} ${chalk.gray(`→ ${member.organization.name}`)} ${roleBadge}`,
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: EmailSearchItem) =>
|
||||
`${item.email} ${item.organizationName}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<EmailSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedMember } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedMember',
|
||||
message: 'Search for a member by email:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedMember: EmailSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedMember.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
|
||||
),
|
||||
);
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
88
admin/src/commands/lookup-org.ts
Normal file
88
admin/src/commands/lookup-org.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByOrg() {
|
||||
console.log(chalk.blue('\n🏢 Lookup by Organization\n'));
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
98
admin/src/commands/lookup-project.ts
Normal file
98
admin/src/commands/lookup-project.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface ProjectSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByProject() {
|
||||
console.log(chalk.blue('\n📊 Lookup by Project\n'));
|
||||
console.log('Loading projects...\n');
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log(chalk.red('No projects found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: ProjectSearchItem[] = projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId,
|
||||
organizationName: project.organization.name,
|
||||
displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ProjectSearchItem) =>
|
||||
`${item.organizationName} ${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<ProjectSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedProject } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedProject',
|
||||
message: 'Search for a project:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedProject: ProjectSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedProject.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization, {
|
||||
highlightProjectId: selectedProject.id,
|
||||
});
|
||||
}
|
||||
|
||||
206
admin/src/utils/display.ts
Normal file
206
admin/src/utils/display.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
Client,
|
||||
Member,
|
||||
Organization,
|
||||
Project,
|
||||
User,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type OrganizationWithDetails = Organization & {
|
||||
projects: (Project & {
|
||||
clients: Client[];
|
||||
})[];
|
||||
members: (Member & {
|
||||
user: User | null;
|
||||
})[];
|
||||
};
|
||||
|
||||
interface DisplayOptions {
|
||||
highlightProjectId?: string;
|
||||
highlightClientId?: string;
|
||||
}
|
||||
|
||||
export function displayOrganizationDetails(
|
||||
organization: OrganizationWithDetails,
|
||||
options: DisplayOptions = {},
|
||||
) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
|
||||
console.log(`${'='.repeat(80)}\n`);
|
||||
|
||||
// Organization Details
|
||||
console.log(chalk.bold('Organization Details:'));
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
|
||||
);
|
||||
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
|
||||
|
||||
// Subscription info
|
||||
if (organization.subscriptionStatus) {
|
||||
console.log(
|
||||
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
|
||||
);
|
||||
if (organization.subscriptionPriceId) {
|
||||
console.log(
|
||||
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionPeriodEventsLimit) {
|
||||
const usage = `${organization.subscriptionPeriodEventsCount}/${organization.subscriptionPeriodEventsLimit}`;
|
||||
const percentage =
|
||||
(organization.subscriptionPeriodEventsCount /
|
||||
organization.subscriptionPeriodEventsLimit) *
|
||||
100;
|
||||
const color =
|
||||
percentage > 90
|
||||
? chalk.red
|
||||
: percentage > 70
|
||||
? chalk.yellow
|
||||
: chalk.green;
|
||||
console.log(
|
||||
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionStartsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionEndsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Members
|
||||
console.log(`\n${chalk.bold('Members:')}`);
|
||||
if (organization.members.length === 0) {
|
||||
console.log(' No members');
|
||||
} else {
|
||||
for (const member of organization.members) {
|
||||
const roleBadge = getRoleBadge(member.role);
|
||||
console.log(
|
||||
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Projects
|
||||
console.log(`\n${chalk.bold(`Projects (${organization.projects.length}):`)}`);
|
||||
|
||||
if (organization.projects.length === 0) {
|
||||
console.log(' No projects');
|
||||
} else {
|
||||
for (const project of organization.projects) {
|
||||
const isHighlighted = project.id === options.highlightProjectId;
|
||||
const projectPrefix = isHighlighted ? chalk.yellow.bold('→ ') : ' ';
|
||||
|
||||
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${project.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
|
||||
);
|
||||
|
||||
if (project.domain) {
|
||||
console.log(` ${chalk.gray('Domain:')} ${project.domain}`);
|
||||
}
|
||||
|
||||
if (project.cors.length > 0) {
|
||||
console.log(` ${chalk.gray('CORS:')} ${project.cors.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
|
||||
);
|
||||
|
||||
if (project.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Clients for this project
|
||||
if (project.clients.length > 0) {
|
||||
console.log(` ${chalk.gray('Clients:')}`);
|
||||
for (const client of project.clients) {
|
||||
const isClientHighlighted = client.id === options.highlightClientId;
|
||||
const clientPrefix = isClientHighlighted
|
||||
? chalk.yellow.bold(' → ')
|
||||
: ' ';
|
||||
const typeBadge = getClientTypeBadge(client.type);
|
||||
|
||||
console.log(`${clientPrefix}${typeBadge} ${chalk.cyan(client.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${client.id}`);
|
||||
console.log(` ${chalk.gray('Type:')} ${client.type}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(` ${chalk.gray('Clients:')} None`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clients without projects (organization-level clients)
|
||||
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
||||
|
||||
console.log(`\n${'='.repeat(80)}\n`);
|
||||
}
|
||||
|
||||
function getSubscriptionStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return chalk.green(status);
|
||||
case 'trialing':
|
||||
return chalk.blue(status);
|
||||
case 'canceled':
|
||||
return chalk.red(status);
|
||||
case 'past_due':
|
||||
return chalk.yellow(status);
|
||||
default:
|
||||
return chalk.gray(status);
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadge(role: string): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return chalk.red.bold('👑');
|
||||
case 'admin':
|
||||
return chalk.yellow.bold('⭐');
|
||||
case 'member':
|
||||
return chalk.blue('👤');
|
||||
default:
|
||||
return chalk.gray('•');
|
||||
}
|
||||
}
|
||||
|
||||
function getClientTypeBadge(type: string): string {
|
||||
switch (type) {
|
||||
case 'root':
|
||||
return chalk.red.bold('[ROOT]');
|
||||
case 'write':
|
||||
return chalk.green('[WRITE]');
|
||||
case 'read':
|
||||
return chalk.blue('[READ]');
|
||||
default:
|
||||
return chalk.gray('[UNKNOWN]');
|
||||
}
|
||||
}
|
||||
12
admin/tsconfig.json
Normal file
12
admin/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tooling/typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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,25 +1,29 @@
|
||||
{
|
||||
"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": {
|
||||
"@fastify/compress": "^8.0.1",
|
||||
"@ai-sdk/anthropic": "^1.2.10",
|
||||
"@ai-sdk/openai": "^1.3.12",
|
||||
"@fastify/compress": "^8.1.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/rate-limit": "^10.2.2",
|
||||
"@fastify/websocket": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@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,16 +32,15 @@
|
||||
"@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": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"ico-to-png": "^0.2.2",
|
||||
"groupmq": "1.1.0-next.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sqlstring": "^2.3.3",
|
||||
@@ -45,7 +48,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",
|
||||
@@ -54,13 +57,12 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/ramda": "^0.30.2",
|
||||
"@types/request-ip": "^0.0.41",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"@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,7 +1,29 @@
|
||||
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';
|
||||
|
||||
// Regex special characters that indicate we need actual regex
|
||||
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
|
||||
|
||||
function transformBots(bots: any[]): any[] {
|
||||
return bots.map((bot) => {
|
||||
const { regex, ...rest } = bot;
|
||||
const hasRegexChars = regexSpecialChars.test(regex);
|
||||
|
||||
if (hasRegexChars) {
|
||||
// Keep as regex
|
||||
return { regex, ...rest };
|
||||
}
|
||||
// Convert to includes
|
||||
return { includes: regex, ...rest };
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get document, or throw exception on error
|
||||
try {
|
||||
@@ -9,6 +31,9 @@ async function main() {
|
||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
).then((res) => res.text());
|
||||
|
||||
const parsedData = yaml.load(data) as any[];
|
||||
const transformedBots = transformBots(parsedData);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/bots/bots.ts'),
|
||||
[
|
||||
@@ -16,11 +41,20 @@ async function main() {
|
||||
'',
|
||||
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
'',
|
||||
`const bots = ${JSON.stringify(yaml.load(data))} as const;`,
|
||||
`const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
|
||||
'export default bots;',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
|
||||
);
|
||||
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
||||
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
||||
console.log(` - ${includesCount} simple string matches (includes)`);
|
||||
console.log(` - ${regexCount} regex patterns`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,8 +40,6 @@ async function main() {
|
||||
properties: {
|
||||
hash: 'test-hash',
|
||||
'query.utm_source': 'test',
|
||||
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
|
||||
__user_agent: 'Mozilla/5.0 (Test)',
|
||||
},
|
||||
created_at: formatClickhouseDate(eventTime),
|
||||
country: 'US',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,47 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
export function isBot(ua: string) {
|
||||
const res = bots.find((bot) => {
|
||||
if (new RegExp(bot.regex).test(ua)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
// Pre-compile regex patterns at module load time
|
||||
const compiledBots = bots.map((bot) => {
|
||||
if ('regex' in bot) {
|
||||
return {
|
||||
...bot,
|
||||
compiledRegex: new RegExp(bot.regex),
|
||||
};
|
||||
}
|
||||
return bot;
|
||||
});
|
||||
|
||||
return {
|
||||
name: res.name,
|
||||
type: 'category' in res ? res.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
for (const bot of includesBots) {
|
||||
if (ua.includes(bot.includes)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check regex patterns (slower)
|
||||
for (const bot of regexBots) {
|
||||
if (bot.compiledRegex.test(ua)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
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,12 @@
|
||||
import { getClientIp, parseIp } from '@/utils/parse-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 { getEventsGroupQueueShard } 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,84 +15,71 @@ export async function postEvent(
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body);
|
||||
const ip = getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const { timestamp, isTimestampFromThePast } = getTimestamp(
|
||||
request.timestamp,
|
||||
request.body,
|
||||
);
|
||||
const ip = request.clientIp;
|
||||
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 currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? request.body?.profileId
|
||||
? `${projectId}:${request.body?.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
const jobId = [
|
||||
request.body.name,
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
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,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
uaInfo,
|
||||
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,
|
||||
jobId,
|
||||
});
|
||||
|
||||
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 { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zChartInputBase } 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,
|
||||
@@ -144,10 +139,9 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zChartInput
|
||||
const chartSchemeFull = zChartInputBase
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
projectId: true,
|
||||
interval: true,
|
||||
range: true,
|
||||
previous: true,
|
||||
@@ -155,14 +149,29 @@ const chartSchemeFull = zChartInput
|
||||
endDate: true,
|
||||
})
|
||||
.extend({
|
||||
events: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
),
|
||||
project_id: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
// Backward compatibility - events will be migrated to series via preprocessing
|
||||
events: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function charts(
|
||||
@@ -181,15 +190,32 @@ export async function charts(
|
||||
});
|
||||
}
|
||||
|
||||
const { events, ...rest } = query.data;
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { events, series, ...rest } = query.data;
|
||||
|
||||
return getChart({
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const eventSeries = (series ?? events ?? []).map((event: any) => ({
|
||||
...event,
|
||||
type: event.type ?? 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
}));
|
||||
|
||||
return ChartEngine.execute({
|
||||
...rest,
|
||||
events: events.map((event) => ({
|
||||
...event,
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
})),
|
||||
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,
|
||||
series: eventSeries,
|
||||
chartType: 'linear',
|
||||
metric: 'sum',
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import superjson from 'superjson';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileByIdCached,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
@@ -92,10 +92,7 @@ export async function wsProjectEvents(
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileByIdCached(
|
||||
event.profileId,
|
||||
event.projectId,
|
||||
);
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
|
||||
@@ -1,51 +1,234 @@
|
||||
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 {
|
||||
DEFAULT_IP_HEADER_ORDER,
|
||||
getClientIpFromHeaders,
|
||||
} from '@openpanel/common/server/get-client-ip';
|
||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
import { cacheable, getCache, getRedisCache } from '@openpanel/redis';
|
||||
import { type GeoLocation, 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.debug('Serving ICO file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (originalUrl && isSvgFile(originalUrl, contentType)) {
|
||||
logger.debug('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.debug('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.debug('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 +236,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 +395,110 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||
eventsLast24hCount: res.last24hCount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
const others = await Promise.all(
|
||||
DEFAULT_IP_HEADER_ORDER.map(async (header) => {
|
||||
const { ip } = getClientIpFromHeaders(request.headers, header);
|
||||
return {
|
||||
header,
|
||||
ip,
|
||||
geo: await getGeoLocation(ip),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Bad Request');
|
||||
}
|
||||
const geo = await getGeoLocation(ip);
|
||||
return reply.status(200).send({
|
||||
selected: {
|
||||
geo,
|
||||
ip,
|
||||
header,
|
||||
},
|
||||
...others.reduce(
|
||||
(acc, other) => {
|
||||
acc[other.header] = other;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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,9 @@
|
||||
import { getClientIp, parseIp } from '@/utils/parse-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,
|
||||
@@ -16,41 +15,39 @@ export async function updateProfile(
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { profileId, properties, ...rest } = request.body;
|
||||
const payload = request.body;
|
||||
const projectId = request.client!.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
const ip = getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const uaInfo = parseUserAgent(ua, properties);
|
||||
const geo = await parseIp(ip);
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
await upsertProfile({
|
||||
id: profileId,
|
||||
...payload,
|
||||
id: payload.profileId,
|
||||
isExternal: true,
|
||||
projectId,
|
||||
properties: {
|
||||
...(properties ?? {}),
|
||||
...(ip ? geo : {}),
|
||||
...uaInfo,
|
||||
...(payload.properties ?? {}),
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
|
||||
reply.status(202).send(profileId);
|
||||
reply.status(202).send(payload.profileId);
|
||||
}
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
@@ -65,18 +62,6 @@ export async function incrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
@@ -119,18 +104,6 @@ export async function decrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { GeoLocation } from '@/utils/parse-ip';
|
||||
import { getClientIp, parseIp } from '@/utils/parse-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, assocPath, pathOr, pick } from 'ramda';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent, isDuplicatedEvent } 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 { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
TrackPayload,
|
||||
} from '@openpanel/sdk';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
@@ -37,10 +37,10 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
}
|
||||
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity = path<IdentifyPayload>(
|
||||
['properties', '__identify'],
|
||||
body.payload,
|
||||
);
|
||||
const identity =
|
||||
'properties' in body.payload
|
||||
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
@@ -56,28 +56,38 @@ export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
) {
|
||||
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
|
||||
const userDefinedTimestamp = path<string>(
|
||||
['properties', '__timestamp'],
|
||||
payload,
|
||||
);
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
'properties' in payload
|
||||
? (payload?.properties?.__timestamp as string | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!userDefinedTimestamp) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
const clientTimestamp = new Date(userDefinedTimestamp);
|
||||
const clientTimestampNumber = clientTimestamp.getTime();
|
||||
|
||||
// Constants for time validation
|
||||
const ONE_MINUTE_MS = 60 * 1000;
|
||||
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
|
||||
|
||||
// Use safeTimestamp if invalid or more than 1 minute in the future
|
||||
if (
|
||||
Number.isNaN(clientTimestamp.getTime()) ||
|
||||
clientTimestamp > new Date(safeTimestamp)
|
||||
Number.isNaN(clientTimestampNumber) ||
|
||||
clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
|
||||
) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 1 hour
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
return {
|
||||
timestamp: clientTimestamp.toISOString(),
|
||||
isTimestampFromThePast: true,
|
||||
timestamp: clientTimestampNumber,
|
||||
isTimestampFromThePast,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,22 +99,33 @@ export async function handler(
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
path<string>(['properties', '__ip'], request.body.payload) ||
|
||||
getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
'properties' in request.body.payload &&
|
||||
request.body.payload.properties?.__ip
|
||||
? (request.body.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
if (!projectId) {
|
||||
reply.status(400).send({
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const profileId = identity?.profileId;
|
||||
const overrideDeviceId = (() => {
|
||||
const deviceId =
|
||||
'properties' in request.body.payload
|
||||
? request.body.payload.properties?.__deviceId
|
||||
: undefined;
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
@@ -114,15 +135,17 @@ export async function handler(
|
||||
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), parseIp(ip)]);
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
@@ -132,33 +155,7 @@ export async function handler(
|
||||
})
|
||||
: '';
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
];
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
@@ -173,24 +170,24 @@ export async function handler(
|
||||
);
|
||||
}
|
||||
|
||||
promises.push(
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geo = await parseIp(ip);
|
||||
const geo = await getGeoLocation(ip);
|
||||
await identify({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
@@ -200,27 +197,13 @@ export async function handler(
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
reply.status(400).send({
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'increment': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
@@ -228,19 +211,6 @@ export async function handler(
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
@@ -248,23 +218,17 @@ export async function handler(
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reply.status(400).send({
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
}
|
||||
|
||||
type TrackPayload = {
|
||||
name: string;
|
||||
properties?: Record<string, any>;
|
||||
};
|
||||
|
||||
async function track({
|
||||
payload,
|
||||
currentDeviceId,
|
||||
@@ -281,48 +245,36 @@ async function track({
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: string;
|
||||
timestamp: number;
|
||||
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;
|
||||
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
uaInfo,
|
||||
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,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
async function identify({
|
||||
@@ -344,8 +296,18 @@ async function identify({
|
||||
projectId,
|
||||
properties: {
|
||||
...(payload.properties ?? {}),
|
||||
...(geo ?? {}),
|
||||
...uaInfo,
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -421,3 +383,65 @@ async function decrement({
|
||||
isExternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
const ip = request.clientIp;
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Missing ip address');
|
||||
}
|
||||
|
||||
const ua = request.headers['user-agent'];
|
||||
if (!ua) {
|
||||
return reply.status(400).send('Missing header: user-agent');
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
28
apps/api/src/hooks/duplicate.hook.ts
Normal file
28
apps/api/src/hooks/duplicate.hook.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ip = req.clientIp;
|
||||
const origin = req.headers.origin;
|
||||
const clientId = req.headers['openpanel-client-id'];
|
||||
const shouldCheck = ip && origin && clientId;
|
||||
|
||||
const isDuplicate = shouldCheck
|
||||
? await isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload: req.body,
|
||||
projectId: clientId as string,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (isDuplicate) {
|
||||
return reply.status(200).send('Duplicate event');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function fixHook(request: FastifyRequest) {
|
||||
const ua = request.headers['user-agent'];
|
||||
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
|
||||
if (ua) {
|
||||
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
|
||||
const match = ua.match(regex);
|
||||
if (match) {
|
||||
request.headers['user-agent'] = ua.replace(
|
||||
regex,
|
||||
`OpenPanel/${match[1]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getClientIp } from '@/utils/parse-ip';
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
HookHandlerDoneFunction,
|
||||
} from 'fastify';
|
||||
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function ipHook(request: FastifyRequest) {
|
||||
const ip = getClientIp(request);
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
|
||||
if (ip) {
|
||||
request.clientIp = ip;
|
||||
request.clientIpHeader = header;
|
||||
} else {
|
||||
request.clientIp = '';
|
||||
request.clientIpHeader = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
||||
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) {
|
||||
@@ -37,12 +38,15 @@ export async function requestLoggingHook(
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
elapsed: reply.elapsedTime,
|
||||
clientIp: request.clientIp,
|
||||
clientIpHeader: request.clientIpHeader,
|
||||
headers: pick(
|
||||
[
|
||||
'openpanel-client-id',
|
||||
'openpanel-sdk-name',
|
||||
'openpanel-sdk-version',
|
||||
'user-agent',
|
||||
...DEFAULT_IP_HEADER_ORDER,
|
||||
],
|
||||
request.headers,
|
||||
),
|
||||
|
||||
@@ -8,43 +8,54 @@ 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;
|
||||
clientIp?: string;
|
||||
clientIp: string;
|
||||
clientIpHeader: string;
|
||||
timestamp?: number;
|
||||
session: SessionValidationResult;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -98,7 +125,6 @@ const startServer = async () => {
|
||||
fastify.addHook('onRequest', requestIdHook);
|
||||
fastify.addHook('onRequest', timestampHook);
|
||||
fastify.addHook('onRequest', ipHook);
|
||||
fastify.addHook('onRequest', fixHook);
|
||||
fastify.addHook('onResponse', requestLoggingHook);
|
||||
|
||||
fastify.register(compress, {
|
||||
@@ -117,10 +143,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 +162,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 +182,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 +192,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 +239,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 +272,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;
|
||||
@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { handler } from '@/controllers/track.controller';
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
@@ -29,6 +31,23 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/device-id',
|
||||
handler: fetchDeviceId,
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default trackRouter;
|
||||
|
||||
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 { ChartEngine } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
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":[]}
|
||||
\`\`\`
|
||||
`;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||
import { verifyPassword } from '@openpanel/common/server';
|
||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
IProjectFilterIp,
|
||||
@@ -104,6 +105,26 @@ export async function validateSdkRequest(
|
||||
throw createError('Ingestion: Profile id is blocked by project filter');
|
||||
}
|
||||
|
||||
const revenue =
|
||||
path(['payload', 'properties', '__revenue'], req.body) ??
|
||||
path(['properties', '__revenue'], req.body);
|
||||
|
||||
// Only allow revenue tracking if it was sent with a client secret
|
||||
// or if the project has allowUnsafeRevenueTracking enabled
|
||||
if (
|
||||
!client.project.allowUnsafeRevenueTracking &&
|
||||
!clientSecret &&
|
||||
typeof revenue !== 'undefined'
|
||||
) {
|
||||
throw createError(
|
||||
'Ingestion: Revenue tracking is not allowed without a client secret',
|
||||
);
|
||||
}
|
||||
|
||||
if (client.ignoreCorsAndSecret) {
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client.project.cors) {
|
||||
const domainAllowed = client.project.cors.find((domain) => {
|
||||
const cleanedDomain = cleanDomain(domain);
|
||||
@@ -131,7 +152,13 @@ export async function validateSdkRequest(
|
||||
}
|
||||
|
||||
if (client.secret && clientSecret) {
|
||||
if (await verifyPassword(clientSecret, client.secret)) {
|
||||
const isVerified = await getCache(
|
||||
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||
60 * 5,
|
||||
async () => await verifyPassword(clientSecret, client.secret!),
|
||||
true,
|
||||
);
|
||||
if (isVerified) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getLock } from '@openpanel/redis';
|
||||
import fastJsonStableHash from 'fast-json-stable-hash';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
export async function isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
ip: string;
|
||||
origin: string;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
|
||||
`fastify:deduplicate:${fastJsonStableHash.hash(
|
||||
{
|
||||
...payload,
|
||||
ip,
|
||||
origin,
|
||||
projectId,
|
||||
},
|
||||
'md5',
|
||||
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkDuplicatedEvent({
|
||||
reply,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
reply: FastifyReply;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
if (await isDuplicatedEvent({ payload, projectId })) {
|
||||
reply.log.info('duplicated event', {
|
||||
payload,
|
||||
projectId,
|
||||
});
|
||||
reply.status(200).send('duplicated');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
eventsGroupQueues,
|
||||
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([
|
||||
...eventsGroupQueues.map((queue) => queue.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,
|
||||
}));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user