1 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
05f310bdbb wip 2025-03-13 20:20:15 +01:00
1113 changed files with 61656 additions and 121978 deletions

View File

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

View File

@@ -1,20 +1,19 @@
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:
@@ -23,13 +22,12 @@ 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/**'
@@ -43,36 +41,22 @@ 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' || needs.changes.outputs.dashboard == 'true' }}
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == '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: |
@@ -85,43 +69,32 @@ 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
@@ -141,48 +114,21 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/api:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
ghcr.io/${{ env.repo_owner }}/api:latest
ghcr.io/${{ env.repo_owner }}/api:${{ github.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
@@ -202,84 +148,7 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.repo_owner }}/worker:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }}
ghcr.io/${{ env.repo_owner }}/worker:latest
ghcr.io/${{ env.repo_owner }}/worker:${{ github.sha }}
build-args: |
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"
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy

6
.gitignore vendored
View File

@@ -1,6 +1,4 @@
.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
@@ -10,7 +8,6 @@ dump-*
.sql
tmp
docker/data*
*.mmdb
# Logs
@@ -170,9 +167,6 @@ dist
.vscode-test
# Wrangler build artifacts and cache
.wrangler/
# yarn v2
.yarn/cache

View File

@@ -17,7 +17,7 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
"editor.defaultFormatter": "biomejs.biome"
},
"editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [

View File

@@ -23,42 +23,11 @@
<br />
</p>
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.
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.
## ✨ Features
## Disclaimer
- **🔍 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
> 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.
## Stack
@@ -68,7 +37,6 @@ Openpanel is an open-source web and product analytics platform that combines the
- **Clickhouse** - storing events
- **Redis** - cache layer, pub/sub and queue
- **BullMQ** - queue
- **GroupMQ** - for grouped queue
- **Resend** - email
- **Arctic** - oauth
- **Oslo** - auth
@@ -95,6 +63,15 @@ 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
@@ -106,8 +83,8 @@ pnpm dev
You can now access the following:
- Dashboard: https://localhost:3000
- API: https://api.localhost:3333
- Dashboard: https://op.local
- API: https://api.op.local
- Bullboard (queue): http://localhost:9999
- `pnpm dock:ch` to access clickhouse terminal
- `pnpm dock:redis` to access redis terminal

View File

@@ -1,161 +0,0 @@
# 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

View File

@@ -1,25 +0,0 @@
{
"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:"
}
}

View File

@@ -1,118 +0,0 @@
#!/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);
});

View File

@@ -1,162 +0,0 @@
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!'));
*/
}

View File

@@ -1,215 +0,0 @@
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;
}
}

View File

@@ -1,220 +0,0 @@
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;
}
}

View File

@@ -1,104 +0,0 @@
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,
});
}

View File

@@ -1,112 +0,0 @@
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);
}

View File

@@ -1,88 +0,0 @@
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);
}

View File

@@ -1,98 +0,0 @@
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,
});
}

View File

@@ -1,206 +0,0 @@
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]');
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "../tooling/typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -1,4 +1,4 @@
ARG NODE_VERSION=22.20.0
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim AS base
@@ -28,7 +28,6 @@ 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/
@@ -43,7 +42,6 @@ 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
@@ -61,14 +59,12 @@ COPY apps/api ./apps/api
COPY packages ./packages
COPY tooling ./tooling
RUN pnpm codegen && \
RUN pnpm db: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 \
@@ -78,14 +74,12 @@ 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
@@ -97,7 +91,6 @@ 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
@@ -111,7 +104,6 @@ COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen
WORKDIR /app/apps/api

View File

@@ -1,29 +1,25 @@
{
"name": "@openpanel/api",
"version": "0.0.4",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsdown",
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"testing": "API_PORT=3333 pnpm dev",
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"start": "node dist/index.js",
"build": "rm -rf dist && tsup",
"gen:referrers": "jiti scripts/get-referrers.ts && biome format --write src/referrers/index.ts",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/openai": "^1.3.12",
"@fastify/compress": "^8.1.0",
"@fastify/compress": "^8.0.1",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.2.0",
"@fastify/cors": "^11.0.0",
"@fastify/rate-limit": "^10.2.2",
"@fastify/websocket": "^11.0.2",
"@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:*",
@@ -32,15 +28,16 @@
"@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*",
"@trpc/server": "^11.6.0",
"ai": "^4.2.10",
"@trpc/server": "^10.45.2",
"bcrypt": "^5.1.1",
"fast-json-stable-hash": "^1.0.3",
"fastify": "^5.6.1",
"fastify": "^5.2.1",
"fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0",
"groupmq": "1.1.0-next.6",
"ico-to-png": "^0.2.2",
"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",
@@ -48,7 +45,7 @@
"svix": "^1.24.0",
"url-metadata": "^4.1.1",
"uuid": "^9.0.1",
"zod": "catalog:"
"zod": "^3.22.4"
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
@@ -57,12 +54,13 @@
"@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",
"tsdown": "0.14.2",
"typescript": "catalog:"
"tsup": "^7.2.0",
"typescript": "^5.2.2"
}
}

View File

@@ -1,29 +1,7 @@
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 {
@@ -31,9 +9,6 @@ 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'),
[
@@ -41,20 +16,11 @@ 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(transformedBots, null, 2)} as const;`,
`const bots = ${JSON.stringify(yaml.load(data))} 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);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ 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;
@@ -18,7 +17,11 @@ interface Track {
type: 'track';
payload: {
name: string;
properties: Record<string, string>;
properties: {
__referrer: string;
__path: string;
__title: string;
};
};
}
@@ -261,228 +264,25 @@ function insertFakeEvents(events: Event[]) {
}
async function simultaneousRequests() {
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' },
],
},
];
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;
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: {},
await Promise.all([
trackit(event),
trackit({
...event,
track: {
...event.track,
payload: {
...event.track.payload,
name: 'text',
},
},
},
};
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));
@@ -493,11 +293,9 @@ async function main() {
const [type, file = 'mock-basic.json'] = process.argv.slice(2);
switch (type) {
case 'send': {
const data = await import(`./${file}`, { assert: { type: 'json' } });
await triggerEvents(data.default);
case 'send':
await triggerEvents(require(`./${file}`));
break;
}
case 'sim':
await simultaneousRequests();
break;

View File

@@ -40,6 +40,8 @@ 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

View File

@@ -1,47 +1,19 @@
import { cacheable, cacheableLru } from '@openpanel/redis';
import bots from './bots';
// 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;
});
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',
};
}
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;
},
{
maxSize: 1000,
ttl: 60 * 5,
},
);
}
return {
name: res.name,
type: 'category' in res ? res.category : 'Unknown',
};
}

View File

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

View File

