Compare commits
43 Commits
feature/bi
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd3b89fa3 | ||
|
|
95af86dc44 | ||
|
|
727a218e6b | ||
|
|
958ba535d6 | ||
|
|
3bbeb927cc | ||
|
|
d99335e2f4 | ||
|
|
1fa61b1ae9 | ||
|
|
548747d826 | ||
|
|
7b18544085 | ||
|
|
57697a5a39 | ||
|
|
06fb6c4f3c | ||
|
|
dd71fd4e11 | ||
|
|
00e25ed4b8 | ||
|
|
83e223a496 | ||
|
|
790801b728 | ||
|
|
d61cbf6f2c | ||
|
|
887ed09388 | ||
|
|
8ba714ce81 | ||
|
|
c8e3cf8552 | ||
|
|
18c056f3ea | ||
|
|
1562d49fd6 | ||
|
|
aa8765d627 | ||
|
|
56c74e13ff | ||
|
|
10726bf373 | ||
|
|
dcc0d0df18 | ||
|
|
da59622dce | ||
|
|
38cc53890a | ||
|
|
e4fac81d27 | ||
|
|
7719985ad1 | ||
|
|
c0cefe704b | ||
|
|
5dbb462578 | ||
|
|
2b0b62d64c | ||
|
|
87e98baeb3 | ||
|
|
2abb44831c | ||
|
|
f990cfcc18 | ||
|
|
8fbe944df0 | ||
|
|
c1801adaa2 | ||
|
|
84fd5ce22f | ||
|
|
447b7668fd | ||
|
|
e505c0ea45 | ||
|
|
e613a4e01c | ||
|
|
723ba3ef6c | ||
|
|
9cafd61b25 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -17,7 +17,7 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
|
||||
161
admin/README.md
Normal file
161
admin/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# OpenPanel Admin CLI
|
||||
|
||||
An interactive CLI tool to help manage and lookup OpenPanel organizations, projects, and clients.
|
||||
|
||||
## Setup
|
||||
|
||||
First, install dependencies:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the CLI from the admin directory:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Or use the convenient shell script from anywhere:
|
||||
|
||||
```bash
|
||||
./admin/cli
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
The CLI provides 4 focused lookup commands for easier navigation:
|
||||
|
||||
### 🏢 Lookup by Organization
|
||||
|
||||
Search and view detailed information about an organization.
|
||||
|
||||
- Fuzzy search across all organizations by name or ID
|
||||
- Shows full organization details with all projects, clients, and members
|
||||
|
||||
### 📊 Lookup by Project
|
||||
|
||||
Search for a specific project and view its organization context.
|
||||
|
||||
- Fuzzy search across all projects by name or ID
|
||||
- Highlights the selected project in the organization view
|
||||
- Displays: `org → project`
|
||||
|
||||
### 🔑 Lookup by Client ID
|
||||
|
||||
Search for a specific client and view its full context.
|
||||
|
||||
- Fuzzy search across all clients by name or ID
|
||||
- Highlights the selected client and its project
|
||||
- Displays: `org → project → client`
|
||||
|
||||
### 📧 Lookup by Email
|
||||
|
||||
Search for a member by email address.
|
||||
|
||||
- Fuzzy search across all member emails
|
||||
- Shows which organization(s) the member belongs to
|
||||
- Displays member role (👑 owner, ⭐ admin, 👤 member)
|
||||
|
||||
**All lookups display:**
|
||||
- Organization information (ID, name, subscription status, timezone, event usage)
|
||||
- Organization members and their roles
|
||||
- All projects with their settings (domain, CORS, event counts)
|
||||
- All clients for each project (ID, name, type, credentials)
|
||||
- Deletion warnings if scheduled
|
||||
|
||||
---
|
||||
|
||||
### 🗑️ Clear Cache
|
||||
|
||||
Clear cache for an organization and all its projects.
|
||||
|
||||
- Fuzzy search to find the organization
|
||||
- Shows organization details and all projects
|
||||
- Confirms before clearing cache
|
||||
- Provides organization ID and all project IDs for cache clearing logic
|
||||
|
||||
**Use when:**
|
||||
- You need to invalidate cache after data changes
|
||||
- Troubleshooting caching issues
|
||||
- After manual database updates
|
||||
|
||||
**Note:** The cache clearing logic needs to be implemented. The command provides the organization and project data structure for you to add your cache clearing calls.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Delete Organization
|
||||
|
||||
Permanently delete an organization and all its data.
|
||||
|
||||
- Fuzzy search to find the organization
|
||||
- Shows detailed preview of what will be deleted (projects, members, events)
|
||||
- Requires **3 confirmations**:
|
||||
1. Initial confirmation
|
||||
2. Type organization name to confirm
|
||||
3. Final warning confirmation
|
||||
- Deletes from both PostgreSQL and ClickHouse
|
||||
|
||||
**Use when:**
|
||||
- Removing organizations that are no longer needed
|
||||
- Cleaning up test/demo organizations
|
||||
- Handling deletion requests
|
||||
|
||||
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
|
||||
|
||||
**What gets deleted:**
|
||||
- Organization record
|
||||
- All projects and their settings
|
||||
- All clients and credentials
|
||||
- All events and analytics data (from ClickHouse)
|
||||
- All member associations
|
||||
- All dashboards and reports
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Delete User
|
||||
|
||||
Permanently delete a user account and remove them from all organizations.
|
||||
|
||||
- Fuzzy search by email or name
|
||||
- Shows which organizations the user belongs to
|
||||
- Shows if user created any organizations (won't delete those orgs)
|
||||
- Requires **3 confirmations**:
|
||||
1. Initial confirmation
|
||||
2. Type user email to confirm
|
||||
3. Final warning confirmation
|
||||
|
||||
**Use when:**
|
||||
- Removing user accounts at user request
|
||||
- Cleaning up inactive accounts
|
||||
- Handling GDPR/data deletion requests
|
||||
|
||||
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
|
||||
|
||||
**What gets deleted:**
|
||||
- User account
|
||||
- All auth sessions and tokens
|
||||
- All memberships (removed from all orgs)
|
||||
- All personal data
|
||||
|
||||
**What is NOT deleted:**
|
||||
- Organizations created by the user (only the creator reference is removed)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Make sure you have the proper environment variables set up:
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `DATABASE_URL_REPLICA` (optional) - Read replica connection string
|
||||
|
||||
## Development
|
||||
|
||||
The CLI uses:
|
||||
- **jiti** - Direct TypeScript execution without build step
|
||||
- **inquirer** - Interactive prompts
|
||||
- **inquirer-autocomplete-prompt** - Fuzzy search functionality
|
||||
- **chalk** - Colored terminal output
|
||||
- **@openpanel/db** - Direct Prisma database access
|
||||
|
||||
25
admin/package.json
Normal file
25
admin/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@openpanel/admin",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "dotenv -e .env -c -- jiti src/cli.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"chalk": "^5.3.0",
|
||||
"fuzzy": "^0.1.3",
|
||||
"inquirer": "^9.3.5",
|
||||
"inquirer-autocomplete-prompt": "^3.0.1",
|
||||
"jiti": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/inquirer-autocomplete-prompt": "^3.0.3",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
118
admin/src/cli.ts
Normal file
118
admin/src/cli.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
import inquirer from 'inquirer';
|
||||
import { clearCache } from './commands/clear-cache';
|
||||
import { deleteOrganization } from './commands/delete-organization';
|
||||
import { deleteUser } from './commands/delete-user';
|
||||
import { lookupByClient } from './commands/lookup-client';
|
||||
import { lookupByEmail } from './commands/lookup-email';
|
||||
import { lookupByOrg } from './commands/lookup-org';
|
||||
import { lookupByProject } from './commands/lookup-project';
|
||||
|
||||
const secureEnv = (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.username && parsed.password) {
|
||||
return `${parsed.protocol}//${parsed.username}:${parsed.password.slice(0, 1)}...${parsed.password.slice(-1)}@${parsed.hostname}:${parsed.port}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔧 OpenPanel Admin CLI\n');
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
|
||||
console.error('Environment variables are not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Log environment variables for debugging
|
||||
console.log('Environment:', {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
SELF_HOSTED: process.env.SELF_HOSTED ? 'Yes' : 'No',
|
||||
DATABASE_URL: secureEnv(DATABASE_URL),
|
||||
CLICKHOUSE_URL: secureEnv(CLICKHOUSE_URL),
|
||||
REDIS_URL: secureEnv(REDIS_URL),
|
||||
});
|
||||
console.log('');
|
||||
|
||||
const { command } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'command',
|
||||
message: 'What would you like to do?',
|
||||
pageSize: 20,
|
||||
choices: [
|
||||
{
|
||||
name: '🏢 Lookup by Organization',
|
||||
value: 'lookup-org',
|
||||
},
|
||||
{
|
||||
name: '📊 Lookup by Project',
|
||||
value: 'lookup-project',
|
||||
},
|
||||
{
|
||||
name: '🔑 Lookup by Client ID',
|
||||
value: 'lookup-client',
|
||||
},
|
||||
{
|
||||
name: '📧 Lookup by Email',
|
||||
value: 'lookup-email',
|
||||
},
|
||||
{
|
||||
name: '🗑️ Clear Cache',
|
||||
value: 'clear-cache',
|
||||
},
|
||||
{ name: '─────────────────────', value: 'separator', disabled: true },
|
||||
{
|
||||
name: '🔴 Delete Organization',
|
||||
value: 'delete-org',
|
||||
},
|
||||
{
|
||||
name: '🔴 Delete User',
|
||||
value: 'delete-user',
|
||||
},
|
||||
{ name: '─────────────────────', value: 'separator', disabled: true },
|
||||
{ name: '❌ Exit', value: 'exit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
switch (command) {
|
||||
case 'lookup-org':
|
||||
await lookupByOrg();
|
||||
break;
|
||||
case 'lookup-project':
|
||||
await lookupByProject();
|
||||
break;
|
||||
case 'lookup-client':
|
||||
await lookupByClient();
|
||||
break;
|
||||
case 'lookup-email':
|
||||
await lookupByEmail();
|
||||
break;
|
||||
case 'clear-cache':
|
||||
await clearCache();
|
||||
break;
|
||||
case 'delete-org':
|
||||
await deleteOrganization();
|
||||
break;
|
||||
case 'delete-user':
|
||||
await deleteUser();
|
||||
break;
|
||||
case 'exit':
|
||||
console.log('Goodbye! 👋');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Loop back to main menu
|
||||
await main();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
162
admin/src/commands/clear-cache.ts
Normal file
162
admin/src/commands/clear-cache.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
db,
|
||||
getOrganizationAccess,
|
||||
getOrganizationByProjectIdCached,
|
||||
getProjectAccess,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
console.log(chalk.blue('\n🗑️ Clear Cache\n'));
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch organization with all projects
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('\n📋 Organization Details:\n'));
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
|
||||
|
||||
if (organization.projects.length > 0) {
|
||||
console.log(chalk.yellow('\n📊 Projects:\n'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm before clearing cache
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Clear cache for organization "${organization.name}" and all ${organization.projects.length} projects?`,
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirm) {
|
||||
console.log(chalk.yellow('\nCache clear cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.blue('\n🔄 Clearing cache...\n'));
|
||||
|
||||
for (const project of organization.projects) {
|
||||
// Clear project access cache for each member
|
||||
for (const member of organization.members) {
|
||||
if (!member.user?.id) continue;
|
||||
console.log(
|
||||
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
|
||||
);
|
||||
await getProjectAccess.clear({
|
||||
userId: member.user?.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
await getOrganizationAccess.clear({
|
||||
userId: member.user?.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Clearing cache for project: ${project.name}`);
|
||||
await getOrganizationByProjectIdCached.clear(project.id);
|
||||
await getProjectByIdCached.clear(project.id);
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Organization ID: ${organization.id}`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Example of what you might do:
|
||||
/*
|
||||
for (const project of organization.projects) {
|
||||
console.log(`Clearing cache for project: ${project.name}...`);
|
||||
// await clearProjectCache(project.id);
|
||||
// await redis.del(`project:${project.id}:*`);
|
||||
}
|
||||
|
||||
// Clear organization-level cache
|
||||
// await clearOrganizationCache(organization.id);
|
||||
// await redis.del(`organization:${organization.id}:*`);
|
||||
|
||||
console.log(chalk.green('\n✅ Cache cleared successfully!'));
|
||||
*/
|
||||
}
|
||||
215
admin/src/commands/delete-organization.ts
Normal file
215
admin/src/commands/delete-organization.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
db,
|
||||
deleteFromClickhouse,
|
||||
deleteOrganization as deleteOrg,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function deleteOrganization() {
|
||||
console.log(chalk.red('\n🗑️ Delete Organization\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
|
||||
),
|
||||
);
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
include: {
|
||||
projects: true,
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization to delete:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display what will be deleted
|
||||
console.log(chalk.red('\n⚠️ YOU ARE ABOUT TO DELETE:\n'));
|
||||
console.log(` ${chalk.bold('Organization:')} ${organization.name}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
|
||||
console.log(` ${chalk.gray('Members:')} ${organization.members.length}`);
|
||||
|
||||
if (organization.projects.length > 0) {
|
||||
console.log(chalk.red('\n Projects that will be deleted:'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.members.length > 0) {
|
||||
console.log(chalk.red('\n Members who will lose access:'));
|
||||
for (const member of organization.members) {
|
||||
const email = member.user?.email || member.email || 'Unknown';
|
||||
console.log(` - ${email} ${chalk.gray(`(${member.role})`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!',
|
||||
),
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
const { confirmFirst } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFirst) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation - type organization name
|
||||
const { confirmName } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirmName',
|
||||
message: `Type the organization name "${organization.name}" to confirm deletion:`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmName !== organization.name) {
|
||||
console.log(
|
||||
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const { confirmFinal } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFinal) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.red('\n🗑️ Deleting organization...\n'));
|
||||
|
||||
try {
|
||||
const projectIds = organization.projects.map((p) => p.id);
|
||||
|
||||
// Step 1: Delete from ClickHouse (events, profiles, etc.)
|
||||
if (projectIds.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
|
||||
),
|
||||
);
|
||||
await deleteFromClickhouse(projectIds);
|
||||
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
|
||||
}
|
||||
|
||||
// Step 2: Delete the organization from PostgreSQL (cascade will handle related records)
|
||||
console.log(chalk.yellow('Deleting organization from database...'));
|
||||
await deleteOrg(organization.id);
|
||||
console.log(chalk.green('✓ Organization deleted from database'));
|
||||
|
||||
console.log(chalk.green('\n✅ Organization deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting organization:'), error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
220
admin/src/commands/delete-user.ts
Normal file
220
admin/src/commands/delete-user.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface UserSearchItem {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function deleteUser() {
|
||||
console.log(chalk.red('\n🗑️ Delete User\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
|
||||
),
|
||||
);
|
||||
console.log('Loading users...\n');
|
||||
|
||||
const users = await db.user.findMany({
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
accounts: true,
|
||||
},
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.red('No users found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: UserSearchItem[] = users.map((user) => {
|
||||
const fullName =
|
||||
user.firstName || user.lastName
|
||||
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
|
||||
: '';
|
||||
const orgCount = user.membership.length;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
displayText: `${user.email} ${fullName ? chalk.gray(`(${fullName})`) : ''} ${chalk.cyan(`- ${orgCount} orgs`)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: UserSearchItem) =>
|
||||
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<UserSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedUser } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedUser',
|
||||
message: 'Search for a user to delete:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedUser: UserSearchItem };
|
||||
|
||||
// Fetch full user details
|
||||
const user = await db.user.findUnique({
|
||||
where: {
|
||||
id: selectedUser.id,
|
||||
},
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
accounts: true,
|
||||
createdOrganizations: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log(chalk.red('User not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display what will be deleted
|
||||
console.log(chalk.red('\n⚠️ YOU ARE ABOUT TO DELETE:\n'));
|
||||
console.log(` ${chalk.bold('User:')} ${user.email}`);
|
||||
if (user.firstName || user.lastName) {
|
||||
console.log(
|
||||
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
|
||||
);
|
||||
}
|
||||
console.log(` ${chalk.gray('ID:')} ${user.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
|
||||
);
|
||||
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
|
||||
|
||||
if (user.createdOrganizations.length > 0) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
|
||||
),
|
||||
);
|
||||
for (const org of user.createdOrganizations) {
|
||||
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
|
||||
}
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Note: These organizations will NOT be deleted, only the user reference.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (user.membership.length > 0) {
|
||||
console.log(
|
||||
chalk.red('\n Organizations where user will be removed from:'),
|
||||
);
|
||||
for (const member of user.membership) {
|
||||
console.log(
|
||||
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!',
|
||||
),
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
const { confirmFirst } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFirst) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation - type email
|
||||
const { confirmEmail } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirmEmail',
|
||||
message: `Type the user email "${user.email}" to confirm deletion:`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmEmail !== user.email) {
|
||||
console.log(chalk.red('\n❌ Email does not match. Deletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const { confirmFinal } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFinal) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.red('\n🗑️ Deleting user...\n'));
|
||||
|
||||
try {
|
||||
// Delete the user (cascade will handle related records like sessions, accounts, memberships)
|
||||
await db.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(chalk.green('\n✅ User deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting user:'), error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
104
admin/src/commands/lookup-client.ts
Normal file
104
admin/src/commands/lookup-client.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface ClientSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByClient() {
|
||||
console.log(chalk.blue('\n🔑 Lookup by Client ID\n'));
|
||||
console.log('Loading clients...\n');
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (clients.length === 0) {
|
||||
console.log(chalk.red('No clients found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: ClientSearchItem[] = clients.map((client) => ({
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
organizationId: client.organizationId,
|
||||
organizationName: client.organization.name,
|
||||
projectId: client.projectId,
|
||||
projectName: client.project?.name || null,
|
||||
displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ClientSearchItem) =>
|
||||
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<ClientSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedClient } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedClient',
|
||||
message: 'Search for a client:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedClient: ClientSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedClient.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization, {
|
||||
highlightProjectId: selectedClient.projectId || undefined,
|
||||
highlightClientId: selectedClient.id,
|
||||
});
|
||||
}
|
||||
|
||||
112
admin/src/commands/lookup-email.ts
Normal file
112
admin/src/commands/lookup-email.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface EmailSearchItem {
|
||||
email: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
role: string;
|
||||
userId: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByEmail() {
|
||||
console.log(chalk.blue('\n📧 Lookup by Email\n'));
|
||||
console.log('Loading members...\n');
|
||||
|
||||
const members = await db.member.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (members.length === 0) {
|
||||
console.log(chalk.red('No members found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by email (in case same email is in multiple orgs)
|
||||
const searchItems: EmailSearchItem[] = members.map((member) => {
|
||||
const email = member.user?.email || member.email || 'Unknown';
|
||||
const roleBadge =
|
||||
member.role === 'owner' ? '👑' : member.role === 'admin' ? '⭐' : '👤';
|
||||
|
||||
return {
|
||||
email,
|
||||
organizationId: member.organizationId,
|
||||
organizationName: member.organization.name,
|
||||
role: member.role,
|
||||
userId: member.userId,
|
||||
displayText: `${email} ${chalk.gray(`→ ${member.organization.name}`)} ${roleBadge}`,
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: EmailSearchItem) =>
|
||||
`${item.email} ${item.organizationName}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<EmailSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedMember } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedMember',
|
||||
message: 'Search for a member by email:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedMember: EmailSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedMember.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
|
||||
),
|
||||
);
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
88
admin/src/commands/lookup-org.ts
Normal file
88
admin/src/commands/lookup-org.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByOrg() {
|
||||
console.log(chalk.blue('\n🏢 Lookup by Organization\n'));
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
98
admin/src/commands/lookup-project.ts
Normal file
98
admin/src/commands/lookup-project.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface ProjectSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByProject() {
|
||||
console.log(chalk.blue('\n📊 Lookup by Project\n'));
|
||||
console.log('Loading projects...\n');
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log(chalk.red('No projects found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: ProjectSearchItem[] = projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId,
|
||||
organizationName: project.organization.name,
|
||||
displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ProjectSearchItem) =>
|
||||
`${item.organizationName} ${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<ProjectSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedProject } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedProject',
|
||||
message: 'Search for a project:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedProject: ProjectSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedProject.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization, {
|
||||
highlightProjectId: selectedProject.id,
|
||||
});
|
||||
}
|
||||
|
||||
206
admin/src/utils/display.ts
Normal file
206
admin/src/utils/display.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
Client,
|
||||
Member,
|
||||
Organization,
|
||||
Project,
|
||||
User,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type OrganizationWithDetails = Organization & {
|
||||
projects: (Project & {
|
||||
clients: Client[];
|
||||
})[];
|
||||
members: (Member & {
|
||||
user: User | null;
|
||||
})[];
|
||||
};
|
||||
|
||||
interface DisplayOptions {
|
||||
highlightProjectId?: string;
|
||||
highlightClientId?: string;
|
||||
}
|
||||
|
||||
export function displayOrganizationDetails(
|
||||
organization: OrganizationWithDetails,
|
||||
options: DisplayOptions = {},
|
||||
) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
|
||||
console.log(`${'='.repeat(80)}\n`);
|
||||
|
||||
// Organization Details
|
||||
console.log(chalk.bold('Organization Details:'));
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
|
||||
);
|
||||
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
|
||||
|
||||
// Subscription info
|
||||
if (organization.subscriptionStatus) {
|
||||
console.log(
|
||||
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
|
||||
);
|
||||
if (organization.subscriptionPriceId) {
|
||||
console.log(
|
||||
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionPeriodEventsLimit) {
|
||||
const usage = `${organization.subscriptionPeriodEventsCount}/${organization.subscriptionPeriodEventsLimit}`;
|
||||
const percentage =
|
||||
(organization.subscriptionPeriodEventsCount /
|
||||
organization.subscriptionPeriodEventsLimit) *
|
||||
100;
|
||||
const color =
|
||||
percentage > 90
|
||||
? chalk.red
|
||||
: percentage > 70
|
||||
? chalk.yellow
|
||||
: chalk.green;
|
||||
console.log(
|
||||
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionStartsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionEndsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Members
|
||||
console.log(`\n${chalk.bold('Members:')}`);
|
||||
if (organization.members.length === 0) {
|
||||
console.log(' No members');
|
||||
} else {
|
||||
for (const member of organization.members) {
|
||||
const roleBadge = getRoleBadge(member.role);
|
||||
console.log(
|
||||
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Projects
|
||||
console.log(`\n${chalk.bold(`Projects (${organization.projects.length}):`)}`);
|
||||
|
||||
if (organization.projects.length === 0) {
|
||||
console.log(' No projects');
|
||||
} else {
|
||||
for (const project of organization.projects) {
|
||||
const isHighlighted = project.id === options.highlightProjectId;
|
||||
const projectPrefix = isHighlighted ? chalk.yellow.bold('→ ') : ' ';
|
||||
|
||||
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${project.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
|
||||
);
|
||||
|
||||
if (project.domain) {
|
||||
console.log(` ${chalk.gray('Domain:')} ${project.domain}`);
|
||||
}
|
||||
|
||||
if (project.cors.length > 0) {
|
||||
console.log(` ${chalk.gray('CORS:')} ${project.cors.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
|
||||
);
|
||||
|
||||
if (project.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Clients for this project
|
||||
if (project.clients.length > 0) {
|
||||
console.log(` ${chalk.gray('Clients:')}`);
|
||||
for (const client of project.clients) {
|
||||
const isClientHighlighted = client.id === options.highlightClientId;
|
||||
const clientPrefix = isClientHighlighted
|
||||
? chalk.yellow.bold(' → ')
|
||||
: ' ';
|
||||
const typeBadge = getClientTypeBadge(client.type);
|
||||
|
||||
console.log(`${clientPrefix}${typeBadge} ${chalk.cyan(client.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${client.id}`);
|
||||
console.log(` ${chalk.gray('Type:')} ${client.type}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(` ${chalk.gray('Clients:')} None`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clients without projects (organization-level clients)
|
||||
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
||||
|
||||
console.log(`\n${'='.repeat(80)}\n`);
|
||||
}
|
||||
|
||||
function getSubscriptionStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return chalk.green(status);
|
||||
case 'trialing':
|
||||
return chalk.blue(status);
|
||||
case 'canceled':
|
||||
return chalk.red(status);
|
||||
case 'past_due':
|
||||
return chalk.yellow(status);
|
||||
default:
|
||||
return chalk.gray(status);
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadge(role: string): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return chalk.red.bold('👑');
|
||||
case 'admin':
|
||||
return chalk.yellow.bold('⭐');
|
||||
case 'member':
|
||||
return chalk.blue('👤');
|
||||
default:
|
||||
return chalk.gray('•');
|
||||
}
|
||||
}
|
||||
|
||||
function getClientTypeBadge(type: string): string {
|
||||
switch (type) {
|
||||
case 'root':
|
||||
return chalk.red.bold('[ROOT]');
|
||||
case 'write':
|
||||
return chalk.green('[WRITE]');
|
||||
case 'read':
|
||||
return chalk.blue('[READ]');
|
||||
default:
|
||||
return chalk.gray('[UNKNOWN]');
|
||||
}
|
||||
}
|
||||
12
admin/tsconfig.json
Normal file
12
admin/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tooling/typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.10",
|
||||
"@ai-sdk/openai": "^1.3.12",
|
||||
"@fastify/compress": "^8.0.1",
|
||||
"@fastify/compress": "^8.1.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/rate-limit": "^10.2.2",
|
||||
"@fastify/websocket": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@openpanel/auth": "workspace:^",
|
||||
"@openpanel/common": "workspace:*",
|
||||
@@ -35,13 +35,12 @@
|
||||
"@trpc/server": "^11.6.0",
|
||||
"ai": "^4.2.10",
|
||||
"fast-json-stable-hash": "^1.0.3",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "1.0.0-next.19",
|
||||
"groupmq": "1.1.0-next.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sqlstring": "^2.3.3",
|
||||
@@ -58,7 +57,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -7,6 +7,23 @@ 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 {
|
||||
@@ -14,6 +31,9 @@ async function main() {
|
||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
).then((res) => res.text());
|
||||
|
||||
const parsedData = yaml.load(data) as any[];
|
||||
const transformedBots = transformBots(parsedData);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/bots/bots.ts'),
|
||||
[
|
||||
@@ -21,11 +41,20 @@ async function main() {
|
||||
'',
|
||||
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
'',
|
||||
`const bots = ${JSON.stringify(yaml.load(data))} as const;`,
|
||||
`const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
|
||||
'export default bots;',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
|
||||
);
|
||||
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
||||
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
||||
console.log(` - ${includesCount} simple string matches (includes)`);
|
||||
console.log(` - ${regexCount} regex patterns`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ async function main() {
|
||||
properties: {
|
||||
hash: 'test-hash',
|
||||
'query.utm_source': 'test',
|
||||
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
|
||||
__user_agent: 'Mozilla/5.0 (Test)',
|
||||
},
|
||||
created_at: formatClickhouseDate(eventTime),
|
||||
country: 'US',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,47 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
export function isBot(ua: string) {
|
||||
const res = bots.find((bot) => {
|
||||
if (new RegExp(bot.regex).test(ua)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
// Pre-compile regex patterns at module load time
|
||||
const compiledBots = bots.map((bot) => {
|
||||
if ('regex' in bot) {
|
||||
return {
|
||||
...bot,
|
||||
compiledRegex: new RegExp(bot.regex),
|
||||
};
|
||||
}
|
||||
return bot;
|
||||
});
|
||||
|
||||
return {
|
||||
name: res.name,
|
||||
type: 'category' in res ? res.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
for (const bot of includesBots) {
|
||||
if (ua.includes(bot.includes)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check regex patterns (slower)
|
||||
for (const bot of regexBots) {
|
||||
if (bot.compiledRegex.test(ua)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { eventsGroupQueue } from '@openpanel/queue';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import type { PostEventPayload } from '@openpanel/sdk';
|
||||
|
||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||
@@ -21,8 +19,8 @@ export async function postEvent(
|
||||
request.timestamp,
|
||||
request.body,
|
||||
);
|
||||
const ip = getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -32,33 +30,22 @@ export async function postEvent(
|
||||
}
|
||||
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
@@ -66,7 +53,16 @@ export async function postEvent(
|
||||
? `${projectId}:${request.body?.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
await eventsGroupQueue.add({
|
||||
const jobId = [
|
||||
request.body.name,
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
data: {
|
||||
projectId,
|
||||
@@ -76,11 +72,13 @@ export async function postEvent(
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
reply.status(202).send('ok');
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
getEventsCountCached,
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartEvent, zChartInput } from '@openpanel/validation';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
|
||||
async function getProjectId(
|
||||
@@ -139,7 +139,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zChartInput
|
||||
const chartSchemeFull = zChartInputBase
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
interval: true,
|
||||
@@ -151,14 +151,27 @@ const chartSchemeFull = zChartInput
|
||||
.extend({
|
||||
project_id: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
events: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
// Backward compatibility - events will be migrated to series via preprocessing
|
||||
events: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function charts(
|
||||
@@ -179,9 +192,17 @@ export async function charts(
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { events, ...rest } = query.data;
|
||||
const { events, series, ...rest } = query.data;
|
||||
|
||||
return getChart({
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const eventSeries = (series ?? events ?? []).map((event: any) => ({
|
||||
...event,
|
||||
type: event.type ?? 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
}));
|
||||
|
||||
return ChartEngine.execute({
|
||||
...rest,
|
||||
startDate: rest.startDate
|
||||
? DateTime.fromISO(rest.startDate)
|
||||
@@ -194,11 +215,7 @@ export async function charts(
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss')
|
||||
: undefined,
|
||||
projectId,
|
||||
events: events.map((event) => ({
|
||||
...event,
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
})),
|
||||
series: eventSeries,
|
||||
chartType: 'linear',
|
||||
metric: 'sum',
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import superjson from 'superjson';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileByIdCached,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
@@ -92,10 +92,7 @@ export async function wsProjectEvents(
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileByIdCached(
|
||||
event.profileId,
|
||||
event.projectId,
|
||||
);
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
|
||||
@@ -4,9 +4,12 @@ import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import {
|
||||
DEFAULT_IP_HEADER_ORDER,
|
||||
getClientIpFromHeaders,
|
||||
} from '@openpanel/common/server/get-client-ip';
|
||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getCache, getRedisCache } from '@openpanel/redis';
|
||||
|
||||
interface GetFaviconParams {
|
||||
@@ -129,7 +132,7 @@ async function processImage(
|
||||
): Promise<Buffer> {
|
||||
// If it's an ICO file, just return it as-is (no conversion needed)
|
||||
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
||||
logger.info('Serving ICO file directly', {
|
||||
logger.debug('Serving ICO file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -137,7 +140,7 @@ async function processImage(
|
||||
}
|
||||
|
||||
if (originalUrl && isSvgFile(originalUrl, contentType)) {
|
||||
logger.info('Serving SVG file directly', {
|
||||
logger.debug('Serving SVG file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -146,7 +149,7 @@ async function processImage(
|
||||
|
||||
// If buffer isnt to big just return it as well
|
||||
if (buffer.length < 5000) {
|
||||
logger.info('Serving image directly without processing', {
|
||||
logger.debug('Serving image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -190,7 +193,7 @@ async function processOgImage(
|
||||
): Promise<Buffer> {
|
||||
// If buffer is small enough, return it as-is
|
||||
if (buffer.length < 10000) {
|
||||
logger.info('Serving OG image directly without processing', {
|
||||
logger.debug('Serving OG image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -394,12 +397,36 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||
}
|
||||
|
||||
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const ip = getClientIp(request);
|
||||
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(geo);
|
||||
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(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
@@ -17,41 +15,39 @@ export async function updateProfile(
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { profileId, properties, ...rest } = request.body;
|
||||
const payload = request.body;
|
||||
const projectId = request.client!.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
const ip = getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const uaInfo = parseUserAgent(ua, properties);
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertProfile({
|
||||
id: profileId,
|
||||
...payload,
|
||||
id: payload.profileId,
|
||||
isExternal: true,
|
||||
projectId,
|
||||
properties: {
|
||||
...(properties ?? {}),
|
||||
...(ip ? geo : {}),
|
||||
...uaInfo,
|
||||
...(payload.properties ?? {}),
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
|
||||
reply.status(202).send(profileId);
|
||||
reply.status(202).send(payload.profileId);
|
||||
}
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
@@ -66,18 +62,6 @@ export async function incrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
@@ -120,18 +104,6 @@ export async function decrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, assocPath, pathOr, pick } from 'ramda';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { eventsGroupQueue } from '@openpanel/queue';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
@@ -38,10 +37,10 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
}
|
||||
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity = path<IdentifyPayload>(
|
||||
['properties', '__identify'],
|
||||
body.payload,
|
||||
);
|
||||
const identity =
|
||||
'properties' in body.payload
|
||||
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
@@ -57,28 +56,38 @@ export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
) {
|
||||
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
|
||||
const userDefinedTimestamp = path<string>(
|
||||
['properties', '__timestamp'],
|
||||
payload,
|
||||
);
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
'properties' in payload
|
||||
? (payload?.properties?.__timestamp as string | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!userDefinedTimestamp) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
const clientTimestamp = new Date(userDefinedTimestamp);
|
||||
const clientTimestampNumber = clientTimestamp.getTime();
|
||||
|
||||
// Constants for time validation
|
||||
const ONE_MINUTE_MS = 60 * 1000;
|
||||
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
|
||||
|
||||
// Use safeTimestamp if invalid or more than 1 minute in the future
|
||||
if (
|
||||
Number.isNaN(clientTimestamp.getTime()) ||
|
||||
clientTimestamp > new Date(safeTimestamp)
|
||||
Number.isNaN(clientTimestampNumber) ||
|
||||
clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
|
||||
) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 1 hour
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
return {
|
||||
timestamp: clientTimestamp.toISOString(),
|
||||
isTimestampFromThePast: true,
|
||||
timestamp: clientTimestampNumber,
|
||||
isTimestampFromThePast,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,22 +99,33 @@ export async function handler(
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
path<string>(['properties', '__ip'], request.body.payload) ||
|
||||
getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
'properties' in request.body.payload &&
|
||||
request.body.payload.properties?.__ip
|
||||
? (request.body.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
if (!projectId) {
|
||||
reply.status(400).send({
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const profileId = identity?.profileId;
|
||||
const overrideDeviceId = (() => {
|
||||
const deviceId =
|
||||
'properties' in request.body.payload
|
||||
? request.body.payload.properties?.__deviceId
|
||||
: undefined;
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
@@ -116,14 +136,16 @@ export async function handler(
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
@@ -133,33 +155,7 @@ export async function handler(
|
||||
})
|
||||
: '';
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
];
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
@@ -174,23 +170,23 @@ export async function handler(
|
||||
);
|
||||
}
|
||||
|
||||
promises.push(
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geo = await getGeoLocation(ip);
|
||||
await identify({
|
||||
payload: request.body.payload,
|
||||
@@ -201,27 +197,13 @@ export async function handler(
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
reply.status(400).send({
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'increment': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
@@ -229,19 +211,6 @@ export async function handler(
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
@@ -249,12 +218,11 @@ export async function handler(
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reply.status(400).send({
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +245,7 @@ async function track({
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: string;
|
||||
timestamp: number;
|
||||
isTimestampFromThePast: boolean;
|
||||
}) {
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
@@ -286,8 +254,11 @@ async function track({
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
await eventsGroupQueue.add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
@@ -296,11 +267,13 @@ async function track({
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -323,8 +296,18 @@ async function identify({
|
||||
projectId,
|
||||
properties: {
|
||||
...(payload.properties ?? {}),
|
||||
...(geo ?? {}),
|
||||
...uaInfo,
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -400,3 +383,65 @@ async function decrement({
|
||||
isExternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
const ip = request.clientIp;
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Missing ip address');
|
||||
}
|
||||
|
||||
const ua = request.headers['user-agent'];
|
||||
if (!ua) {
|
||||
return reply.status(400).send('Missing header: user-agent');
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
28
apps/api/src/hooks/duplicate.hook.ts
Normal file
28
apps/api/src/hooks/duplicate.hook.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ip = req.clientIp;
|
||||
const origin = req.headers.origin;
|
||||
const clientId = req.headers['openpanel-client-id'];
|
||||
const shouldCheck = ip && origin && clientId;
|
||||
|
||||
const isDuplicate = shouldCheck
|
||||
? await isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload: req.body,
|
||||
projectId: clientId as string,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (isDuplicate) {
|
||||
return reply.status(200).send('Duplicate event');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function fixHook(request: FastifyRequest) {
|
||||
const ua = request.headers['user-agent'];
|
||||
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
|
||||
if (ua) {
|
||||
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
|
||||
const match = ua.match(regex);
|
||||
if (match) {
|
||||
request.headers['user-agent'] = ua.replace(
|
||||
regex,
|
||||
`OpenPanel/${match[1]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
HookHandlerDoneFunction,
|
||||
} from 'fastify';
|
||||
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function ipHook(request: FastifyRequest) {
|
||||
const ip = getClientIp(request);
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
|
||||
if (ip) {
|
||||
request.clientIp = ip;
|
||||
request.clientIpHeader = header;
|
||||
} else {
|
||||
request.clientIp = '';
|
||||
request.clientIpHeader = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, pick } from 'ramda';
|
||||
|
||||
@@ -37,12 +38,15 @@ export async function requestLoggingHook(
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
elapsed: reply.elapsedTime,
|
||||
clientIp: request.clientIp,
|
||||
clientIpHeader: request.clientIpHeader,
|
||||
headers: pick(
|
||||
[
|
||||
'openpanel-client-id',
|
||||
'openpanel-sdk-name',
|
||||
'openpanel-sdk-version',
|
||||
'user-agent',
|
||||
...DEFAULT_IP_HEADER_ORDER,
|
||||
],
|
||||
request.headers,
|
||||
),
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
liveness,
|
||||
readiness,
|
||||
} from './controllers/healthcheck.controller';
|
||||
import { fixHook } from './hooks/fix.hook';
|
||||
import { ipHook } from './hooks/ip.hook';
|
||||
import { requestIdHook } from './hooks/request-id.hook';
|
||||
import { requestLoggingHook } from './hooks/request-logging.hook';
|
||||
@@ -55,7 +54,8 @@ process.env.TZ = 'UTC';
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
client: IServiceClientWithProject | null;
|
||||
clientIp?: string;
|
||||
clientIp: string;
|
||||
clientIpHeader: string;
|
||||
timestamp?: number;
|
||||
session: SessionValidationResult;
|
||||
}
|
||||
@@ -125,7 +125,6 @@ const startServer = async () => {
|
||||
fastify.addHook('onRequest', requestIdHook);
|
||||
fastify.addHook('onRequest', timestampHook);
|
||||
fastify.addHook('onRequest', ipHook);
|
||||
fastify.addHook('onRequest', fixHook);
|
||||
fastify.addHook('onResponse', requestLoggingHook);
|
||||
|
||||
fastify.register(compress, {
|
||||
|
||||
@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { handler } from '@/controllers/track.controller';
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
@@ -29,6 +31,23 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/device-id',
|
||||
handler: fetchDeviceId,
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default trackRouter;
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
ch,
|
||||
clix,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||
import { verifyPassword } from '@openpanel/common/server';
|
||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
IProjectFilterIp,
|
||||
@@ -104,6 +105,22 @@ 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;
|
||||
}
|
||||
@@ -135,7 +152,13 @@ export async function validateSdkRequest(
|
||||
}
|
||||
|
||||
if (client.secret && clientSecret) {
|
||||
if (await verifyPassword(clientSecret, client.secret)) {
|
||||
const isVerified = await getCache(
|
||||
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||
60 * 5,
|
||||
async () => await verifyPassword(clientSecret, client.secret!),
|
||||
true,
|
||||
);
|
||||
if (isVerified) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getLock } from '@openpanel/redis';
|
||||
import fastJsonStableHash from 'fast-json-stable-hash';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
export async function isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
ip: string;
|
||||
origin: string;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
|
||||
`fastify:deduplicate:${fastJsonStableHash.hash(
|
||||
{
|
||||
...payload,
|
||||
ip,
|
||||
origin,
|
||||
projectId,
|
||||
},
|
||||
'md5',
|
||||
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkDuplicatedEvent({
|
||||
reply,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
reply: FastifyReply;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
if (await isDuplicatedEvent({ payload, projectId })) {
|
||||
reply.log.info('duplicated event', {
|
||||
payload,
|
||||
projectId,
|
||||
});
|
||||
reply.status(200).send('duplicated');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import requestIp from 'request-ip';
|
||||
|
||||
const ignore = ['127.0.0.1', '::1'];
|
||||
|
||||
export function getClientIp(req: FastifyRequest) {
|
||||
return requestIp.getClientIp(req);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ch, db } from '@openpanel/db';
|
||||
import {
|
||||
cronQueue,
|
||||
eventsGroupQueue,
|
||||
eventsGroupQueues,
|
||||
miscQueue,
|
||||
notificationQueue,
|
||||
sessionsQueue,
|
||||
@@ -71,7 +71,7 @@ export async function shutdown(
|
||||
// Step 6: Close Bull queues (graceful shutdown of queue state)
|
||||
try {
|
||||
await Promise.all([
|
||||
eventsGroupQueue.close(),
|
||||
...eventsGroupQueues.map((queue) => queue.close()),
|
||||
sessionsQueue.close(),
|
||||
cronQueue.close(),
|
||||
miscQueue.close(),
|
||||
|
||||
7
apps/public/app/api/[...op]/route.ts
Normal file
7
apps/public/app/api/[...op]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
createNextRouteHandler,
|
||||
createScriptHandler,
|
||||
} from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
export const GET = createScriptHandler();
|
||||
29
apps/public/app/api/headers/route.ts
Normal file
29
apps/public/app/api/headers/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const IP_HEADER_ORDER = [
|
||||
'cf-connecting-ip',
|
||||
'true-client-ip',
|
||||
'x-vercel-forwarded-for', // Vercel-specific, most reliable on Vercel
|
||||
'x-forwarded-for', // Standard proxy header (first IP in chain)
|
||||
'x-real-ip', // Alternative header
|
||||
'x-client-ip',
|
||||
'fastly-client-ip',
|
||||
'do-connecting-ip',
|
||||
'x-cluster-client-ip',
|
||||
];
|
||||
|
||||
export const GET = function POST(req: Request) {
|
||||
return NextResponse.json({
|
||||
headers: Object.fromEntries(req.headers),
|
||||
ips: IP_HEADER_ORDER.reduce(
|
||||
(acc, header) => {
|
||||
const value = req.headers.get(header);
|
||||
if (value) {
|
||||
acc[header] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -61,12 +61,9 @@ export default async function Layout({ children }: { children: ReactNode }) {
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
<Script
|
||||
defer
|
||||
src="http://localhost:3000/script.js"
|
||||
data-website-id="44d65df1-e9cb-4c2c-917d-4bf1c7850948"
|
||||
/>
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op"
|
||||
cdnUrl="/api/op/op1.js"
|
||||
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
trackAttributes
|
||||
trackScreenViews
|
||||
|
||||
79
apps/public/components/flow-step.tsx
Normal file
79
apps/public/components/flow-step.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FlowStepProps {
|
||||
step: number;
|
||||
actor: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
visitor: User,
|
||||
website: Globe,
|
||||
backend: Server,
|
||||
payment: CreditCard,
|
||||
success: CheckCircle,
|
||||
};
|
||||
|
||||
const iconColorMap = {
|
||||
visitor: 'text-blue-500',
|
||||
website: 'text-green-500',
|
||||
backend: 'text-purple-500',
|
||||
payment: 'text-yellow-500',
|
||||
success: 'text-green-600',
|
||||
};
|
||||
|
||||
const iconBorderColorMap = {
|
||||
visitor: 'border-blue-500',
|
||||
website: 'border-green-500',
|
||||
backend: 'border-purple-500',
|
||||
payment: 'border-yellow-500',
|
||||
success: 'border-green-600',
|
||||
};
|
||||
|
||||
export function FlowStep({
|
||||
step,
|
||||
actor,
|
||||
description,
|
||||
children,
|
||||
icon = 'visitor',
|
||||
isLast = false,
|
||||
}: FlowStepProps) {
|
||||
const Icon = iconMap[icon];
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 mb-4 min-w-0">
|
||||
{/* Step number and icon */}
|
||||
<div className="flex flex-col items-center flex-shrink-0">
|
||||
<div className="relative z-10 bg-background">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
|
||||
{step}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
|
||||
>
|
||||
<Icon
|
||||
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector line - extends from badge through content to next step */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pt-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
{children && <div className="mt-3 min-w-0">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,9 +88,7 @@ We built OpenPanel from the ground up with privacy at its heart—and with featu
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.op = window.op || function(...args) {
|
||||
(window.op.q = window.op.q || []).push(args);
|
||||
};
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
|
||||
104
apps/public/content/docs/(tracking)/adblockers.mdx
Normal file
104
apps/public/content/docs/(tracking)/adblockers.mdx
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Avoid adblockers with proxy
|
||||
description: Learn why adblockers block analytics and how to avoid it by proxying events.
|
||||
---
|
||||
|
||||
In this article we need to talk about adblockers, why they exist, how they work, and how to avoid them.
|
||||
|
||||
Adblockers' main purpose was initially to block ads, but they have since started to block tracking scripts as well. This is primarily for privacy reasons, and while we respect that, there are legitimate use cases for understanding your visitors. OpenPanel is designed to be a privacy-friendly, cookieless analytics tool that doesn't track users across sites, but generic blocklists often catch all analytics tools indiscriminately.
|
||||
|
||||
The best way to avoid adblockers is to proxy events via your own domain name. Adblockers generally cannot block requests to your own domain (first-party requests) without breaking the functionality of the site itself.
|
||||
|
||||
## Built-in Support
|
||||
|
||||
Today, our Next.js SDK and WordPress plugin have built-in support for proxying:
|
||||
- **WordPress**: Does it automatically.
|
||||
- **Next.js**: Easy to setup with a route handler.
|
||||
|
||||
## Implementing Proxying for Any Framework
|
||||
|
||||
If you are not using Next.js or WordPress, you can implement proxying in any backend framework. The key is to set up an API endpoint on your domain (e.g., `api.domain.com` or `domain.com/api`) that forwards requests to OpenPanel.
|
||||
|
||||
Below is an example of how to set up a proxy using a [Hono](https://hono.dev/) server. This implementation mimics the logic used in our Next.js SDK.
|
||||
|
||||
> You can always see how our Next.js implementation looks like in our [repository](https://github.com/Openpanel-dev/openpanel/blob/main/packages/sdks/nextjs/createNextRouteHandler.ts).
|
||||
|
||||
### Hono Example
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// 1. Proxy the script file
|
||||
app.get('/op1.js', async (c) => {
|
||||
const scriptUrl = 'https://openpanel.dev/op1.js'
|
||||
try {
|
||||
const res = await fetch(scriptUrl)
|
||||
const text = await res.text()
|
||||
|
||||
c.header('Content-Type', 'text/javascript')
|
||||
// Optional caching for 24 hours
|
||||
c.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=86400')
|
||||
return c.body(text)
|
||||
} catch (e) {
|
||||
return c.json({ error: 'Failed to fetch script' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Proxy the track event
|
||||
app.post('/track', async (c) => {
|
||||
const body = await c.req.json()
|
||||
|
||||
// Forward the client's IP address (be sure to pick correct IP based on your infra)
|
||||
const ip = c.req.header('cf-connecting-ip') ??
|
||||
c.req.header('x-forwarded-for')?.split(',')[0]
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('Origin', c.req.header('origin') ?? '')
|
||||
headers.set('User-Agent', c.req.header('user-agent') ?? '')
|
||||
headers.set('openpanel-client-id', c.req.header('openpanel-client-id') ?? '')
|
||||
|
||||
if (ip) {
|
||||
headers.set('openpanel-client-ip', ip)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.openpanel.dev/track', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return c.json(await res.text(), res.status)
|
||||
} catch (e) {
|
||||
return c.json(e, 500)
|
||||
}
|
||||
})
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
This script sets up two endpoints:
|
||||
1. `GET /op1.js`: Fetches the OpenPanel script and serves it from your domain.
|
||||
2. `POST /track`: Receives events from the frontend, adds necessary headers (User-Agent, Origin, Content-Type, openpanel-client-id, openpanel-client-ip), and forwards them to OpenPanel's API.
|
||||
|
||||
## Frontend Configuration
|
||||
|
||||
Once your proxy is running, you need to configure the OpenPanel script on your frontend to use your proxy endpoints instead of the default ones.
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
window.op('init', {
|
||||
apiUrl: 'https://api.domain.com'
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://api.domain.com/op1.js" defer async></script>
|
||||
```
|
||||
|
||||
By doing this, all requests are sent to your domain first, bypassing adblockers that look for third-party tracking domains.
|
||||
190
apps/public/content/docs/(tracking)/how-it-works.mdx
Normal file
190
apps/public/content/docs/(tracking)/how-it-works.mdx
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: How it works
|
||||
description: Understanding device IDs, session IDs, profile IDs, and event tracking
|
||||
---
|
||||
|
||||
## Device ID
|
||||
|
||||
A **device ID** is a unique identifier generated for each device/browser combination. It's calculated using a hash function that combines:
|
||||
|
||||
- **User Agent** (browser/client information)
|
||||
- **IP Address**
|
||||
- **Origin** (project ID)
|
||||
- **Salt** (a rotating secret key)
|
||||
|
||||
```typescript:packages/common/server/profileId.ts
|
||||
export function generateDeviceId({
|
||||
salt,
|
||||
ua,
|
||||
ip,
|
||||
origin,
|
||||
}: GenerateDeviceIdOptions) {
|
||||
return createHash(`${ua}:${ip}:${origin}:${salt}`, 16);
|
||||
}
|
||||
```
|
||||
|
||||
### Salt Rotation
|
||||
|
||||
The salt used for device ID generation rotates **daily at midnight** (UTC). This means:
|
||||
|
||||
- Device IDs remain consistent throughout a single day
|
||||
- Device IDs reset each day for privacy purposes
|
||||
- The system maintains both the current and previous day's salt to handle events that may arrive slightly after midnight
|
||||
|
||||
```typescript:apps/worker/src/jobs/cron.salt.ts
|
||||
// Salt rotation happens daily at midnight (pattern: '0 0 * * *')
|
||||
```
|
||||
|
||||
When the salt rotates, all device IDs change, effectively anonymizing tracking data on a daily basis while still allowing session continuity within a 24-hour period.
|
||||
|
||||
## Session ID
|
||||
|
||||
A **session** represents a continuous period of user activity. Sessions are used to group related events together and understand user behavior patterns.
|
||||
|
||||
### Session Duration
|
||||
|
||||
Sessions have a **30-minute timeout**. If no events are received for 30 minutes, the session automatically ends. Each new event resets this 30-minute timer.
|
||||
|
||||
```typescript:apps/worker/src/utils/session-handler.ts
|
||||
export const SESSION_TIMEOUT = 1000 * 60 * 30; // 30 minutes
|
||||
```
|
||||
|
||||
### Session Creation Rules
|
||||
|
||||
Sessions are **only created for client events**, not server events. This means:
|
||||
|
||||
- Events sent from browsers, mobile apps, or client-side SDKs will create sessions
|
||||
- Events sent from backend servers, scripts, or server-side SDKs will **not** create sessions
|
||||
- If you only track events from your backend, no sessions will be created
|
||||
|
||||
Additionally, sessions are **not created for events older than 15 minutes**. This prevents historical data imports from creating artificial sessions.
|
||||
|
||||
```typescript:apps/worker/src/jobs/events.incoming-event.ts
|
||||
// Sessions are not created if:
|
||||
// 1. The event is from a server (uaInfo.isServer === true)
|
||||
// 2. The timestamp is from the past (isTimestampFromThePast === true)
|
||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||
// Event is attached to existing session or no session
|
||||
}
|
||||
```
|
||||
|
||||
## Profile ID
|
||||
|
||||
A **profile ID** is a persistent identifier for a user across multiple devices and sessions. It allows you to track the same user across different browsers, devices, and time periods.
|
||||
|
||||
### Profile ID Assignment
|
||||
|
||||
If a `profileId` is provided when tracking an event, it will be used to identify the user. However, **if no `profileId` is provided, it defaults to the `deviceId`**.
|
||||
|
||||
This means:
|
||||
- Anonymous users (without a profile ID) are tracked by their device ID
|
||||
- Once you identify a user (by providing a profile ID), all their events will be associated with that profile
|
||||
- The same user can be tracked across multiple devices by using the same profile ID
|
||||
|
||||
```typescript:packages/db/src/services/event.service.ts
|
||||
// If no profileId is provided, it defaults to deviceId
|
||||
if (!payload.profileId && payload.deviceId) {
|
||||
payload.profileId = payload.deviceId;
|
||||
}
|
||||
```
|
||||
|
||||
## Client Events vs Server Events
|
||||
|
||||
OpenPanel distinguishes between **client events** and **server events** based on the User-Agent header.
|
||||
|
||||
### Client Events
|
||||
|
||||
Client events are sent from:
|
||||
- Web browsers (Chrome, Firefox, Safari, etc.)
|
||||
- Mobile apps using client-side SDKs
|
||||
- Any client that sends a browser-like User-Agent
|
||||
|
||||
Client events:
|
||||
- Create sessions
|
||||
- Generate device IDs
|
||||
- Support full session tracking
|
||||
|
||||
### Server Events
|
||||
|
||||
Server events are detected when the User-Agent matches server patterns, such as:
|
||||
- `Go-http-client/1.0`
|
||||
- `node-fetch/1.0`
|
||||
- Other single-name/version patterns (e.g., `LibraryName/1.0`)
|
||||
|
||||
Server events:
|
||||
- Do **not** create sessions
|
||||
- Are attached to existing sessions if available
|
||||
- Are useful for backend tracking without session management
|
||||
|
||||
```typescript:packages/common/server/parser-user-agent.ts
|
||||
// Server events are detected by patterns like "Go-http-client/1.0"
|
||||
function isServer(res: UAParser.IResult) {
|
||||
if (SINGLE_NAME_VERSION_REGEX.test(res.ua)) {
|
||||
return true;
|
||||
}
|
||||
// ... additional checks
|
||||
}
|
||||
```
|
||||
|
||||
The distinction is made in the event processing pipeline:
|
||||
|
||||
```typescript:apps/worker/src/jobs/events.incoming-event.ts
|
||||
const uaInfo = parseUserAgent(userAgent, properties);
|
||||
|
||||
// Only client events create sessions
|
||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||
// Server events or old events don't create new sessions
|
||||
}
|
||||
```
|
||||
|
||||
## Timestamps
|
||||
|
||||
Events can include custom timestamps to track when events actually occurred, rather than when they were received by the server.
|
||||
|
||||
### Setting Custom Timestamps
|
||||
|
||||
You can provide a custom timestamp using the `__timestamp` property in your event properties:
|
||||
|
||||
```javascript
|
||||
track('page_view', {
|
||||
__timestamp: '2024-01-15T10:30:00Z'
|
||||
});
|
||||
```
|
||||
|
||||
### Timestamp Validation
|
||||
|
||||
The system validates timestamps to prevent abuse and ensure data quality:
|
||||
|
||||
1. **Future timestamps**: If a timestamp is more than **1 minute in the future**, the server timestamp is used instead
|
||||
2. **Past timestamps**: If a timestamp is older than **15 minutes**, it's marked as `isTimestampFromThePast: true`
|
||||
|
||||
```typescript:apps/api/src/controllers/track.controller.ts
|
||||
// Timestamp validation logic
|
||||
const ONE_MINUTE_MS = 60 * 1000;
|
||||
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
|
||||
|
||||
// Future check: more than 1 minute ahead
|
||||
if (clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// Past check: older than 15 minutes
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
```
|
||||
|
||||
### Timestamp Impact on Sessions
|
||||
|
||||
**Important**: Events with timestamps older than 15 minutes (`isTimestampFromThePast: true`) will **not create new sessions**. This prevents historical data imports from creating artificial sessions in your analytics.
|
||||
|
||||
```typescript:apps/worker/src/jobs/events.incoming-event.ts
|
||||
// Events from the past don't create sessions
|
||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||
// Attach to existing session or track without session
|
||||
}
|
||||
```
|
||||
|
||||
This ensures that:
|
||||
- Real-time tracking creates proper sessions
|
||||
- Historical data imports don't interfere with session analytics
|
||||
- Backdated events are still tracked but don't affect session metrics
|
||||
3
apps/public/content/docs/(tracking)/meta.json
Normal file
3
apps/public/content/docs/(tracking)/meta.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": ["sdks", "how-it-works", "..."]
|
||||
}
|
||||
364
apps/public/content/docs/(tracking)/revenue-tracking.mdx
Normal file
364
apps/public/content/docs/(tracking)/revenue-tracking.mdx
Normal file
@@ -0,0 +1,364 @@
|
||||
---
|
||||
title: Revenue tracking
|
||||
description: Learn how to easily track your revenue with OpenPanel and how to get it shown directly in your dashboard.
|
||||
---
|
||||
|
||||
import { FlowStep } from '@/components/flow-step';
|
||||
|
||||
Revenue tracking is a great way to get a better understanding of what your best revenue source is. On this page we'll break down how to get started.
|
||||
|
||||
Before we start, we need to know some fundamentals about how OpenPanel and your payment provider work and how we can link a payment to a visitor.
|
||||
|
||||
### Payment providers
|
||||
|
||||
Usually, you create your checkout from your backend, which then returns a payment link that your visitor will be redirected to. When creating the checkout link, you usually add additional fields such as metadata, customer information, or order details. We'll add the device ID information in this metadata field to be able to link your payment to a visitor.
|
||||
|
||||
### OpenPanel
|
||||
|
||||
OpenPanel is a cookieless analytics tool that identifies visitors using a `device_id`. To link a payment to a visitor, you need to capture their `device_id` before they complete checkout. This `device_id` will be stored in your payment provider's metadata, and when the payment webhook arrives, you'll use it to associate the revenue with the correct visitor.
|
||||
|
||||
## Some typical flows
|
||||
|
||||
- [Revenue tracking from your backend (not identified)](#revenue-tracking-from-your-backend-webhook)
|
||||
- [Revenue tracking from your backend (identified)](#revenue-tracking-from-your-backend-webhook-identified)
|
||||
- [Revenue tracking from your frontend](#revenue-tracking-from-your-frontend)
|
||||
- [Revenue tracking without linking it to a identity or device](#revenue-tracking-without-linking-it-to-an-identity-or-device)
|
||||
|
||||
### Revenue tracking from your backend (webhook)
|
||||
|
||||
This is the most common flow and most secure one. Your backend receives webhooks from your payment provider, and here is the best opportunity to do revenue tracking.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
|
||||
When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
|
||||
|
||||
```javascript
|
||||
fetch('https://domain.com/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now
|
||||
// ... other checkout data
|
||||
}),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Handle checkout response, e.g., redirect to payment link
|
||||
window.location.href = data.paymentUrl;
|
||||
})
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={4} actor="Your backend" description="Will generate and return the checkout URL" icon="backend">
|
||||
```javascript
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { deviceId, amount, currency } = await req.json();
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: currency,
|
||||
product_data: { name: 'Product Name' },
|
||||
unit_amount: amount * 100, // Convert to cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
metadata: {
|
||||
deviceId: deviceId, // ✅ since deviceId is here we can link the payment now
|
||||
},
|
||||
success_url: 'https://domain.com/success',
|
||||
cancel_url: 'https://domain.com/cancel',
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
paymentUrl: session.url,
|
||||
});
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={5} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
|
||||
|
||||
<FlowStep step={6} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={7} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend">
|
||||
```javascript
|
||||
export async function POST(req: Request) {
|
||||
const event = await req.json();
|
||||
|
||||
// Stripe sends events with type and data.object structure
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const deviceId = session.metadata.deviceId;
|
||||
const amount = session.amount_total;
|
||||
|
||||
op.revenue(amount, { deviceId }); // ✅ since deviceId is here we can link the payment now
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={8} actor="Visitor" description="Redirected to your website with payment confirmation" icon="success" isLast />
|
||||
|
||||
---
|
||||
|
||||
### Revenue tracking from your backend (webhook) - Identified users
|
||||
|
||||
If your visitors are identified (meaning you have called `identify` with a `profileId`), this process gets a bit easier. You don't need to pass the `deviceId` when creating your checkout, and you only need to provide the `profileId` (in backend) to the revenue call.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Your website" description="Identifies the visitor" icon="website">
|
||||
When a visitor logs in or is identified, call `op.identify()` with their unique `profileId`.
|
||||
|
||||
```javascript
|
||||
op.identify({
|
||||
profileId: 'user-123', // Unique identifier for this user
|
||||
email: 'user@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={3} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={4} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
|
||||
Since the visitor is already identified, you don't need to fetch or pass the `deviceId`. Just send the checkout data.
|
||||
|
||||
```javascript
|
||||
fetch('https://domain.com/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// ✅ No deviceId needed - user is already identified
|
||||
// ... other checkout data
|
||||
}),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Handle checkout response, e.g., redirect to payment link
|
||||
window.location.href = data.paymentUrl;
|
||||
})
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={5} actor="Your backend" description="Will generate and return the checkout URL" icon="backend">
|
||||
Since the user is authenticated, you can get their `profileId` from the session and store it in metadata for easy retrieval in the webhook.
|
||||
|
||||
```javascript
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { amount, currency } = await req.json();
|
||||
|
||||
// Get profileId from authenticated session
|
||||
const profileId = req.session.userId; // or however you get the user ID
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: currency,
|
||||
product_data: { name: 'Product Name' },
|
||||
unit_amount: amount * 100, // Convert to cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
metadata: {
|
||||
profileId: profileId, // ✅ Store profileId instead of deviceId
|
||||
},
|
||||
success_url: 'https://domain.com/success',
|
||||
cancel_url: 'https://domain.com/cancel',
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
paymentUrl: session.url,
|
||||
});
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={6} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
|
||||
|
||||
<FlowStep step={7} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={8} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend">
|
||||
In the webhook handler, retrieve the `profileId` from the session metadata.
|
||||
|
||||
```javascript
|
||||
export async function POST(req: Request) {
|
||||
const event = await req.json();
|
||||
|
||||
// Stripe sends events with type and data.object structure
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const profileId = session.metadata.profileId;
|
||||
const amount = session.amount_total;
|
||||
|
||||
op.revenue(amount, { profileId }); // ✅ Use profileId instead of deviceId
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={9} actor="Visitor" description="Redirected to your website with payment confirmation" icon="success" isLast />
|
||||
|
||||
---
|
||||
|
||||
### Revenue tracking from your frontend
|
||||
|
||||
This flow tracks revenue directly from your frontend. Since the success page doesn't have access to the payment amount (payment happens on Stripe's side), we track revenue when checkout is initiated and then confirm it on the success page.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Visitor" description="Clicks to purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={3} actor="Your website" description="Track revenue when checkout is initiated" icon="website">
|
||||
When the visitor clicks the checkout button, track the revenue with the amount.
|
||||
|
||||
```javascript
|
||||
async function handleCheckout() {
|
||||
const amount = 2000; // Amount in cents
|
||||
|
||||
// Create a pending revenue (stored in sessionStorage)
|
||||
op.pendingRevenue(amount, {
|
||||
productId: '123',
|
||||
// ... other properties
|
||||
});
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = 'https://checkout.stripe.com/...';
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={4} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
|
||||
|
||||
<FlowStep step={5} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={6} actor="Visitor" description="Redirected back to your success page" icon="visitor" />
|
||||
|
||||
<FlowStep step={7} actor="Your website" description="Confirm/flush the revenue on success page" icon="website" isLast>
|
||||
On your success page, flush all pending revenue events. This will send all pending revenues tracked during checkout and clear them from sessionStorage.
|
||||
|
||||
```javascript
|
||||
// Flush all pending revenues
|
||||
await op.flushRevenue();
|
||||
|
||||
// Or if you want to clear without sending (e.g., payment was cancelled)
|
||||
op.clearRevenue();
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
#### Pros:
|
||||
- Quick way to get going
|
||||
- No backend required
|
||||
- Can track revenue immediately when checkout starts
|
||||
|
||||
#### Cons:
|
||||
- Less accurate (visitor might not complete payment)
|
||||
- Less "secure" meaning anyone could post revenue data
|
||||
|
||||
---
|
||||
|
||||
### Revenue tracking without linking it to an identity or device
|
||||
|
||||
If you simply want to track revenue totals without linking payments to specific visitors or devices, you can call `op.revenue()` directly from your backend without providing a `deviceId` or `profileId`. This is the simplest approach and works well when you only need aggregate revenue data.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={3} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend" isLast>
|
||||
Simply call `op.revenue()` with the amount. No `deviceId` or `profileId` is needed.
|
||||
|
||||
```javascript
|
||||
export async function POST(req: Request) {
|
||||
const event = await req.json();
|
||||
|
||||
// Stripe sends events with type and data.object structure
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const amount = session.amount_total;
|
||||
|
||||
op.revenue(amount); // ✅ Simple revenue tracking without linking to a visitor
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
#### Pros:
|
||||
- Simplest implementation
|
||||
- No need to capture or pass device IDs
|
||||
- Works well for aggregate revenue tracking
|
||||
|
||||
#### Cons:
|
||||
- **You can't dive deeper into where this revenue came from.** For instance, you won't be able to see which source generates the best revenue, which campaigns are most profitable, or which visitors are your highest-value customers.
|
||||
- Revenue events won't be linked to specific user journeys or sessions
|
||||
|
||||
## Available methods
|
||||
|
||||
### Revenue
|
||||
|
||||
The revenue method will create a revenue event. It's important to know that this method will not work if your OpenPanel instance didn't receive a client secret (for security reasons). You can enable frontend revenue tracking within your project settings.
|
||||
|
||||
```javascript
|
||||
op.revenue(amount: number, properties: Record<string, unknown>): Promise<void>
|
||||
```
|
||||
|
||||
### Add a pending revenue
|
||||
|
||||
This method will create a pending revenue item and store it in sessionStorage. It will not be sent to OpenPanel until you call `flushRevenue()`. Pending revenues are automatically restored from sessionStorage when the SDK initializes.
|
||||
|
||||
```javascript
|
||||
op.pendingRevenue(amount: number, properties?: Record<string, unknown>): void
|
||||
```
|
||||
|
||||
### Send all pending revenues
|
||||
|
||||
This method will send all pending revenues to OpenPanel and then clear them from sessionStorage. Returns a Promise that resolves when all revenues have been sent.
|
||||
|
||||
```javascript
|
||||
await op.flushRevenue(): Promise<void>
|
||||
```
|
||||
|
||||
### Clear any pending revenue
|
||||
|
||||
This method will clear all pending revenues from memory and sessionStorage without sending them to OpenPanel. Useful if a payment was cancelled or you want to discard pending revenues.
|
||||
|
||||
```javascript
|
||||
op.clearRevenue(): void
|
||||
```
|
||||
|
||||
### Fetch your current users device id
|
||||
|
||||
```javascript
|
||||
op.fetchDeviceId(): Promise<string>
|
||||
```
|
||||
20
apps/public/content/docs/(tracking)/sdks/meta.json
Normal file
20
apps/public/content/docs/(tracking)/sdks/meta.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "SDKs",
|
||||
"pages": [
|
||||
"script",
|
||||
"web",
|
||||
"javascript",
|
||||
"nextjs",
|
||||
"react",
|
||||
"vue",
|
||||
"astro",
|
||||
"remix",
|
||||
"express",
|
||||
"python",
|
||||
"react-native",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"..."
|
||||
],
|
||||
"defaultOpen": false
|
||||
}
|
||||
@@ -273,19 +273,21 @@ export function GET() {
|
||||
|
||||
### Proxy events
|
||||
|
||||
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains.
|
||||
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. You'll also need to either host our tracking script or you can use `createScriptHandler` function which proxies this as well.
|
||||
|
||||
```typescript title="/app/api/[...op]/route.ts"
|
||||
import { createNextRouteHandler } from '@openpanel/nextjs/server';
|
||||
import { createNextRouteHandler, createScriptHandler } from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
export const GET = createScriptHandler()
|
||||
```
|
||||
|
||||
Remember to change the `apiUrl` in the `OpenPanelComponent` to your own server.
|
||||
Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server.
|
||||
|
||||
```tsx
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op" // [!code highlight]
|
||||
cdnUrl="/api/op/op1.js" // [!code highlight]
|
||||
clientId="your-client-id"
|
||||
trackScreenViews={true}
|
||||
/>
|
||||
@@ -15,7 +15,7 @@ Just insert this snippet and replace `YOUR_CLIENT_ID` with your client id.
|
||||
|
||||
```html title="index.html" /clientId: 'YOUR_CLIENT_ID'/
|
||||
<script>
|
||||
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
47
apps/public/content/docs/get-started/identify-users.mdx
Normal file
47
apps/public/content/docs/get-started/identify-users.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Identify Users
|
||||
description: Connect anonymous events to specific users.
|
||||
---
|
||||
|
||||
By default, OpenPanel tracks visitors anonymously. To connect these events to a specific user in your database, you need to identify them.
|
||||
|
||||
## How it works
|
||||
|
||||
When a user logs in or signs up, you should call the `identify` method. This associates their current session and all future events with their unique ID from your system.
|
||||
|
||||
```javascript
|
||||
op.identify({
|
||||
profileId: 'user_123'
|
||||
});
|
||||
```
|
||||
|
||||
## Adding user traits
|
||||
|
||||
You can also pass user traits (like name, email, or plan type) when you identify them. These traits will appear in the user's profile in your dashboard.
|
||||
|
||||
```javascript
|
||||
op.identify({
|
||||
profileId: 'user_123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
email: 'jane@example.com',
|
||||
company: 'Acme Inc'
|
||||
});
|
||||
```
|
||||
|
||||
### Standard traits
|
||||
|
||||
We recommend using these standard keys for common user information so they display correctly in the OpenPanel dashboard:
|
||||
|
||||
- `firstName`
|
||||
- `lastName`
|
||||
- `email`
|
||||
- `phone`
|
||||
- `avatar`
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Call on login**: Always identify the user immediately after they log in.
|
||||
2. **Call on update**: If a user updates their profile, call identify again with the new information.
|
||||
3. **Unique IDs**: Use a stable, unique ID from your database (like a UUID) rather than an email address or username that might change.
|
||||
|
||||
81
apps/public/content/docs/get-started/install-openpanel.mdx
Normal file
81
apps/public/content/docs/get-started/install-openpanel.mdx
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Install OpenPanel
|
||||
description: Get started with OpenPanel in less than 2 minutes.
|
||||
---
|
||||
|
||||
import { Cards, Card } from 'fumadocs-ui/components/card';
|
||||
import { Code, Globe, Layout, Smartphone, FileJson } from 'lucide-react';
|
||||
|
||||
The quickest way to get started with OpenPanel is to use our Web SDK. It works with any website.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Simply add this script tag to your website's `<head>` section.
|
||||
|
||||
```html title="index.html"
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
```
|
||||
|
||||
That's it! OpenPanel will now automatically track:
|
||||
- Page views
|
||||
- Visit duration
|
||||
- Referrers
|
||||
- Device and browser information
|
||||
- Location
|
||||
|
||||
## Using a Framework?
|
||||
|
||||
If you are using a specific framework or platform, we have dedicated SDKs that provide a better developer experience.
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
href="/docs/sdks/nextjs"
|
||||
title="Next.js"
|
||||
icon={<Globe />}
|
||||
description="Optimized for App Router and Server Components"
|
||||
/>
|
||||
<Card
|
||||
href="/docs/sdks/react"
|
||||
title="React"
|
||||
icon={<Layout />}
|
||||
description="Components and hooks for React applications"
|
||||
/>
|
||||
<Card
|
||||
href="/docs/sdks/vue"
|
||||
title="Vue"
|
||||
icon={<Layout />}
|
||||
description="Integration for Vue.js applications"
|
||||
/>
|
||||
<Card
|
||||
href="/docs/sdks/javascript"
|
||||
title="JavaScript"
|
||||
icon={<FileJson />}
|
||||
description="Universal JavaScript/TypeScript SDK"
|
||||
/>
|
||||
<Card
|
||||
href="/docs/sdks/react-native"
|
||||
title="React Native"
|
||||
icon={<Smartphone />}
|
||||
description="Track mobile apps with React Native"
|
||||
/>
|
||||
<Card
|
||||
href="/docs/sdks/python"
|
||||
title="Python"
|
||||
icon={<Code />}
|
||||
description="Server-side tracking for Python"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
## Explore all SDKs
|
||||
|
||||
We support many more platforms. Check out our [SDKs Overview](/docs/sdks) for the full list.
|
||||
|
||||
8
apps/public/content/docs/get-started/meta.json
Normal file
8
apps/public/content/docs/get-started/meta.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"pages": [
|
||||
"install-openpanel",
|
||||
"track-events",
|
||||
"identify-users",
|
||||
"revenue-tracking"
|
||||
]
|
||||
}
|
||||
48
apps/public/content/docs/get-started/track-events.mdx
Normal file
48
apps/public/content/docs/get-started/track-events.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Track Events
|
||||
description: Learn how to track custom events to measure user actions.
|
||||
---
|
||||
|
||||
Events are the core of OpenPanel. They allow you to measure specific actions users take on your site, like clicking a button, submitting a form, or completing a purchase.
|
||||
|
||||
## Tracking an event
|
||||
|
||||
To track an event, simply call the `track` method with an event name.
|
||||
|
||||
```javascript
|
||||
op.track('button_clicked');
|
||||
```
|
||||
|
||||
## Adding properties
|
||||
|
||||
You can add additional context to your events by passing a properties object. This helps you understand the details of the interaction.
|
||||
|
||||
```javascript
|
||||
op.track('signup_button_clicked', {
|
||||
location: 'header',
|
||||
color: 'blue',
|
||||
variant: 'primary'
|
||||
});
|
||||
```
|
||||
|
||||
### Common property types
|
||||
|
||||
- **Strings**: Text values like names, categories, or IDs.
|
||||
- **Numbers**: Numeric values like price, quantity, or score.
|
||||
- **Booleans**: True or false values.
|
||||
|
||||
## Using Data Attributes
|
||||
|
||||
If you prefer not to write JavaScript, you can use data attributes to track clicks automatically.
|
||||
|
||||
```html
|
||||
<button
|
||||
data-track="signup_clicked"
|
||||
data-location="header"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
```
|
||||
|
||||
When a user clicks this button, OpenPanel will automatically track a `signup_clicked` event with the property `location: 'header'`.
|
||||
|
||||
@@ -1,111 +1,83 @@
|
||||
---
|
||||
title: Introduction to OpenPanel
|
||||
description: Get started with OpenPanel's powerful analytics platform that combines the best of product and web analytics in one simple solution.
|
||||
title: What is OpenPanel?
|
||||
description: OpenPanel is an open-source web and product analytics platform that combines the power of Mixpanel with the ease of Plausible. Whether you're tracking website visitors or analyzing user behavior in your app, OpenPanel provides the insights you need without the complexity.
|
||||
---
|
||||
|
||||
## What is OpenPanel?
|
||||
import { UserIcon,HardDriveIcon } from 'lucide-react'
|
||||
|
||||
OpenPanel is an open-source analytics platform that combines product analytics (like Mixpanel) with web analytics (like Plausible) into one simple solution. Whether you're tracking website visitors or analyzing user behavior in your app, OpenPanel provides the insights you need without the complexity.
|
||||
## ✨ Key Features
|
||||
|
||||
## Key Features
|
||||
- **🔍 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
|
||||
- **🛠️ Custom Dashboards**: Flexible chart creation and data visualization
|
||||
- **📱 Multi-Platform**: Web, mobile (iOS/Android), and server-side tracking
|
||||
|
||||
### Web Analytics
|
||||
- **Real-time data**: See visitor activity as it happens
|
||||
- **Traffic sources**: Understand where your visitors come from
|
||||
- **Geographic insights**: Track visitor locations and trends
|
||||
- **Device analytics**: Monitor usage across different devices
|
||||
- **Page performance**: Analyze your most visited pages
|
||||
## 📊 Analytics Platform Comparison
|
||||
|
||||
### Product Analytics
|
||||
- **Event tracking**: Monitor user actions and interactions
|
||||
- **User profiles**: Build detailed user journey insights
|
||||
- **Funnels**: Analyze conversion paths
|
||||
- **Retention**: Track user engagement over time
|
||||
- **Custom properties**: Add context to your events
|
||||
| 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 | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
## Getting Started
|
||||
✅* 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
|
||||
|
||||
1. **Installation**: Choose your preferred method:
|
||||
- [Script tag](/docs/sdks/script) - Quickest way to get started
|
||||
- [Web SDK](/docs/sdks/web) - For more control and TypeScript support
|
||||
- [React](/docs/sdks/react) - Native React integration
|
||||
- [Next.js](/docs/sdks/nextjs) - Optimized for Next.js apps
|
||||
## 🚀 Quick Start
|
||||
|
||||
2. **Core Methods**:
|
||||
```js
|
||||
// Track an event
|
||||
track('button_clicked', {
|
||||
buttonId: 'signup',
|
||||
location: 'header'
|
||||
});
|
||||
Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel.
|
||||
|
||||
// Identify a user
|
||||
identify({
|
||||
profileId: 'user123',
|
||||
email: 'user@example.com',
|
||||
firstName: 'John'
|
||||
});
|
||||
```
|
||||
<Cards>
|
||||
<Card
|
||||
href="https://dashboard.openpanel.dev/onboarding"
|
||||
title="Create an account"
|
||||
icon={<UserIcon />}
|
||||
description="Create your account and workspace"
|
||||
/>
|
||||
<Card
|
||||
href="/docs/self-hosting/self-hosting"
|
||||
title="Self-hosted OpenPanel"
|
||||
icon={<HardDriveIcon />}
|
||||
description="Get full control and start self-host"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
## Privacy First
|
||||
1. **[Install OpenPanel](/docs/get-started/install-openpanel)** - Add the script tag or use one of our SDKs
|
||||
2. **[Track Events](/docs/get-started/track-events)** - Start measuring user actions
|
||||
3. **[Identify Users](/docs/get-started/identify-users)** - Connect events to specific users
|
||||
4. **[Track Revenue](/docs/get-started/revenue-tracking)** - Monitor purchases and subscriptions
|
||||
|
||||
## 🔒 Privacy First
|
||||
|
||||
OpenPanel is built with privacy in mind:
|
||||
- No cookies required
|
||||
- GDPR and CCPA compliant
|
||||
- Self-hosting option available
|
||||
- Full control over your data
|
||||
- **No cookies required** - Cookieless tracking by default
|
||||
- **GDPR and CCPA compliant** - Built for privacy regulations
|
||||
- **Self-hosting option** - Full control over your data
|
||||
- **Transparent data handling** - You own your data
|
||||
|
||||
## Open Source
|
||||
## 🌐 Open Source
|
||||
|
||||
OpenPanel is fully open-source and available on [GitHub](https://github.com/Openpanel-dev/openpanel). We believe in transparency and community-driven development.
|
||||
|
||||
## Need Help?
|
||||
## 💬 Need Help?
|
||||
|
||||
- Join our [Discord community](https://go.openpanel.dev/discord)
|
||||
- Check our [GitHub issues](https://github.com/Openpanel-dev/openpanel/issues)
|
||||
- Email us at [hello@openpanel.dev](mailto:hello@openpanel.dev)
|
||||
|
||||
## Core Methods
|
||||
|
||||
### Set global properties
|
||||
|
||||
Sets global properties that will be included with every subsequent event.
|
||||
|
||||
### Track
|
||||
|
||||
Tracks a custom event with the given name and optional properties.
|
||||
|
||||
#### Tips
|
||||
|
||||
You can identify the user directly with this method.
|
||||
|
||||
```js title="Example shown in JavaScript"
|
||||
track('your_event_name', {
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
// reserved property name
|
||||
__identify: {
|
||||
profileId: 'your_user_id', // required
|
||||
email: 'your_user_email',
|
||||
firstName: 'your_user_name',
|
||||
lastName: 'your_user_name',
|
||||
avatar: 'your_user_avatar',
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Identify
|
||||
|
||||
Associates the current user with a unique identifier and optional traits.
|
||||
|
||||
### Increment
|
||||
|
||||
Increments a numeric property for a user.
|
||||
|
||||
### Decrement
|
||||
|
||||
Decrements a numeric property for a user.
|
||||
|
||||
### Clear
|
||||
|
||||
Clears the current user identifier and ends the session.
|
||||
|
||||
16
apps/public/content/docs/meta.json
Normal file
16
apps/public/content/docs/meta.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pages": [
|
||||
"---Introduction---",
|
||||
"index",
|
||||
"---Get started---",
|
||||
"...get-started",
|
||||
"---Tracking---",
|
||||
"...(tracking)",
|
||||
"---API---",
|
||||
"...api",
|
||||
"---Self-hosting---",
|
||||
"...self-hosting",
|
||||
"---Migration---",
|
||||
"...migration"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"title": "SDKs",
|
||||
"pages": ["script", "web", "javascript", "nextjs", "react", "vue", "astro", "remix", "express", "python", "react-native", "swift", "kotlin"],
|
||||
"defaultOpen": true
|
||||
}
|
||||
@@ -76,7 +76,7 @@ The path should be `/api` and the domain should be your domain.
|
||||
|
||||
```html title="index.html"
|
||||
<script>
|
||||
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
window.op('init', {
|
||||
apiUrl: 'https://your-domain.com/api', // [!code highlight]
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm with-env next dev",
|
||||
"dev": "pnpm with-env next dev --port 3001",
|
||||
"build": "pnpm with-env next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
@@ -13,7 +13,8 @@
|
||||
"dependencies": {
|
||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||
"@number-flow/react": "0.3.5",
|
||||
"@openpanel/nextjs": "^1.0.5",
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/nextjs": "^1.0.15",
|
||||
"@openpanel/payments": "workspace:^",
|
||||
"@openpanel/sdk-info": "workspace:^",
|
||||
"@openstatus/react": "0.0.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.5",
|
||||
"@clickhouse/client": "^1.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -104,7 +103,6 @@
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.476.0",
|
||||
"mathjs": "^12.3.2",
|
||||
"mitt": "^3.0.1",
|
||||
"nuqs": "^2.5.2",
|
||||
"prisma-error-enum": "^0.1.3",
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as d3 from 'd3';
|
||||
|
||||
export function ChartSSR({
|
||||
data,
|
||||
dots = false,
|
||||
color = 'blue',
|
||||
}: {
|
||||
dots?: boolean;
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
data: { value: number; date: Date }[];
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xScale = d3
|
||||
.scaleTime()
|
||||
.domain([data[0]!.date, data[data.length - 1]!.date])
|
||||
.range([0, 100]);
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
|
||||
.range([100, 0]);
|
||||
|
||||
const line = d3
|
||||
.line<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y((d) => yScale(d.value));
|
||||
|
||||
const area = d3
|
||||
.area<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y0(yScale(0))
|
||||
.y1((d) => yScale(d.value));
|
||||
|
||||
const pathLine = line(data);
|
||||
const pathArea = area(data);
|
||||
|
||||
if (!pathLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gradientId = `gradient-${color}`;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Chart area */}
|
||||
<svg className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="100%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
||||
<stop offset="50%" stopColor={color} stopOpacity={0.05} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Gradient area */}
|
||||
{pathArea && (
|
||||
<path
|
||||
d={pathArea}
|
||||
fill={`url(#${gradientId})`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathLine}
|
||||
fill="none"
|
||||
className={
|
||||
color === 'green'
|
||||
? 'text-green-600'
|
||||
: color === 'red'
|
||||
? 'text-red-600'
|
||||
: 'text-highlight'
|
||||
}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Circles */}
|
||||
{dots &&
|
||||
data.map((d) => (
|
||||
<path
|
||||
key={d.date.toString()}
|
||||
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-gray-400"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,43 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { createContext, useContext as useBaseContext } from 'react';
|
||||
|
||||
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
||||
|
||||
export const ChartTooltipContainer = ({
|
||||
children,
|
||||
}: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChartTooltipHeader = ({
|
||||
children,
|
||||
}: { children: React.ReactNode }) => {
|
||||
return <div className="flex justify-between gap-8">{children}</div>;
|
||||
};
|
||||
|
||||
export const ChartTooltipItem = ({
|
||||
children,
|
||||
color,
|
||||
className,
|
||||
innerClassName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
color: string;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<div className="w-[3px] rounded-full" style={{ background: color }} />
|
||||
<div className={cn('col flex-1 gap-1', innerClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function createChartTooltip<
|
||||
PropsFromTooltip,
|
||||
PropsFromContext extends Record<string, unknown>,
|
||||
@@ -31,9 +67,9 @@ export function createChartTooltip<
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
|
||||
<ChartTooltipContainer>
|
||||
<Tooltip data={data} context={context} {...tooltip} />
|
||||
</div>
|
||||
</ChartTooltipContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -77,6 +77,15 @@ export const BarShapeBlue = BarWithBorder({
|
||||
fill: 'rgba(59, 121, 255, 0.4)',
|
||||
},
|
||||
});
|
||||
export const BarShapeGreen = BarWithBorder({
|
||||
borderHeight: 2,
|
||||
border: 'rgba(59, 169, 116, 1)',
|
||||
fill: 'rgba(59, 169, 116, 0.3)',
|
||||
active: {
|
||||
border: 'rgba(59, 169, 116, 1)',
|
||||
fill: 'rgba(59, 169, 116, 0.4)',
|
||||
},
|
||||
});
|
||||
export const BarShapeProps = BarWithBorder({
|
||||
borderHeight: 2,
|
||||
border: 'props',
|
||||
|
||||
@@ -48,6 +48,10 @@ export const EventIconRecords: Record<
|
||||
icon: 'ExternalLinkIcon',
|
||||
color: 'indigo',
|
||||
},
|
||||
revenue: {
|
||||
icon: 'DollarSignIcon',
|
||||
color: 'green',
|
||||
},
|
||||
};
|
||||
|
||||
export const EventIconMapper: Record<string, LucideIcon> = {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
}}
|
||||
data-slot="inner"
|
||||
className={cn(
|
||||
'col gap-2 flex-1 p-2',
|
||||
'col gap-1 flex-1 p-2',
|
||||
// Desktop
|
||||
'@lg:row @lg:items-center',
|
||||
'cursor-pointer',
|
||||
@@ -63,7 +63,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
: 'hover:bg-def-200',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 row items-center gap-4">
|
||||
<div className="min-w-0 flex-1 row items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
@@ -77,7 +77,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
>
|
||||
<EventIcon name={event.name} size="sm" meta={event.meta} />
|
||||
</button>
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all">
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all text-sm leading-normal">
|
||||
{event.name === 'screen_view' ? (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Visit:</span>
|
||||
@@ -87,13 +87,12 @@ export const EventItem = memo<EventItemProps>(
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Event:</span>
|
||||
<span className="font-medium">{event.name}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-2 items-center @max-lg:pl-10">
|
||||
<div className="row gap-2 items-center @max-lg:pl-8">
|
||||
{event.referrerName && viewOptions.referrerName !== false && (
|
||||
<Pill
|
||||
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
|
||||
|
||||
@@ -24,7 +24,7 @@ const ConnectWeb = ({ client }: Props) => {
|
||||
<Syntax
|
||||
className="border"
|
||||
code={`<script>
|
||||
window.op = window.op||function(...args){(window.op.q=window.op.q||[]).push(args);};
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
window.op('init', {
|
||||
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
|
||||
trackScreenViews: true,
|
||||
|
||||
@@ -108,8 +108,8 @@ function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="row gap-2 justify-between">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} sessions last 30 minutes
|
||||
<div className="relative mb-1 text-xs font-medium text-muted-foreground">
|
||||
{count} sessions last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
import { Area, AreaChart, Tooltip } from 'recharts';
|
||||
|
||||
import { formatDate, timeAgo } from '@/utils/date';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '../charts/chart-tooltip';
|
||||
import {
|
||||
PreviousDiffIndicatorPure,
|
||||
getDiffIndicator,
|
||||
@@ -17,12 +24,13 @@ interface MetricCardProps {
|
||||
data: {
|
||||
current: number;
|
||||
previous?: number;
|
||||
date: string;
|
||||
}[];
|
||||
metric: {
|
||||
current: number;
|
||||
previous?: number | null;
|
||||
};
|
||||
unit?: '' | 'date' | 'timeAgo' | 'min' | '%';
|
||||
unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency';
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
@@ -41,8 +49,28 @@ export function OverviewMetricCard({
|
||||
inverted = false,
|
||||
isLoading = false,
|
||||
}: MetricCardProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
const number = useNumber();
|
||||
const { current, previous } = metric;
|
||||
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
|
||||
if (currentIndex) {
|
||||
timer.current = setTimeout(() => {
|
||||
setCurrentIndex(null);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
};
|
||||
}, [currentIndex]);
|
||||
|
||||
const renderValue = (value: number, unitClassName?: string, short = true) => {
|
||||
if (unit === 'date') {
|
||||
@@ -57,6 +85,11 @@ export function OverviewMetricCard({
|
||||
return <>{fancyMinutes(value)}</>;
|
||||
}
|
||||
|
||||
if (unit === 'currency') {
|
||||
// Revenue is stored in cents, convert to dollars
|
||||
return <>{number.currency(value / 100)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{short ? number.short(value) : number.format(value)}
|
||||
@@ -73,19 +106,33 @@ export function OverviewMetricCard({
|
||||
'#93c5fd', // blue
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltiper
|
||||
content={
|
||||
const renderTooltip = () => {
|
||||
if (currentIndex) {
|
||||
return (
|
||||
<span>
|
||||
{label}:{' '}
|
||||
{formatDate(new Date(data[currentIndex]?.date))}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(current, 'ml-1 font-light text-xl', false)}
|
||||
{renderValue(
|
||||
data[currentIndex].current,
|
||||
'ml-1 font-light text-xl',
|
||||
false,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
sideOffset={-20}
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(metric.current, 'ml-1 font-light text-xl', false)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -97,7 +144,7 @@ export function OverviewMetricCard({
|
||||
<div className={cn('group relative p-4')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<AutoSizer>
|
||||
@@ -107,6 +154,9 @@ export function OverviewMetricCard({
|
||||
height={height / 4}
|
||||
data={data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
onMouseMove={(event) => {
|
||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
@@ -128,6 +178,7 @@ export function OverviewMetricCard({
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Tooltip content={() => null} />
|
||||
<Area
|
||||
dataKey={'current'}
|
||||
type="step"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
@@ -13,24 +12,22 @@ import { getPreviousMetric } from '@openpanel/common';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { last, omit } from 'ramda';
|
||||
import { last } from 'ramda';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Customized,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { createChartTooltip } from '../charts/chart-tooltip';
|
||||
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
|
||||
import { Skeleton } from '../skeleton';
|
||||
@@ -78,6 +75,12 @@ const TITLES = [
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
key: 'total_revenue',
|
||||
unit: 'currency',
|
||||
inverted: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
@@ -86,11 +89,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
|
||||
'chartType',
|
||||
'bars',
|
||||
);
|
||||
|
||||
const activeMetric = TITLES[metric]!;
|
||||
const overviewQuery = useQuery(
|
||||
trpc.overview.stats.queryOptions({
|
||||
@@ -125,6 +123,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
}}
|
||||
unit={title.unit}
|
||||
data={data.map((item) => ({
|
||||
date: item.date,
|
||||
current: item[title.key],
|
||||
previous: item[`prev_${title.key}`],
|
||||
}))}
|
||||
@@ -136,7 +135,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2',
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1',
|
||||
)}
|
||||
>
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
@@ -148,32 +147,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('bars')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'bars'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('lines')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'lines'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Lines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
@@ -181,7 +154,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
activeMetric={activeMetric}
|
||||
interval={interval}
|
||||
data={data}
|
||||
chartType={chartType}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
@@ -194,18 +166,25 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RouterOutputs['overview']['stats']['series'][number],
|
||||
{
|
||||
anyMetric?: boolean;
|
||||
metric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
}
|
||||
>(({ context: { metric, interval }, data: dataArray }) => {
|
||||
>(({ context: { metric, interval, anyMetric }, data: dataArray }) => {
|
||||
const data = dataArray[0];
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revenue = data.total_revenue ?? 0;
|
||||
const prevRevenue = data.prev_total_revenue ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
@@ -215,16 +194,25 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
style={{ background: anyMetric ? getChartColor(0) : '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{metric.title}</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data[metric.key])}
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[metric.key] ?? 0) / 100)
|
||||
: number.formatWithUnit(data[metric.key], metric.unit)}
|
||||
{!!data[`prev_${metric.key}`] && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data[`prev_${metric.key}`])})
|
||||
(
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[`prev_${metric.key}`] ?? 0) / 100)
|
||||
: number.formatWithUnit(
|
||||
data[`prev_${metric.key}`],
|
||||
metric.unit,
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -238,6 +226,32 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{anyMetric && revenue > 0 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.currency(revenue / 100)}
|
||||
{prevRevenue > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.currency(prevRevenue / 100)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prevRevenue > 0 && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(revenue, prevRevenue)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
@@ -247,17 +261,19 @@ function Chart({
|
||||
activeMetric,
|
||||
interval,
|
||||
data,
|
||||
chartType,
|
||||
projectId,
|
||||
}: {
|
||||
activeMetric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
data: RouterOutputs['overview']['stats']['series'];
|
||||
chartType: 'bars' | 'lines';
|
||||
projectId: string;
|
||||
}) {
|
||||
const xAxisProps = useXAxisProps({ interval });
|
||||
const yAxisProps = useYAxisProps();
|
||||
const number = useNumber();
|
||||
const revenueYAxisProps = useYAxisProps({
|
||||
tickFormatter: (value) => number.short(value / 100),
|
||||
});
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
|
||||
@@ -278,13 +294,11 @@ function Chart({
|
||||
|
||||
// Line chart specific logic
|
||||
let dotIndex = undefined;
|
||||
if (chartType === 'lines') {
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
|
||||
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
|
||||
@@ -294,6 +308,10 @@ function Chart({
|
||||
|
||||
const lastSerieDataItem = last(data)?.date || new Date();
|
||||
const useDashedLastLine = (() => {
|
||||
if (range === 'today') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interval === 'hour') {
|
||||
return isSameHour(lastSerieDataItem, new Date());
|
||||
}
|
||||
@@ -313,11 +331,11 @@ function Chart({
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (chartType === 'lines') {
|
||||
if (activeMetric.key === 'total_revenue') {
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<ComposedChart data={data}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
@@ -326,13 +344,8 @@ function Chart({
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
@@ -340,10 +353,30 @@ function Chart({
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="linear"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
key={'prev_total_revenue'}
|
||||
type="monotone"
|
||||
dataKey={'prev_total_revenue'}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
@@ -352,24 +385,26 @@ function Chart({
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'var(--def-100)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'var(--def-100)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={activeMetric.key}
|
||||
type="linear"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
<Area
|
||||
key={'total_revenue'}
|
||||
type="monotone"
|
||||
dataKey={'total_revenue'}
|
||||
stroke={'#3ba974'}
|
||||
fill={'#3ba974'}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
@@ -381,18 +416,19 @@ function Chart({
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
stroke: '#3ba974',
|
||||
fill: '#3ba974',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
stroke: '#3ba974',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
@@ -410,36 +446,48 @@ function Chart({
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Bar chart (default)
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<TooltipProvider metric={activeMetric} interval={interval} anyMetric={true}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
setActiveBar(e.activeTooltipIndex ?? -1);
|
||||
}}
|
||||
barCategoryGap={2}
|
||||
>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: 'var(--def-200)',
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<XAxis {...omit(['scale', 'type'], xAxisProps)} />
|
||||
<YAxis
|
||||
{...revenueYAxisProps}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[
|
||||
0,
|
||||
data.reduce(
|
||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||
0,
|
||||
) * 2,
|
||||
]}
|
||||
width={30}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -448,21 +496,103 @@ function Chart({
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="monotone"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
shape={(props: any) => (
|
||||
<BarShapeGrey isActive={activeBar === props.index} {...props} />
|
||||
)}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
key={activeMetric.key}
|
||||
dataKey={activeMetric.key}
|
||||
key="total_revenue"
|
||||
dataKey="total_revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
shape={(props: any) => (
|
||||
<BarShapeBlue isActive={activeBar === props.index} {...props} />
|
||||
)}
|
||||
radius={5}
|
||||
maxBarSize={20}
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.date}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Area
|
||||
key={activeMetric.key}
|
||||
type="monotone"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
fill={getChartColor(0)}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
@@ -480,7 +610,7 @@ function Chart({
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -45,8 +45,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -81,8 +82,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -120,8 +122,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -160,8 +163,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -199,8 +203,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -239,8 +244,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -278,8 +284,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
|
||||
@@ -37,8 +37,9 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
...filters,
|
||||
@@ -78,8 +79,9 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [...filters],
|
||||
id: 'A',
|
||||
@@ -112,8 +114,9 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
...filters,
|
||||
|
||||
@@ -146,8 +146,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
|
||||
@@ -8,6 +8,43 @@ import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
|
||||
|
||||
function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const size = 16;
|
||||
const strokeWidth = 2;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - percentage * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="flex-shrink-0">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-def-200"
|
||||
/>
|
||||
{/* Revenue arc */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#3ba974"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props<T> = WidgetTableProps<T> & {
|
||||
getColumnPercentage: (item: T) => number;
|
||||
};
|
||||
@@ -45,9 +82,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
: 'text-right font-mono',
|
||||
index !== 0 &&
|
||||
index !== columns.length - 1 &&
|
||||
'hidden @[310px]:table-cell',
|
||||
// Remove old responsive logic - now handled by responsive prop
|
||||
column.className,
|
||||
),
|
||||
};
|
||||
@@ -119,12 +154,15 @@ export function OverviewWidgetTablePages({
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}[];
|
||||
showDomain?: boolean;
|
||||
}) {
|
||||
const [_filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
|
||||
const hasRevenue = data.some((item) => item.revenue > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
@@ -135,6 +173,7 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'Path',
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
@@ -178,6 +217,7 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -185,13 +225,41 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'Duration',
|
||||
width: '75px',
|
||||
responsive: { priority: 7 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.avg_duration, 'min');
|
||||
},
|
||||
},
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{item.revenue > 0
|
||||
? number.currency(item.revenue / 100)
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: lastColumnName,
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -303,20 +371,24 @@ export function OverviewWidgetTableGeneric({
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.name}
|
||||
keyExtractor={(item) => item.prefix + item.name}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
...column,
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -327,9 +399,38 @@ export function OverviewWidgetTableGeneric({
|
||||
// return number.shortWithUnit(item.avg_session_duration, 'min');
|
||||
// },
|
||||
// },
|
||||
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{revenue > 0
|
||||
? number.currency(revenue / 100, { short: true })
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
|
||||
@@ -15,8 +15,9 @@ export const ProfileCharts = memo(
|
||||
const pageViewsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
@@ -48,8 +49,9 @@ export const ProfileCharts = memo(
|
||||
const eventsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
|
||||
@@ -12,72 +12,91 @@ const PROFILE_METRICS = [
|
||||
key: 'totalEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Sessions',
|
||||
key: 'sessions',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Page Views',
|
||||
key: 'screenViews',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Events/Session',
|
||||
key: 'avgEventsPerSession',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Bounce Rate',
|
||||
key: 'bounceRate',
|
||||
unit: '%',
|
||||
inverted: true,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (Avg)',
|
||||
key: 'durationAvg',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (P90)',
|
||||
key: 'durationP90',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'First seen',
|
||||
key: 'firstSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
key: 'lastSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Days Active',
|
||||
key: 'uniqueDaysActive',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversion Events',
|
||||
key: 'conversionEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Time Between Sessions (h)',
|
||||
key: 'avgTimeBetweenSessions',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
key: 'revenue',
|
||||
unit: 'currency',
|
||||
inverted: false,
|
||||
hideOnZero: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -85,7 +104,12 @@ export const ProfileMetrics = ({ data }: Props) => {
|
||||
return (
|
||||
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
||||
{PROFILE_METRICS.map((metric) => (
|
||||
{PROFILE_METRICS.filter((metric) => {
|
||||
if (metric.hideOnZero && data[metric.key] === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((metric) => (
|
||||
<OverviewMetricCard
|
||||
key={metric.key}
|
||||
id={metric.key}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shortNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
@@ -7,11 +7,11 @@ import type { IServiceProject } from '@openpanel/db';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
import { ChartSSR } from '../chart-ssr';
|
||||
import { FadeIn } from '../fade-in';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { LinkButton } from '../ui/button';
|
||||
import { ProjectChart } from './project-chart';
|
||||
|
||||
export function ProjectCardRoot({
|
||||
children,
|
||||
@@ -60,7 +60,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-4 aspect-[8/1] mb-4">
|
||||
<ProjectChart id={id} />
|
||||
<ProjectChartOuter id={id} />
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 h-9 md:h-4">
|
||||
<ProjectMetrics id={id} />
|
||||
@@ -77,7 +77,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectChart({ id }: { id: string }) {
|
||||
function ProjectChartOuter({ id }: { id: string }) {
|
||||
const trpc = useTRPC();
|
||||
const { data } = useQuery(
|
||||
trpc.chart.projectCard.queryOptions({
|
||||
@@ -87,7 +87,7 @@ function ProjectChart({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<FadeIn className="h-full w-full">
|
||||
<ChartSSR data={data?.chart || []} color={'blue'} />
|
||||
<ProjectChart data={data?.chart || []} color={'blue'} />
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,7 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
|
||||
}
|
||||
|
||||
function ProjectMetrics({ id }: { id: string }) {
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const { data } = useQuery(
|
||||
trpc.chart.projectCard.queryOptions({
|
||||
@@ -138,16 +139,18 @@ function ProjectMetrics({ id }: { id: string }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!data?.metrics?.revenue && (
|
||||
<Metric
|
||||
label="Revenue"
|
||||
value={number.currency(data?.metrics?.revenue / 100, {
|
||||
short: true,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Metric
|
||||
label="3M"
|
||||
value={shortNumber('en')(data?.metrics?.months_3 ?? 0)}
|
||||
/>
|
||||
<Metric
|
||||
label="30D"
|
||||
value={shortNumber('en')(data?.metrics?.month ?? 0)}
|
||||
/>
|
||||
<Metric label="24H" value={shortNumber('en')(data?.metrics?.day ?? 0)} />
|
||||
<Metric label="3M" value={number.short(data?.metrics?.months_3 ?? 0)} />
|
||||
<Metric label="30D" value={number.short(data?.metrics?.month ?? 0)} />
|
||||
<Metric label="24H" value={number.short(data?.metrics?.day ?? 0)} />
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
215
apps/start/src/components/projects/project-chart.tsx
Normal file
215
apps/start/src/components/projects/project-chart.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type ChartDataItem = {
|
||||
value: number;
|
||||
date: Date;
|
||||
revenue: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
ChartDataItem,
|
||||
{
|
||||
color: 'blue' | 'green' | 'red';
|
||||
}
|
||||
>(
|
||||
({
|
||||
context,
|
||||
data: dataArray,
|
||||
}: {
|
||||
context: { color: 'blue' | 'green' | 'red' };
|
||||
data: ChartDataItem[];
|
||||
}) => {
|
||||
const { color } = context;
|
||||
const data = dataArray[0];
|
||||
const number = useNumber();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getColorValue = () => {
|
||||
if (color === 'green') return '#16a34a';
|
||||
if (color === 'red') return '#dc2626';
|
||||
return getChartColor(0);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div className="text-muted-foreground">{formatDate(data.date)}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem
|
||||
color={getColorValue()}
|
||||
innerClassName="row justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">Sessions</div>
|
||||
<div className="font-mono font-bold">{number.format(data.value)}</div>
|
||||
</ChartTooltipItem>
|
||||
{data.revenue > 0 && (
|
||||
<ChartTooltipItem color="#3ba974">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="font-mono font-medium">
|
||||
{number.currency(data.revenue / 100)}
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function ProjectChart({
|
||||
data,
|
||||
dots = false,
|
||||
color = 'blue',
|
||||
}: {
|
||||
dots?: boolean;
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
data: { value: number; date: Date; revenue: number }[];
|
||||
}) {
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform data for Recharts (needs timestamp for time-based x-axis)
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
timestamp: item.date.getTime(),
|
||||
}));
|
||||
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 0);
|
||||
const maxRevenue = Math.max(...data.map((d) => d.revenue), 0);
|
||||
|
||||
const getColorValue = () => {
|
||||
if (color === 'green') return '#16a34a';
|
||||
if (color === 'red') return '#dc2626';
|
||||
return getChartColor(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<TooltipProvider color={color}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
setActiveBar(e.activeTooltipIndex ?? -1);
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
hide
|
||||
/>
|
||||
<YAxis domain={[0, maxValue || 'dataMax']} hide width={0} />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, maxRevenue * 2 || 'dataMax']}
|
||||
hide
|
||||
width={0}
|
||||
/>
|
||||
|
||||
<Tooltip />
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={getColorValue()}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
dots && data.length <= 90
|
||||
? {
|
||||
stroke: getColorValue(),
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
: false
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getColorValue(),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey="revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
radius={5}
|
||||
maxBarSize={20}
|
||||
>
|
||||
{chartData.map((item, index) => (
|
||||
<Cell
|
||||
key={item.timestamp}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
width: '84px',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -86,6 +86,19 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '82px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.unique_sessions)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,126 +1,99 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { BarShapeBlue } from '../charts/common-bar';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const getReport = (projectId: string): IChartProps => {
|
||||
return {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
name: '*',
|
||||
displayName: 'Active users',
|
||||
},
|
||||
],
|
||||
chartType: 'histogram',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
name: '',
|
||||
metric: 'sum',
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
previous: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCountReport = (projectId: string): IChartProps => {
|
||||
return {
|
||||
name: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
};
|
||||
|
||||
export function RealtimeLiveHistogram({
|
||||
projectId,
|
||||
}: RealtimeLiveHistogramProps) {
|
||||
const report = getReport(projectId);
|
||||
const countReport = getCountReport(projectId);
|
||||
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(trpc.chart.chart.queryOptions(report));
|
||||
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
|
||||
|
||||
const metrics = res.data?.series[0]?.metrics;
|
||||
const minutes = (res.data?.series[0]?.data || []).slice(-30);
|
||||
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
|
||||
// Use the same liveData endpoint as overview
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
);
|
||||
|
||||
if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) {
|
||||
const staticArray = [
|
||||
10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52,
|
||||
5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5,
|
||||
];
|
||||
const chartData = liveData?.minuteCounts ?? [];
|
||||
// Calculate total unique visitors (sum of unique visitors per minute)
|
||||
// Note: This is an approximation - ideally we'd want unique visitors across all minutes
|
||||
const totalVisitors = liveData?.totalSessions ?? 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
{staticArray.map((percent, i) => (
|
||||
<div
|
||||
key={i as number}
|
||||
className="flex-1 animate-pulse rounded-sm bg-def-200"
|
||||
style={{ height: `${percent}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.isSuccess && !countRes.isSuccess) {
|
||||
if (!liveData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDomain =
|
||||
Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1;
|
||||
|
||||
return (
|
||||
<Wrapper count={liveCount}>
|
||||
{minutes.map((minute) => {
|
||||
return (
|
||||
<Tooltip key={minute.date}>
|
||||
<TooltipTrigger asChild>
|
||||
<Wrapper
|
||||
count={totalVisitors}
|
||||
icons={
|
||||
liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
<div className="row gap-2">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110',
|
||||
minute.count === 0 ? 'bg-def-200' : 'bg-highlight',
|
||||
)}
|
||||
style={{
|
||||
height:
|
||||
minute.count === 0
|
||||
? '20%'
|
||||
: `${(minute.count / metrics!.max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div>{minute.count} active users</div>
|
||||
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Tooltip
|
||||
content={CustomTooltip}
|
||||
cursor={{
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="visitorCount"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
activeBar={BarShapeBlue}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -128,22 +101,144 @@ export function RealtimeLiveHistogram({
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
count: number;
|
||||
icons?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Unique vistors last 30 minutes
|
||||
<div className="row gap-2 justify-between mb-2">
|
||||
<div className="relative text-sm font-medium text-muted-foreground leading-normal">
|
||||
Unique visitors {icons ? <br /> : null}
|
||||
last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="col gap-2 mb-4">
|
||||
<div className="font-mono text-6xl font-bold">
|
||||
<AnimatedNumber value={count} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
<div className="relative aspect-[6/1] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom tooltip component that uses portals to escape overflow hidden
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
type: 'mousemove',
|
||||
listener(event) {
|
||||
if (!inactive) {
|
||||
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
|
||||
}
|
||||
},
|
||||
});
|
||||
const unsubDragEnter = bind(window, {
|
||||
type: 'pointerdown',
|
||||
listener() {
|
||||
setPosition(null);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMouseMove();
|
||||
unsubDragEnter();
|
||||
};
|
||||
}, [inactive]);
|
||||
|
||||
if (inactive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
const tooltipWidth = 200;
|
||||
const correctXPosition = (x: number | undefined) => {
|
||||
if (!x) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const newX = x;
|
||||
|
||||
if (newX + tooltipWidth > screenWidth) {
|
||||
return screenWidth - tooltipWidth;
|
||||
}
|
||||
return newX;
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
left: correctXPosition(position?.x),
|
||||
zIndex: 1000,
|
||||
width: tooltipWidth,
|
||||
}}
|
||||
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.time}</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.visitorCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Portal.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
width: '84px',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -93,6 +93,19 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '82px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.unique_sessions)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
width: '84px',
|
||||
width: '60px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -76,6 +76,19 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '82px',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.unique_sessions)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getCountReport, getReport } from './realtime-live-histogram';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -17,11 +16,15 @@ const RealtimeReloader = ({ projectId }: Props) => {
|
||||
if (!document.hidden) {
|
||||
client.refetchQueries(trpc.realtime.pathFilter());
|
||||
client.refetchQueries(
|
||||
trpc.chart.chart.queryFilter(getReport(projectId)),
|
||||
trpc.overview.liveData.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.chart.chart.queryFilter(getCountReport(projectId)),
|
||||
trpc.realtime.activeSessions.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.referrals.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId }));
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,8 +7,9 @@ import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
@@ -25,7 +26,10 @@ import {
|
||||
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { SolidToDashedGradient } from '../common/linear-gradient';
|
||||
import {
|
||||
ChartClickMenu,
|
||||
type ChartClickMenuItem,
|
||||
} from '../common/chart-click-menu';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
@@ -46,6 +50,8 @@ export function Chart({ data }: Props) {
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis },
|
||||
@@ -127,16 +133,66 @@ export function Chart({ data }: Props) {
|
||||
interval,
|
||||
});
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0]) {
|
||||
const clickedData = e.activePayload[0].payload;
|
||||
if (clickedData.date) {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
const getMenuItems = useCallback(
|
||||
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||
const items: ChartClickMenuItem[] = [];
|
||||
|
||||
if (!clickedData?.date) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// View Users - only show if we have projectId
|
||||
if (projectId) {
|
||||
items.push({
|
||||
label: 'View Users',
|
||||
icon: <UsersIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'chart',
|
||||
chartData: data,
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'area',
|
||||
metric: 'sum',
|
||||
},
|
||||
date: clickedData.date,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add Reference - always show
|
||||
items.push({
|
||||
label: 'Add Reference',
|
||||
icon: <BookmarkIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
data,
|
||||
reportSeries,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
],
|
||||
);
|
||||
|
||||
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
||||
useDashedStroke({
|
||||
@@ -145,106 +201,114 @@ export function Chart({ data }: Props) {
|
||||
|
||||
return (
|
||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
))}
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<defs key={`defs-${serie.id}`}>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
})}
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={serie.id}
|
||||
stackId="1"
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
fill={`url(#color${color})`}
|
||||
isAnimationActive={false}
|
||||
fillOpacity={0.7}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{previous &&
|
||||
series.map((serie) => {
|
||||
))}
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<defs key={`defs-${serie.id}`}>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop
|
||||
offset={'100%'}
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
})}
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={`${serie.id}:prev`}
|
||||
stackId="2"
|
||||
key={serie.id}
|
||||
stackId="1"
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
fill={`url(#color${color})`}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
{previous &&
|
||||
series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={`${serie.id}:prev`}
|
||||
stackId="2"
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeOpacity={0.3}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</ChartClickMenu>
|
||||
</ReportChartTooltip.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export function Chart({ data }: Props) {
|
||||
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
|
||||
[data, isEditMode, limit],
|
||||
);
|
||||
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||
const maxCount = Math.max(
|
||||
...series.map((serie) => serie.metrics[metric] ?? 0),
|
||||
);
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRef, useState } from 'react';
|
||||
import type { AxisDomain } from 'recharts/types/util/types';
|
||||
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
export const AXIS_FONT_PROPS = {
|
||||
fontSize: 8,
|
||||
className: 'font-mono',
|
||||
@@ -69,9 +68,11 @@ export const useXAxisProps = (
|
||||
interval: 'auto',
|
||||
},
|
||||
) => {
|
||||
const formatDate = useFormatDateInterval(
|
||||
interval === 'auto' ? 'day' : interval,
|
||||
);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval: interval === 'auto' ? 'day' : interval,
|
||||
short: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...X_AXIS_STYLE_PROPS,
|
||||
height: hide ? 0 : X_AXIS_STYLE_PROPS.height,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface ChartClickMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ChartClickMenuProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Function that receives the click event and clicked data, returns menu items
|
||||
* This allows conditional menu items based on what was clicked
|
||||
*/
|
||||
getMenuItems: (e: any, clickedData: any) => ChartClickMenuItem[];
|
||||
/**
|
||||
* Optional callback when menu closes
|
||||
*/
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ChartClickMenuHandle {
|
||||
setPosition: (position: { x: number; y: number } | null) => void;
|
||||
getContainerElement: () => HTMLDivElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for handling chart clicks and showing a dropdown menu
|
||||
* Wraps the chart and handles click position tracking and dropdown positioning
|
||||
*/
|
||||
export const ChartClickMenu = forwardRef<
|
||||
ChartClickMenuHandle,
|
||||
ChartClickMenuProps
|
||||
>(({ children, getMenuItems, onClose }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [clickPosition, setClickPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [clickedData, setClickedData] = useState<any>(null);
|
||||
|
||||
const [clickEvent, setClickEvent] = useState<any>(null);
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0] && containerRef.current) {
|
||||
const payload = e.activePayload[0].payload;
|
||||
|
||||
// Calculate click position relative to chart container
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// Try to get viewport coordinates from the event
|
||||
// Recharts passes nativeEvent with clientX/clientY (viewport coordinates)
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
|
||||
if (
|
||||
e.nativeEvent?.clientX !== undefined &&
|
||||
e.nativeEvent?.clientY !== undefined
|
||||
) {
|
||||
// Best case: use nativeEvent client coordinates (viewport coordinates)
|
||||
clientX = e.nativeEvent.clientX;
|
||||
clientY = e.nativeEvent.clientY;
|
||||
} else if (e.clientX !== undefined && e.clientY !== undefined) {
|
||||
// Fallback: use event's clientX/Y directly
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
} else if (e.activeCoordinate) {
|
||||
// Last resort: activeCoordinate is SVG-relative, need to find SVG element
|
||||
// and convert to viewport coordinates
|
||||
const svgElement = containerRef.current.querySelector('svg');
|
||||
if (svgElement) {
|
||||
const svgRect = svgElement.getBoundingClientRect();
|
||||
clientX = svgRect.left + (e.activeCoordinate.x ?? 0);
|
||||
clientY = svgRect.top + (e.activeCoordinate.y ?? 0);
|
||||
} else {
|
||||
// If no SVG found, use container position + activeCoordinate
|
||||
clientX = containerRect.left + (e.activeCoordinate.x ?? 0);
|
||||
clientY = containerRect.top + (e.activeCoordinate.y ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
setClickedData(payload);
|
||||
setClickEvent(e); // Store the full event
|
||||
setClickPosition({
|
||||
x: clientX - containerRect.left,
|
||||
y: clientY - containerRect.top,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const menuItems =
|
||||
clickedData && clickEvent ? getMenuItems(clickEvent, clickedData) : [];
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: ChartClickMenuItem) => {
|
||||
item.onClick();
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
setClickEvent(null);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
setClickEvent(null);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Expose methods via ref (for advanced use cases)
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
setPosition: (position: { x: number; y: number } | null) => {
|
||||
setClickPosition(position);
|
||||
},
|
||||
getContainerElement: () => containerRef.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Clone children and add onClick handler to chart components
|
||||
const chartWithClickHandler = React.useMemo(() => {
|
||||
const addClickHandler = (node: React.ReactNode): React.ReactNode => {
|
||||
// Handle null, undefined, strings, numbers
|
||||
if (!React.isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// Check if this is a chart component
|
||||
const componentName =
|
||||
(node.type as any)?.displayName || (node.type as any)?.name;
|
||||
const isChartComponent =
|
||||
componentName === 'ComposedChart' ||
|
||||
componentName === 'LineChart' ||
|
||||
componentName === 'BarChart' ||
|
||||
componentName === 'AreaChart' ||
|
||||
componentName === 'PieChart' ||
|
||||
componentName === 'ResponsiveContainer';
|
||||
|
||||
// Process children recursively - handle arrays, fragments, and single elements
|
||||
const processChildren = (children: React.ReactNode): React.ReactNode => {
|
||||
if (children == null) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(addClickHandler);
|
||||
}
|
||||
|
||||
// Handle React fragments
|
||||
if (
|
||||
React.isValidElement(children) &&
|
||||
children.type === React.Fragment
|
||||
) {
|
||||
const fragmentElement = children as React.ReactElement<{
|
||||
children?: React.ReactNode;
|
||||
}>;
|
||||
return React.cloneElement(fragmentElement, {
|
||||
children: processChildren(fragmentElement.props.children),
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively process single child
|
||||
return addClickHandler(children);
|
||||
};
|
||||
|
||||
const element = node as React.ReactElement<{
|
||||
children?: React.ReactNode;
|
||||
onClick?: (e: any) => void;
|
||||
}>;
|
||||
|
||||
if (isChartComponent) {
|
||||
// For ResponsiveContainer, we need to add onClick to its child (ComposedChart, etc.)
|
||||
if (componentName === 'ResponsiveContainer') {
|
||||
return React.cloneElement(element, {
|
||||
children: processChildren(element.props.children),
|
||||
});
|
||||
}
|
||||
// For chart components, add onClick directly
|
||||
return React.cloneElement(element, {
|
||||
onClick: handleChartClick,
|
||||
children: processChildren(element.props.children),
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively process children for non-chart components
|
||||
if (element.props.children != null) {
|
||||
return React.cloneElement(element, {
|
||||
children: processChildren(element.props.children),
|
||||
});
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
// Handle multiple children (array) or single child
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(addClickHandler);
|
||||
}
|
||||
return addClickHandler(children);
|
||||
}, [children, handleChartClick]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full">
|
||||
<DropdownMenu
|
||||
open={clickPosition !== null}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: clickPosition?.x ?? -9999,
|
||||
top: clickPosition?.y ?? -9999,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="bottom" sideOffset={5}>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.label}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{chartWithClickHandler}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChartClickMenu.displayName = 'ChartClickMenu';
|
||||
@@ -17,10 +17,10 @@ export function ReportChartEmpty({
|
||||
}) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { events },
|
||||
report: { series },
|
||||
} = useReportChartContext();
|
||||
|
||||
if (events.length === 0) {
|
||||
if (!series || series.length === 0) {
|
||||
return (
|
||||
<div className="card p-4 center-center h-full w-full flex-col relative">
|
||||
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model';
|
||||
import React from 'react';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import {
|
||||
@@ -58,7 +62,10 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
|
||||
const {
|
||||
report: { interval, unit },
|
||||
} = useReportChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
@@ -88,37 +95,31 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2">
|
||||
<>
|
||||
{visible.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{index === 0 && item.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<ChartTooltipHeader>
|
||||
<div>{formatDate(new Date(item.date))}</div>
|
||||
</div>
|
||||
</ChartTooltipHeader>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={item.names} />
|
||||
<SerieName name={item.names} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(item.count, unit)}
|
||||
{!!item.previous && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(item.previous.value, unit)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PreviousDiffIndicator {...item.previous} />
|
||||
</div>
|
||||
<ChartTooltipItem color={item.color}>
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={item.names} />
|
||||
<SerieName name={item.names} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(item.count, unit)}
|
||||
{!!item.previous && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(item.previous.value, unit)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PreviousDiffIndicator {...item.previous} />
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
@@ -142,7 +143,7 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user