@@ -1,12 +1,13 @@
import { getClientIp, parseIp } from '@/utils/parse-ip';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { generateDeviceId } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { eventsQueue } from '@openpanel/queue';
import { getLock } from '@openpanel/redis';
import type { PostEventPayload } from '@openpanel/sdk';
import { generateId } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { getStringHeaders, getTimestamp } from './track.controller';
export async function postEvent(
@@ -15,71 +16,84 @@ export async function postEvent(
}>,
reply: FastifyReply,
) {
const { timestamp, isTimestampFromThePast } = getTimestamp(
request.timestamp,
request.body,
);
const ip = request.clientIp;
const ua = request.headers['user-agent'];
const timestamp = getTimestamp(request.timestamp, request.body);
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers);
if (!projectId) {
reply.status(400).send('missing origin');
return;
}
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
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 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: {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
isTimestampFromThePast,
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,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
{
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,
},
);
reply.status(202).send('ok');
}

View File

@@ -2,18 +2,19 @@ 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 { ChartEngine } from '@openpanel/db';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import {
zChartEvent,
zChartEventFilter,
zChartInput,
} from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -32,9 +33,11 @@ async function getProjectId(
request.client?.type === ClientType.read &&
request.client?.projectId !== projectId
) {
throw new HttpError('You do not have access to this project', {
status: 403,
reply.status(403).send({
error: 'Forbidden',
message: 'You do not have access to this project',
});
return '';
}
const project = await db.project.findUnique({
@@ -45,9 +48,11 @@ async function getProjectId(
});
if (!project) {
throw new HttpError('Project not found', {
status: 404,
reply.status(404).send({
error: 'Not Found',
message: 'Project not found',
});
return '';
}
}
@@ -56,9 +61,11 @@ async function getProjectId(
}
if (!projectId) {
throw new HttpError('project_id or projectId is required', {
status: 400,
reply.status(400).send({
error: 'Bad Request',
message: 'project_id is required',
});
return '';
}
return projectId;
@@ -67,7 +74,6 @@ 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(),
@@ -100,7 +106,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, 1000), 1);
const take = Math.max(Math.min(limit, 50), 1);
const cursor = page - 1;
const options: GetEventListOptions = {
projectId,
@@ -112,7 +118,6 @@ 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,
@@ -139,9 +144,10 @@ export async function events(
});
}
const chartSchemeFull = zChartInputBase
const chartSchemeFull = zChartInput
.pick({
breakdowns: true,
projectId: true,
interval: true,
range: true,
previous: true,
@@ -149,29 +155,14 @@ const chartSchemeFull = zChartInputBase
endDate: true,
})
.extend({
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(),
events: z.array(
z.object({
name: z.string(),
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
),
});
export async function charts(
@@ -190,32 +181,15 @@ export async function charts(
});
}
const projectId = await getProjectId(request, reply);
const { timezone } = await getSettingsForProject(projectId);
const { events, series, ...rest } = query.data;
const { events, ...rest } = query.data;
// 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({
return getChart({
...rest,
startDate: rest.startDate
? DateTime.fromISO(rest.startDate)
.setZone(timezone)
.toFormat('yyyy-MM-dd HH:mm:ss')
: undefined,
endDate: rest.endDate
? DateTime.fromISO(rest.endDate)
.setZone(timezone)
.toFormat('yyyy-MM-dd HH:mm:ss')
: undefined,
projectId,
series: eventSeries,
events: events.map((event) => ({
...event,
segment: event.segment ?? 'event',
filters: event.filters ?? [],
})),
chartType: 'linear',
metric: 'sum',
});

View File

@@ -1,60 +1,83 @@
import { isShuttingDown } from '@/utils/graceful-shutdown';
import { chQuery, db } from '@openpanel/db';
import { round } from '@openpanel/common';
import { TABLE_NAMES, chQuery, db } from '@openpanel/db';
import { eventsQueue } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify';
// For docker compose healthcheck
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;
}
}
export async function healthcheck(
request: FastifyRequest,
reply: FastifyReply,
) {
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({
ready: status === 200,
redis: redisRes === 'PONG',
db: !!dbRes,
ch: chRes && chRes.length > 0,
if (process.env.DISABLE_HEALTHCHECK) {
return reply.status(200).send({
ok: true,
});
} catch (error) {
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
}
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;
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,
});
} else {
reply.status(200).send({
ok: true,
count,
});
}
}
// Kubernetes - Liveness probe - returns 200 if process is alive
export async function liveness(request: FastifyRequest, reply: FastifyReply) {
return reply.status(200).send({ live: true });
}
// Kubernetes - Readiness probe - returns 200 only when accepting requests, 503 during shutdown
export async function readiness(request: FastifyRequest, reply: FastifyReply) {
if (isShuttingDown()) {
return reply.status(503).send({ ready: false, reason: 'shutting down' });
}
// Perform lightweight dependency checks for readiness
const redisRes = await getRedisCache().ping();
const dbRes = await db.project.findFirst();
const chRes = await chQuery('SELECT 1');
const isReady = redisRes && dbRes && chRes;
if (!isReady) {
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
redis: redisRes === 'PONG',
db: !!dbRes,
ch: chRes && chRes.length > 0,
});
}
return reply.status(200).send({ ready: true });
}

View File

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

View File

@@ -4,7 +4,7 @@ import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket';
import {
eventBuffer,
getProfileById,
getProfileByIdCached,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json';
@@ -92,7 +92,10 @@ export async function wsProjectEvents(
type,
async (event) => {
if (event.projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId);
const profile = await getProfileByIdCached(
event.profileId,
event.projectId,
);
socket.send(
superjson.stringify(
access

View File

@@ -1,234 +1,51 @@
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 {
DEFAULT_IP_HEADER_ORDER,
getClientIpFromHeaders,
} from '@openpanel/common/server/get-client-ip';
import { createHash } from '@openpanel/common/server';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getCache, getRedisCache } from '@openpanel/redis';
import { cacheable, getCache, getRedisCache } from '@openpanel/redis';
interface GetFaviconParams {
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 {
async function getImageBuffer(url: string) {
try {
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;
}
}
const res = await fetch(url);
const contentType = res.headers.get('content-type');
// 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 (!contentType?.includes('image')) {
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 (!res.ok) {
return null;
}
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');
if (contentType === 'image/x-icon' || url.endsWith('.ico')) {
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return await icoToPng(buffer, 30);
}
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)
return await sharp(await res.arrayBuffer())
.resize(30, 30, {
fit: 'cover',
})
.png()
.toBuffer();
} catch (error) {
logger.warn('Sharp failed to process image, trying fallback', {
error: error instanceof Error ? error.message : 'Unknown error',
originalUrl,
bufferSize: buffer.length,
logger.error('Failed to get image from url', {
error,
url,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
// 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')
);
}
const imageExtensions = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'];
export async function getFavicon(
request: FastifyRequest<{
@@ -236,110 +53,68 @@ export async function getFavicon(
}>,
reply: FastifyReply,
) {
try {
const url = validateUrl(request.query.url);
if (!url) {
return createFallbackImage();
function sendBuffer(buffer: Buffer, cacheKey?: string) {
if (cacheKey) {
getRedisCache().set(`favicon:${cacheKey}`, buffer.toString('base64'));
}
const cacheKey = createCacheKey(url.toString());
// 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 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`,
);
}
}
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
}
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
const processedBuffer = await processImage(
buffer,
imageUrl.toString(),
contentType,
);
// 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);
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 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'));
}
const buffer = await getImageBuffer(url);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, cacheKey);
}
}
const { hostname } = new URL(url);
const cache = await getRedisCache().get(`favicon:${hostname}`);
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);
}
}
const buffer = await getImageBuffer(
'https://www.iconsdb.com/icons/download/orange/warning-128.png',
);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, hostname);
}
return reply.status(404).send('Not found');
}
export async function clearFavicons(
request: FastifyRequest,
reply: FastifyReply,
) {
const redis = getRedisCache();
const keys = await redis.keys('favicon:*');
// Delete both the binary data and content-type keys
const keys = await getRedisCache().keys('favicon:*');
for (const key of keys) {
await redis.del(key);
await redis.del(`${key}:ctype`);
await getRedisCache().del(key);
}
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');
return reply.status(404).send('OK');
}
export async function ping(
@@ -395,110 +170,3 @@ 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);
}
}

View File

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

View File

@@ -1,9 +1,10 @@
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,
@@ -15,39 +16,41 @@ export async function updateProfile(
}>,
reply: FastifyReply,
) {
const payload = request.body;
const { profileId, properties, ...rest } = request.body;
const projectId = request.client!.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const ip = request.clientIp;
const ua = request.headers['user-agent'];
const uaInfo = parseUserAgent(ua, payload.properties);
const geo = await getGeoLocation(ip);
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;
}
await upsertProfile({
...payload,
id: payload.profileId,
id: profileId,
isExternal: true,
projectId,
properties: {
...(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,
...(properties ?? {}),
...(ip ? geo : {}),
...uaInfo,
},
...rest,
});
reply.status(202).send(payload.profileId);
reply.status(202).send(profileId);
}
export async function incrementProfileProperty(
@@ -62,6 +65,18 @@ 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');
@@ -104,6 +119,18 @@ 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');

View File

@@ -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 { assocPath, pathOr, pick } from 'ramda';
import { path, assocPath, pathOr, pick } from 'ramda';
import { generateId } from '@openpanel/common';
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { eventsQueue } from '@openpanel/queue';
import { getLock } 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 =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
const identity = path<IdentifyPayload>(
['properties', '__identify'],
body.payload,
);
return (
identity ||
@@ -56,38 +56,28 @@ export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'],
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
'properties' in payload
? (payload?.properties?.__timestamp as string | undefined)
: undefined;
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
const userDefinedTimestamp = path<string>(
['properties', '__timestamp'],
payload,
);
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(clientTimestampNumber) ||
clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
Number.isNaN(clientTimestamp.getTime()) ||
clientTimestamp > new Date(safeTimestamp)
) {
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: clientTimestampNumber,
isTimestampFromThePast,
timestamp: clientTimestamp.toISOString(),
isTimestampFromThePast: true,
};
}
@@ -99,33 +89,22 @@ export async function handler(
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
'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'];
path<string>(['properties', '__ip'], request.body.payload) ||
getClientIp(request)!;
const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId;
if (!projectId) {
return reply.status(400).send({
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
@@ -135,17 +114,15 @@ export async function handler(
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const [salts, geo] = await Promise.all([getSalts(), parseIp(ip)]);
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
@@ -155,7 +132,33 @@ export async function handler(
})
: '';
const promises = [];
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,
}),
];
// 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
@@ -170,24 +173,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': {
const geo = await getGeoLocation(ip);
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
const geo = await parseIp(ip);
await identify({
payload: request.body.payload,
projectId,
@@ -197,13 +200,27 @@ export async function handler(
break;
}
case 'alias': {
return reply.status(400).send({
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,
@@ -211,6 +228,19 @@ export async function handler(
break;
}
case 'decrement': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
await decrement({
payload: request.body.payload,
projectId,
@@ -218,17 +248,23 @@ export async function handler(
break;
}
default: {
return reply.status(400).send({
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,
@@ -245,36 +281,48 @@ async function track({
projectId: string;
geo: GeoLocation;
headers: Record<string, string | undefined>;
timestamp: number;
timestamp: string;
isTimestampFromThePast: boolean;
}) {
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,
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,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
{
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,
},
);
}
async function identify({
@@ -296,18 +344,8 @@ async function identify({
projectId,
properties: {
...(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,
...(geo ?? {}),
...uaInfo,
},
});
}
@@ -383,65 +421,3 @@ 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',
});
}

View File

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

View File

@@ -1,28 +0,0 @@
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');
}
}

View File

@@ -0,0 +1,16 @@
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]}`,
);
}
}
}

View File

@@ -1,14 +1,13 @@
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import type { FastifyRequest } from 'fastify';
import { getClientIp } from '@/utils/parse-ip';
import type {
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
export async function ipHook(request: FastifyRequest) {
const { ip, header } = getClientIpFromHeaders(request.headers);
const ip = getClientIp(request);
if (ip) {
request.clientIp = ip;
request.clientIpHeader = header;
} else {
request.clientIp = '';
request.clientIpHeader = '';
}
}

View File

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

View File

@@ -8,54 +8,43 @@ import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import { generateId } from '@openpanel/common';
import {
type IServiceClientWithProject,
runWithAlsSession,
} from '@openpanel/db';
import { getCache, getRedisPub } from '@openpanel/redis';
import type { IServiceClientWithProject } from '@openpanel/db';
import { 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,
liveness,
readiness,
healthcheckQueue,
} 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;
clientIpHeader: string;
clientIp?: string;
timestamp?: number;
session: SessionValidationResult;
}
@@ -83,31 +72,15 @@ const startServer = async () => {
callback: (error: Error | null, options: FastifyCorsOptions) => void,
) => {
// TODO: set prefix on dashboard routes
const corsPaths = [
'/trpc',
'/live',
'/webhook',
'/oauth',
'/misc',
'/ai',
];
const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc'];
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: isAllowed ? origin : false,
origin: process.env.NEXT_PUBLIC_DASHBOARD_URL,
credentials: true,
});
}
@@ -125,6 +98,7 @@ 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, {
@@ -143,11 +117,10 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const sessionId = decodeSessionToken(req.cookies.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
);
req.session = session;
const session = await validateSessionToken(req.cookies.session);
if (session.session) {
req.session = session;
}
} catch (e) {
req.session = EMPTY_SESSION;
}
@@ -162,18 +135,11 @@ 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'],
@@ -182,7 +148,6 @@ 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
@@ -192,35 +157,16 @@ 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);
// New Kubernetes-style health endpoints
instance.get('/healthz/live', liveness);
instance.get('/healthz/ready', readiness);
instance.get('/healthcheck/queue', healthcheckQueue);
instance.get('/', (_request, reply) =>
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
}),
reply.send({ name: 'openpanel sdk api' }),
);
});
fastify.setErrorHandler((error, request, reply) => {
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) {
if (error.statusCode === 429) {
reply.status(429).send({
status: 429,
error: 'Too Many Requests',
@@ -239,17 +185,14 @@ const startServer = async () => {
});
if (process.env.NODE_ENV === 'production') {
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);
});
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);
});
});
}
}
await fastify.listen({
@@ -272,4 +215,5 @@ const startServer = async () => {
}
};
// start
startServer();

View File

@@ -55,7 +55,7 @@ const referrers: Record<string, { type: string; name: string }> = {
'lo.st': { type: 'search', name: 'Lo.st' },
'www1.dastelefonbuch.de': { type: 'search', name: 'DasTelefonbuch' },
'www.fireball.de': { type: 'search', name: 'Fireball' },
'suche.1und1.de': { type: 'search', name: '1und1' },
'search.1und1.de': { type: 'search', name: '1und1' },
'ricerca.virgilio.it': { type: 'search', name: 'Virgilio' },
'ricercaimmagini.virgilio.it': { type: 'search', name: 'Virgilio' },
'ricercavideo.virgilio.it': { type: 'search', name: 'Virgilio' },
@@ -1162,7 +1162,6 @@ const referrers: Record<string, { type: string; name: string }> = {
'clusty.com': { type: 'search', name: 'InfoSpace' },
'www.weborama.com': { type: 'search', name: 'Weborama' },
'search.bluewin.ch': { type: 'search', name: 'Bluewin' },
'search.brave.com': { type: 'search', name: 'Brave' },
'search.bt.com': { type: 'search', name: 'British Telecommunications' },
'www.neti.ee': { type: 'search', name: 'Neti' },
'nigma.ru': { type: 'search', name: 'Nigma' },
@@ -1207,8 +1206,6 @@ const referrers: Record<string, { type: string; name: string }> = {
'www.toile.com': { type: 'search', name: 'La Toile Du Quebec Via Google' },
'web.toile.com': { type: 'search', name: 'La Toile Du Quebec Via Google' },
'www.paperball.de': { type: 'search', name: 'Paperball' },
'arianna.libero.it': { type: 'search', name: 'Arianna' },
'www.arianna.com': { type: 'search', name: 'Arianna' },
'www.stepstone.de': { type: 'search', name: 'StepStone' },
'www.stepstone.at': { type: 'search', name: 'StepStone' },
'www.stepstone.be': { type: 'search', name: 'StepStone' },
@@ -1499,7 +1496,7 @@ const referrers: Record<string, { type: string; name: string }> = {
'www.walhello.com': { type: 'search', name: 'Walhello' },
'www.walhello.de': { type: 'search', name: 'Walhello' },
'www.walhello.nl': { type: 'search', name: 'Walhello' },
'www.startsiden.no': { type: 'search', name: 'Startsiden' },
'meta.ua': { type: 'search', name: 'Meta' },
'www.skynet.be': { type: 'search', name: 'Skynet' },
'www.searchy.co.uk': { type: 'search', name: 'Searchy' },
'search.findwide.com': { type: 'search', name: 'Findwide' },
@@ -1521,6 +1518,7 @@ const referrers: Record<string, { type: string; name: string }> = {
'search.lilo.org': { type: 'search', name: 'Lilo' },
'search.naver.com': { type: 'search', name: 'Naver' },
'www.zoeken.nl': { type: 'search', name: 'Zoeken' },
'www.startsiden.no': { type: 'search', name: 'Startsiden' },
'search.yam.com': { type: 'search', name: 'Yam' },
'www.eniro.se': { type: 'search', name: 'Eniro' },
'apollo7.de': { type: 'search', name: 'APOLL07' },
@@ -1571,7 +1569,6 @@ const referrers: Record<string, { type: string; name: string }> = {
'suche.gmx.net': { type: 'search', name: 'GMX' },
'daemon-search.com': { type: 'search', name: 'Daemon search' },
'my.daemon-search.com': { type: 'search', name: 'Daemon search' },
'meta.ua': { type: 'search', name: 'Meta.ua' },
'so.m.sm.cn': { type: 'search', name: 'Shenma' },
'yz.m.sm.cn': { type: 'search', name: 'Shenma' },
'm.sm.cn': { type: 'search', name: 'Shenma' },
@@ -1941,7 +1938,8 @@ const referrers: Record<string, { type: string; name: string }> = {
'jyxo.1188.cz': { type: 'search', name: 'Jyxo' },
'www.kataweb.it': { type: 'search', name: 'Kataweb' },
'busca.uol.com.br': { type: 'search', name: 'uol.com.br' },
'websearch.rakuten.co.jp': { type: 'search', name: 'Rakuten' },
'arianna.libero.it': { type: 'search', name: 'Arianna' },
'www.arianna.com': { type: 'search', name: 'Arianna' },
'www.mamma.com': { type: 'search', name: 'Mamma' },
'mamma75.mamma.com': { type: 'search', name: 'Mamma' },
'www.yatedo.com': { type: 'search', name: 'Yatedo' },
@@ -1949,6 +1947,7 @@ const referrers: Record<string, { type: string; name: string }> = {
'www.twingly.com': { type: 'search', name: 'Twingly' },
'smart.delfi.lv': { type: 'search', name: 'Delfi latvia' },
'www.pricerunner.co.uk': { type: 'search', name: 'PriceRunner' },
'websearch.rakuten.co.jp': { type: 'search', name: 'Rakuten' },
'www.google.com': { type: 'search', name: 'Google' },
'www.google.ac': { type: 'search', name: 'Google' },
'www.google.ad': { type: 'search', name: 'Google' },
@@ -2396,10 +2395,8 @@ const referrers: Record<string, { type: string; name: string }> = {
'email.telstra.com': { type: 'email', name: 'Bigpond' },
'basic.messaging.bigpond.com': { type: 'email', name: 'Bigpond' },
'mail.naver.com': { type: 'email', name: 'Naver Mail' },
'email.t-online.de': { type: 'email', name: 'T-online' },
'mail.zoho.com': { type: 'email', name: 'Zoho' },
'mail.163.com': { type: 'email', name: '163 Mail' },
'webmail.tim.it': { type: 'email', name: 'TIM' },
'webmail.virginbroadband.com.au': { type: 'email', name: 'Virgin' },
'mail.yahoo.net': { type: 'email', name: 'Yahoo! Mail' },
'mail.yahoo.com': { type: 'email', name: 'Yahoo! Mail' },
'mail.yahoo.co.uk': { type: 'email', name: 'Yahoo! Mail' },
@@ -2412,18 +2409,10 @@ const referrers: Record<string, { type: string; name: string }> = {
'mail.iinet.net.au': { type: 'email', name: 'iiNet' },
'mail.e1.ru': { type: 'email', name: 'E1.ru' },
'webmail.vodafone.co.nz': { type: 'email', name: 'Vodafone' },
'mail.vodafone.de': { type: 'email', name: 'Vodafone' },
'deref-1und1-02.de': { type: 'email', name: '1und1' },
'webmail.dodo.com.au': { type: 'email', name: 'Dodo' },
'mail.126.com': { type: 'email', name: '126 Mail' },
'com.mailchimp.mailchimp': { type: 'email', name: 'Mailchimp' },
'inbox.com': { type: 'email', name: 'Inbox.com' },
'webmail.iprimus.com.au': { type: 'email', name: 'iPrimus' },
'deref-web.de': { type: 'email', name: 'Web.de' },
'3c.web.de': { type: 'email', name: 'Web.de' },
'3c-bap.web.de': { type: 'email', name: 'Web.de' },
'lightmailer-bap.web.de': { type: 'email', name: 'Web.de' },
'lightmailer-bs.web.de': { type: 'email', name: 'Web.de' },
'mail.qq.com': { type: 'email', name: 'QQ Mail' },
'exmail.qq.com': { type: 'email', name: 'QQ Mail' },
'mail.qip.ru': { type: 'email', name: 'QIP' },
@@ -2434,79 +2423,30 @@ const referrers: Record<string, { type: string; name: string }> = {
'mail.live.com': { type: 'email', name: 'Outlook.com' },
'outlook.live.com': { type: 'email', name: 'Outlook.com' },
'com.microsoft.office.outlook': { type: 'email', name: 'Outlook.com' },
'deref-mail.com': { type: 'email', name: 'Mail.com' },
'3c-lxa.mail.com': { type: 'email', name: 'Mail.com' },
'lightmailer.mail.com': { type: 'email', name: 'Mail.com' },
'webmail.dodo.com.au': { type: 'email', name: 'Dodo' },
'webmail.2degreesbroadband.co.nz': { type: 'email', name: '2degrees' },
'mail2.daum.net': { type: 'email', name: 'Daum Mail' },
'mail.daum.net': { type: 'email', name: 'Daum Mail' },
'upcmail.hispeed.ch': { type: 'email', name: 'UPC' },
'webmail.2degreesbroadband.co.nz': { type: 'email', name: '2degrees' },
'post.ru': { type: 'email', name: 'Beeline' },
'mail.infomaniak.com': { type: 'email', name: 'Infomaniak' },
'e.mail.ru': { type: 'email', name: 'Mail.ru' },
'touch.mail.ru': { type: 'email', name: 'Mail.ru' },
'webmail.adam.com.au': { type: 'email', name: 'Adam Internet' },
'orange.fr/webmail': { type: 'email', name: 'Orange Webmail' },
'mail01.orange.fr': { type: 'email', name: 'Orange Webmail' },
'mail02.orange.fr': { type: 'email', name: 'Orange Webmail' },
'wmail.orange.fr': { type: 'email', name: 'Orange Webmail' },
'messageriepro3.orange.fr': { type: 'email', name: 'Orange Webmail' },
'messagerie.orange.fr': { type: 'email', name: 'Orange Webmail' },
'email.ionos.de': { type: 'email', name: 'Ionos' },
'email.ionos.es': { type: 'email', name: 'Ionos' },
'email.ionos.fr': { type: 'email', name: 'Ionos' },
'email.ionos.it': { type: 'email', name: 'Ionos' },
'email.ionos.ca': { type: 'email', name: 'Ionos' },
'email.ionos.mx': { type: 'email', name: 'Ionos' },
'email.ionos.com': { type: 'email', name: 'Ionos' },
'email.ionos.co.uk': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.de': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.es': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.fr': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.it': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.ca': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.mx': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.com': { type: 'email', name: 'Ionos' },
'mailbusiness.ionos.co.uk': { type: 'email', name: 'Ionos' },
'com.earthlink.myearthlink': { type: 'email', name: 'earthlink' },
'rich-v01.bluewin.ch': { type: 'email', name: 'Bluewin' },
'rich-v02.bluewin.ch': { type: 'email', name: 'Bluewin' },
'email.bluewin.ch': { type: 'email', name: 'Bluewin' },
'mail.aol.com': { type: 'email', name: 'AOL Mail' },
'com.aol.mobile.aolapp': { type: 'email', name: 'AOL Mail' },
'webmail.netspace.net.au': { type: 'email', name: 'Netspace' },
'webmail.optuszoo.com.au': { type: 'email', name: 'Optus Zoo' },
'webmail.optusnet.com.au': { type: 'email', name: 'Optus Zoo' },
'webmail.virginbroadband.com.au': { type: 'email', name: 'Virgin' },
'mail.proton.me': { type: 'email', name: 'Proton' },
'webmail.commander.net.au': { type: 'email', name: 'Commander' },
'mastermail.ru': { type: 'email', name: 'Mastermail' },
'm.mastermail.ru': { type: 'email', name: 'Mastermail' },
'deref-gmx.de': { type: 'email', name: 'GMX' },
'deref-gmx.at': { type: 'email', name: 'GMX' },
'deref-gmx.ch': { type: 'email', name: 'GMX' },
'deref-gmx.fr': { type: 'email', name: 'GMX' },
'deref-gmx.es': { type: 'email', name: 'GMX' },
'deref-gmx.it': { type: 'email', name: 'GMX' },
'deref-gmx.com': { type: 'email', name: 'GMX' },
'deref-gmx.net': { type: 'email', name: 'GMX' },
'deref-gmx.co.uk': { type: 'email', name: 'GMX' },
'lightmailer.gmx.de': { type: 'email', name: 'GMX' },
'lightmailer.gmx.at': { type: 'email', name: 'GMX' },
'lightmailer.gmx.ch': { type: 'email', name: 'GMX' },
'lightmailer.gmx.fr': { type: 'email', name: 'GMX' },
'lightmailer.gmx.es': { type: 'email', name: 'GMX' },
'lightmailer.gmx.it': { type: 'email', name: 'GMX' },
'lightmailer.gmx.com': { type: 'email', name: 'GMX' },
'lightmailer.gmx.net': { type: 'email', name: 'GMX' },
'lightmailer.gmx.co.uk': { type: 'email', name: 'GMX' },
'lightmailer-bs.gmx.net': { type: 'email', name: 'GMX' },
'lightmailer-bap.gmx.net': { type: 'email', name: 'GMX' },
'mail.yandex.ru': { type: 'email', name: 'Yandex' },
'mail.yandex.com': { type: 'email', name: 'Yandex' },
'mail.yandex.kz': { type: 'email', name: 'Yandex' },
'mail.yandex.ua': { type: 'email', name: 'Yandex' },
'mail.yandex.by': { type: 'email', name: 'Yandex' },
'e.mail.ru': { type: 'email', name: 'Mail.ru' },
'touch.mail.ru': { type: 'email', name: 'Mail.ru' },
'mail.163.com': { type: 'email', name: '163 Mail' },
'mail.ukr.net': { type: 'email', name: 'Ukr.net' },
'mail.rambler.ru': { type: 'email', name: 'Rambler' },
'mail.mynet.com': { type: 'email', name: 'Mynet Mail' },
@@ -2569,14 +2509,9 @@ const referrers: Record<string, { type: string; name: string }> = {
'www.googleadservices.com': { type: 'paid', name: 'Google' },
'partner.googleadservices.com': { type: 'paid', name: 'Google' },
'googleads.g.doubleclick.net': { type: 'paid', name: 'Google' },
'tdsf.doubleclick.net': { type: 'paid', name: 'Google' },
'tpc.googlesyndication.com': { type: 'paid', name: 'Google' },
'safeframe.googlesyndication.com': { type: 'paid', name: 'Google' },
'googleadservices.com': { type: 'paid', name: 'Google' },
'imasdk.googleapis.com': { type: 'paid', name: 'Google' },
'www.adsensecustomsearchads.com': { type: 'paid', name: 'Google' },
'syndicatedsearch.goog': { type: 'paid', name: 'Google' },
'pagead2.googlesyndication.com': { type: 'paid', name: 'Google' },
'eyeota.net': { type: 'paid', name: 'Eyeota' },
'price.ru': { type: 'paid', name: 'Price.ru' },
'v.price.ru': { type: 'paid', name: 'Price.ru' },
@@ -2609,7 +2544,8 @@ const referrers: Record<string, { type: string; name: string }> = {
'sonico.com': { type: 'social', name: 'Sonico.com' },
'odnoklassniki.ru': { type: 'social', name: 'Odnoklassniki' },
'ok.ru': { type: 'social', name: 'Odnoklassniki' },
'github.com': { type: 'tech', name: 'GitHub' },
'tildes.net': { type: 'social', name: 'Tildes' },
'com.talklittle.android.tildes': { type: 'social', name: 'Tildes' },
'classmates.com': { type: 'social', name: 'Classmates' },
'friendsreunited.com': { type: 'social', name: 'Friends Reunited' },
'news.ycombinator.com': { type: 'social', name: 'Hacker News' },
@@ -2620,12 +2556,9 @@ const referrers: Record<string, { type: string; name: string }> = {
'orkut.com': { type: 'social', name: 'Orkut' },
'myheritage.com': { type: 'social', name: 'MyHeritage' },
'multiply.com': { type: 'social', name: 'Multiply' },
'facebook.com': { type: 'social', name: 'Facebook' },
'fb.me': { type: 'social', name: 'Facebook' },
'm.facebook.com': { type: 'social', name: 'Facebook' },
'l.facebook.com': { type: 'social', name: 'Facebook' },
'lm.facebook.com': { type: 'social', name: 'Facebook' },
'com.facebook.katana': { type: 'social', name: 'Facebook' },
'threads.net': { type: 'social', name: 'Threads' },
'l.threads.net': { type: 'social', name: 'Threads' },
'com.instagram.barcelona': { type: 'social', name: 'Threads' },
'myyearbook.com': { type: 'social', name: 'myYearbook' },
'renren.com': { type: 'social', name: 'Renren' },
'app.slack.com': { type: 'social', name: 'Slack' },
@@ -2699,13 +2632,15 @@ const referrers: Record<string, { type: string; name: string }> = {
'douban.com': { type: 'social', name: 'Douban' },
'login.live.com': { type: 'social', name: 'Windows Live Spaces' },
'blackplanet.com': { type: 'social', name: 'BlackPlanet' },
'lnk.bio': { type: 'social', name: 'Lnk.Bio' },
'global.cyworld.com': { type: 'social', name: 'Cyworld' },
'getpocket.com': { type: 'social', name: 'Pocket' },
'skyrock.com': { type: 'social', name: 'Skyrock' },
'threads.net': { type: 'social', name: 'Threads' },
'l.threads.net': { type: 'social', name: 'Threads' },
'com.instagram.barcelona': { type: 'social', name: 'Threads' },
'facebook.com': { type: 'social', name: 'Facebook' },
'fb.me': { type: 'social', name: 'Facebook' },
'm.facebook.com': { type: 'social', name: 'Facebook' },
'l.facebook.com': { type: 'social', name: 'Facebook' },
'lm.facebook.com': { type: 'social', name: 'Facebook' },
'com.facebook.katana': { type: 'social', name: 'Facebook' },
'web.whatsapp.com': { type: 'social', name: 'WhatsApp' },
'com.whatsapp': { type: 'social', name: 'WhatsApp' },
'redirect.disqus.com': { type: 'social', name: 'Disqus' },
@@ -2728,11 +2663,8 @@ const referrers: Record<string, { type: string; name: string }> = {
'com.laurencedawson.reddit_sync': { type: 'social', name: 'Reddit' },
'com.laurencedawson.reddit_sync.pro': { type: 'social', name: 'Reddit' },
'viadeo.com': { type: 'social', name: 'Viadeo' },
'tildes.net': { type: 'social', name: 'Tildes' },
'com.talklittle.android.tildes': { type: 'social', name: 'Tildes' },
'l.workplace.com': { type: 'social', name: 'Workplace' },
'lm.workplace.com': { type: 'social', name: 'Workplace' },
'stackoverflow.com': { type: 'tech', name: 'Stack Overflow' },
'github.com': { type: 'social', name: 'GitHub' },
'stackoverflow.com': { type: 'social', name: 'StackOverflow' },
'gaiaonline.com': { type: 'social', name: 'Gaia Online' },
'stumbleupon.com': { type: 'social', name: 'StumbleUpon' },
'inci.sozlukspot.com': { type: 'social', name: 'Inci Sozluk' },
@@ -2748,38 +2680,6 @@ const referrers: Record<string, { type: string; name: string }> = {
'hyves.nl': { type: 'social', name: 'Hyves' },
'paper.li': { type: 'social', name: 'Paper.li' },
'moikrug.ru': { type: 'social', name: 'MoiKrug.ru' },
'zoom.us': { type: 'social', name: 'Zoom' },
'apple.com': { type: 'tech', name: 'Apple' },
'adobe.com': { type: 'tech', name: 'Adobe' },
'figma.com': { type: 'tech', name: 'Figma' },
'wix.com': { type: 'commerce', name: 'Wix' },
'gmail.com': { type: 'email', name: 'Gmail' },
'notion.so': { type: 'tech', name: 'Notion' },
'ebay.com': { type: 'commerce', name: 'eBay' },
'gitlab.com': { type: 'tech', name: 'GitLab' },
'slack.com': { type: 'social', name: 'Slack' },
'etsy.com': { type: 'commerce', name: 'Etsy' },
'bsky.app': { type: 'social', name: 'Bluesky' },
'twitch.tv': { type: 'content', name: 'Twitch' },
'dropbox.com': { type: 'tech', name: 'Dropbox' },
'outlook.com': { type: 'email', name: 'Outlook' },
'medium.com': { type: 'content', name: 'Medium' },
'paypal.com': { type: 'commerce', name: 'PayPal' },
'discord.com': { type: 'social', name: 'Discord' },
'stripe.com': { type: 'commerce', name: 'Stripe' },
'spotify.com': { type: 'content', name: 'Spotify' },
'netflix.com': { type: 'content', name: 'Netflix' },
'whatsapp.com': { type: 'social', name: 'WhatsApp' },
'shopify.com': { type: 'commerce', name: 'Shopify' },
'microsoft.com': { type: 'tech', name: 'Microsoft' },
'alibaba.com': { type: 'commerce', name: 'Alibaba' },
'telegram.org': { type: 'social', name: 'Telegram' },
'substack.com': { type: 'content', name: 'Substack' },
'salesforce.com': { type: 'tech', name: 'Salesforce' },
'wikipedia.org': { type: 'content', name: 'Wikipedia' },
'mastodon.social': { type: 'social', name: 'Mastodon' },
'office.com': { type: 'tech', name: 'Microsoft Office' },
'squarespace.com': { type: 'commerce', name: 'Squarespace' },
'teams.microsoft.com': { type: 'social', name: 'Microsoft Teams' },
} as const;
export default referrers;

View File

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

View File

@@ -2,11 +2,9 @@ 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);

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import { 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);
@@ -31,23 +29,6 @@ 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;

View File

@@ -1,475 +0,0 @@
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(',');
});
},
});
}

View File

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

View File

@@ -3,7 +3,6 @@ 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,
@@ -105,26 +104,6 @@ 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);
@@ -152,13 +131,7 @@ export async function validateSdkRequest(
}
if (client.secret && clientSecret) {
const isVerified = await getCache(
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
if (await verifyPassword(clientSecret, client.secret)) {
return client;
}
}

View File

@@ -1,14 +1,11 @@
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;
}) {
@@ -16,8 +13,6 @@ export async function isDuplicatedEvent({
`fastify:deduplicate:${fastJsonStableHash.hash(
{
...payload,
ip,
origin,
projectId,
},
'md5',
@@ -32,3 +27,24 @@ 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;
}

View File

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

View File

@@ -1,108 +0,0 @@
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);
}

View File

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

View File

@@ -5,16 +5,12 @@ function fallbackFavicon(url: string) {
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {
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',
);
const match = favicons.find(
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
);
if (match) {
return match.href;
@@ -22,32 +18,11 @@ 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,
};
}
@@ -57,11 +32,6 @@ 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) {
@@ -72,7 +42,6 @@ export async function parseUrlMeta(url: string) {
} catch (err) {
return {
favicon: fallbackFavicon(url),
ogImage: null,
};
}
}

View File

@@ -1,16 +1,14 @@
import { getRedisCache } from '@openpanel/redis';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { FastifyInstance } from 'fastify';
export async function activateRateLimiter<T extends FastifyRequest>({
export async function activateRateLimiter({
fastify,
max,
timeWindow,
keyGenerator,
}: {
fastify: FastifyInstance;
max: number;
timeWindow?: string;
keyGenerator?: (req: T) => string | undefined;
}) {
await fastify.register(import('@fastify/rate-limit'), {
max,
@@ -24,12 +22,6 @@ export async function activateRateLimiter<T extends FastifyRequest>({
},
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'] ||

View File

@@ -1,23 +0,0 @@
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);

26
apps/api/tsup.config.ts Normal file
View File

@@ -0,0 +1,26 @@
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 Normal file
View File

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

View File

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

98
apps/dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,98 @@
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
apps/dashboard/README.md Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
// @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;

135
apps/dashboard/package.json Normal file
View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 865 B

After

Width:  |  Height:  |  Size: 865 B

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
'use client';
import { Card, CardActions, CardActionsItem } from '@/components/card';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal, showConfirm } from '@/modals';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { api, handleErrorToastOptions } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { format } from 'date-fns';
import {
AreaChartIcon,
@@ -13,6 +15,7 @@ import {
ChartScatterIcon,
ConeIcon,
Globe2Icon,
Grid3X3Icon,
HashIcon,
LayoutPanelTopIcon,
LineChartIcon,
@@ -20,72 +23,42 @@ import {
PieChartIcon,
PlusIcon,
Trash,
TrendingUpIcon,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link, createFileRoute } from '@tanstack/react-router';
import type { IServiceDashboards } from '@openpanel/db';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/dashboards',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle('Dashboards'),
},
],
};
},
async loader({ context, params }) {
await context.queryClient.prefetchQuery(
context.trpc.dashboard.list.queryOptions({
projectId: params.projectId,
}),
);
},
pendingComponent: FullPageLoadingState,
});
interface ListDashboardsProps {
dashboards: IServiceDashboards;
}
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const query = useQuery(
trpc.dashboard.list.queryOptions({
projectId,
}),
);
const dashboards = query.data ?? [];
const deletion = useMutation(
trpc.dashboard.delete.mutationOptions({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useAppParams();
const { organizationId, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
})(error);
},
onSuccess() {
query.refetch();
toast('Success', {
description: 'Dashboard deleted.',
});
},
}),
);
},
})(error);
},
onSuccess() {
router.refresh();
toast('Success', {
description: 'Dashboard deleted.',
});
},
});
if (dashboards.length === 0) {
return (
@@ -103,18 +76,7 @@ function Component() {
}
return (
<PageContainer>
<PageHeader
title="Dashboards"
description="Access all your dashboards here"
className="mb-8"
actions={
<Button icon={PlusIcon} onClick={() => pushModal('AddDashboard')}>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
}
/>
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{dashboards.map((item) => {
const visibleReports = item.reports.slice(
@@ -125,8 +87,7 @@ function Component() {
<Card key={item.id} hover>
<div>
<Link
from={Route.fullPath}
to={`${item.id}`}
href={`/${organizationId}/${projectId}/dashboards/${item.id}`}
className="flex flex-col p-4 @container"
>
<div className="col gap-2">
@@ -152,7 +113,6 @@ function Component() {
funnel: ConeIcon,
area: AreaChartIcon,
retention: ChartScatterIcon,
conversion: TrendingUpIcon,
}[report.chartType];
return (
@@ -201,10 +161,8 @@ function Component() {
<button
type="button"
onClick={() => {
showConfirm({
title: 'Delete dashboard',
text: 'Are you sure you want to delete this dashboard? All your reports will be deleted!',
onConfirm: () => deletion.mutate({ id: item.id }),
deletion.mutate({
id: item.id,
});
}}
>
@@ -217,6 +175,6 @@ function Component() {
);
})}
</div>
</PageContainer>
</>
);
}

View File

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

View File

@@ -1,43 +1,41 @@
import {
OverviewFilterButton,
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
'use client';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
} from '@/hooks/useEventQueryFilters';
import type { IChartEventItem } from '@openpanel/validation';
import type { IChartEvent } from '@openpanel/validation';
import { createFileRoute } from '@tanstack/react-router';
interface Props {
projectId: string;
}
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/events/_tabs/stats',
)({
component: Component,
});
function Component() {
const { projectId } = Route.useParams();
function Charts({ projectId }: Props) {
const [filters] = useEventQueryFilters();
const [events] = useEventQueryNamesFilter();
const fallback: IChartEventItem[] = [
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
];
return (
<div>
<div className="mb-2 flex items-center gap-2">
<OverviewFilterButton enableEventsFilter />
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
@@ -50,7 +48,7 @@ function Component() {
projectId={projectId}
range="30d"
chartType="histogram"
series={
events={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -58,7 +56,6 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: fallback
}
@@ -80,7 +77,7 @@ function Component() {
name: 'name',
},
]}
series={
events={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -88,7 +85,6 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -97,7 +93,6 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -119,7 +114,7 @@ function Component() {
name: 'name',
},
]}
series={
events={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -127,7 +122,6 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -136,7 +130,6 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -158,7 +151,7 @@ function Component() {
name: 'name',
},
]}
series={
events={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -166,7 +159,6 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -175,7 +167,6 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -186,3 +177,5 @@ function Component() {
</div>
);
}
export default Charts;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,63 +12,55 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/use-app-params';
import { useRouter } from '@tanstack/react-router';
import { Link } from '@tanstack/react-router';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import {
Building2Icon,
CheckIcon,
ChevronsUpDownIcon,
PlusIcon,
} from 'lucide-react';
import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react';
import { pushModal } from '@/modals';
import type { IServiceOrganization } from '@openpanel/db';
import type {
getOrganizations,
getProjectsByOrganizationId,
} from '@openpanel/db';
import Link from 'next/link';
interface ProjectSelectorProps {
projects: Array<{ id: string; name: string; organizationId: string }>;
organizations?: IServiceOrganization[];
interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
organizations?: Awaited<ReturnType<typeof getOrganizations>>;
align?: 'start' | 'end';
}
export default function ProjectSelector({
export default function LayoutProjectSelector({
projects,
organizations,
align = 'start',
}: ProjectSelectorProps) {
}: LayoutProjectSelectorProps) {
const router = useRouter();
const { organizationId, projectId } = useAppParams();
const pathname = usePathname() || '';
const [open, setOpen] = useState(false);
const changeProject = (newProjectId: string) => {
if (organizationId && projectId) {
// Navigate to the new project keeping the current path structure
router.navigate({
to: '/$organizationId/$projectId',
params: {
organizationId,
projectId: newProjectId,
},
});
const split = pathname
.replace(
`/${organizationId}/${projectId}`,
`/${organizationId}/${newProjectId}`,
)
.split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/'));
} else {
router.navigate({
to: '/$organizationId/$projectId',
params: {
organizationId: organizationId!,
projectId: newProjectId,
},
});
router.push(`/${organizationId}/${newProjectId}`);
}
};
const changeOrganization = (newOrganizationId: string) => {
router.navigate({
to: '/$organizationId',
params: {
organizationId: newOrganizationId,
},
});
router.push(`/${newOrganizationId}`);
};
return (
@@ -107,21 +102,12 @@ export default function ProjectSelector({
))}
{projects.length > 10 && (
<DropdownMenuItem asChild>
<Link
to={'/$organizationId'}
params={{
organizationId,
}}
>
All projects
</Link>
<Link href={`/${organizationId}`}>All projects</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-emerald-600"
onClick={() => {
pushModal('AddProject');
}}
onClick={() => pushModal('AddProject')}
>
Create new project
<DropdownMenuShortcut>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +1,36 @@
import {
OverviewFilterButton,
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewShareServer from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/$projectId/')({
component: ProjectDashboard,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.DASHBOARD),
},
],
};
},
});
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
function ProjectDashboard() {
const { projectId } = Route.useParams();
interface PageProps {
params: {
projectId: string;
};
}
export default function Page({ params: { projectId } }: PageProps) {
return (
<div>
<>
<div className="col gap-2 p-4">
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<OverviewRange />
<OverviewInterval />
<OverviewFilterButton mode="events" />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<LiveCounter projectId={projectId} />
<OverviewShare projectId={projectId} />
<ServerLiveCounter projectId={projectId} />
<OverviewShareServer projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
@@ -54,6 +43,6 @@ function ProjectDashboard() {
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
import { Widget } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
'use client';
import { Widget, WidgetHead, WidgetTitle } from '@/components/widget';
import { BellIcon } from 'lucide-react';
type Props = {
data: { count: number; name: string }[];
};
export const MostEvents = ({ data }: Props) => {
const MostEvents = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count));
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Popular events</WidgetTitle>
<WidgetTitle icon={BellIcon}>Popular events</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
@@ -31,3 +33,5 @@ export const MostEvents = ({ data }: Props) => {
</Widget>
);
};
export default MostEvents;

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
import { Widget } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
'use client';
import { Widget, WidgetHead, WidgetTitle } from '@/components/widget';
import { BellIcon, MonitorPlayIcon } from 'lucide-react';
type Props = {
data: { count: number; path: string }[];
};
export const PopularRoutes = ({ data }: Props) => {
const PopularRoutes = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count));
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Most visted pages</WidgetTitle>
<WidgetTitle icon={MonitorPlayIcon}>Most visted pages</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
@@ -31,3 +33,5 @@ export const PopularRoutes = ({ data }: Props) => {
</Widget>
);
};
export default PopularRoutes;

View File

@@ -0,0 +1,20 @@
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);

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