1 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
bc3d7b7ea8 docs: add guides 2025-12-15 10:14:40 +01:00
341 changed files with 5143 additions and 30613 deletions

View File

@@ -8,7 +8,6 @@ on:
- "apps/api/**"
- "apps/worker/**"
- "apps/public/**"
- "apps/start/**"
- "packages/**"
- "!packages/sdks/**"
- "**Dockerfile"

View File

@@ -98,10 +98,6 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
### Start
```bash
pnpm install
cp .env.example .env
echo "API_URL=http://localhost:3333" > apps/start/.env
pnpm dock:up
pnpm codegen
pnpm migrate:deploy # once to setup the db
@@ -114,4 +110,4 @@ You can now access the following:
- API: https://api.localhost:3333
- Bullboard (queue): http://localhost:9999
- `pnpm dock:ch` to access clickhouse terminal
- `pnpm dock:redis` to access redis terminal
- `pnpm dock:redis` to access redis terminal

View File

@@ -38,10 +38,11 @@ COPY packages/redis/package.json packages/redis/
COPY packages/logger/package.json packages/logger/
COPY packages/common/package.json packages/common/
COPY packages/payments/package.json packages/payments/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/js-runtime/package.json packages/js-runtime/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY patches ./patches
# BUILD
@@ -106,10 +107,10 @@ COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen

View File

@@ -8,7 +8,6 @@
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"test:manage": "jiti scripts/test-manage-api.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -53,6 +52,7 @@
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9",

View File

@@ -1,340 +0,0 @@
/**
* One-off script to test all /manage/ API endpoints
*
* Usage:
* pnpm test:manage
* or
* pnpm jiti scripts/test-manage-api.ts
*
* Set API_URL environment variable to test against a different server:
* API_URL=http://localhost:3000 pnpm test:manage
*/
const CLIENT_ID = process.env.CLIENT_ID!;
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('CLIENT_ID and CLIENT_SECRET must be set');
process.exit(1);
}
interface TestResult {
name: string;
method: string;
url: string;
status: number;
success: boolean;
error?: string;
data?: any;
}
const results: TestResult[] = [];
async function makeRequest(
method: string,
path: string,
body?: any,
): Promise<TestResult> {
const url = `${API_BASE_URL}${path}`;
const headers: Record<string, string> = {
'openpanel-client-id': CLIENT_ID,
'openpanel-client-secret': CLIENT_SECRET,
};
// Only set Content-Type if there's a body
if (body) {
headers['Content-Type'] = 'application/json';
}
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => ({}));
return {
name: `${method} ${path}`,
method,
url,
status: response.status,
success: response.ok,
error: response.ok ? undefined : data.message || 'Request failed',
data: response.ok ? data : undefined,
};
} catch (error) {
return {
name: `${method} ${path}`,
method,
url,
status: 0,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async function testProjects() {
console.log('\n📁 Testing Projects endpoints...\n');
// Create project
const createResult = await makeRequest('POST', '/manage/projects', {
name: `Test Project ${Date.now()}`,
domain: 'https://example.com',
cors: ['https://example.com', 'https://www.example.com'],
crossDomain: false,
types: ['website'],
});
results.push(createResult);
console.log(
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
const projectId = createResult.data?.data?.id;
const clientId = createResult.data?.data?.client?.id;
const clientSecret = createResult.data?.data?.client?.secret;
if (projectId) {
console.log(` Created project: ${projectId}`);
if (clientId) console.log(` Created client: ${clientId}`);
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
}
// List projects
const listResult = await makeRequest('GET', '/manage/projects');
results.push(listResult);
console.log(
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} projects`);
}
if (projectId) {
// Get project
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
results.push(getResult);
console.log(
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
);
// Update project
const updateResult = await makeRequest(
'PATCH',
`/manage/projects/${projectId}`,
{
name: 'Updated Test Project',
crossDomain: true,
},
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
);
// Delete project (soft delete)
const deleteResult = await makeRequest(
'DELETE',
`/manage/projects/${projectId}`,
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
);
}
return { projectId, clientId };
}
async function testClients(projectId?: string) {
console.log('\n🔑 Testing Clients endpoints...\n');
// Create client
const createResult = await makeRequest('POST', '/manage/clients', {
name: `Test Client ${Date.now()}`,
projectId: projectId || undefined,
type: 'read',
});
results.push(createResult);
console.log(
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
const clientId = createResult.data?.data?.id;
const clientSecret = createResult.data?.data?.secret;
if (clientId) {
console.log(` Created client: ${clientId}`);
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
}
// List clients
const listResult = await makeRequest(
'GET',
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
);
results.push(listResult);
console.log(
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} clients`);
}
if (clientId) {
// Get client
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
results.push(getResult);
console.log(
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
);
// Update client
const updateResult = await makeRequest(
'PATCH',
`/manage/clients/${clientId}`,
{
name: 'Updated Test Client',
},
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
);
// Delete client
const deleteResult = await makeRequest(
'DELETE',
`/manage/clients/${clientId}`,
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
);
}
}
async function testReferences(projectId?: string) {
console.log('\n📚 Testing References endpoints...\n');
if (!projectId) {
console.log(' ⚠️ Skipping references tests - no project ID available');
return;
}
// Create reference
const createResult = await makeRequest('POST', '/manage/references', {
projectId,
title: `Test Reference ${Date.now()}`,
description: 'This is a test reference',
datetime: new Date().toISOString(),
});
results.push(createResult);
console.log(
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
const referenceId = createResult.data?.data?.id;
if (referenceId) {
console.log(` Created reference: ${referenceId}`);
}
// List references
const listResult = await makeRequest(
'GET',
`/manage/references?projectId=${projectId}`,
);
results.push(listResult);
console.log(
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} references`);
}
if (referenceId) {
// Get reference
const getResult = await makeRequest(
'GET',
`/manage/references/${referenceId}`,
);
results.push(getResult);
console.log(
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
);
// Update reference
const updateResult = await makeRequest(
'PATCH',
`/manage/references/${referenceId}`,
{
title: 'Updated Test Reference',
description: 'Updated description',
datetime: new Date().toISOString(),
},
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
);
// Delete reference
const deleteResult = await makeRequest(
'DELETE',
`/manage/references/${referenceId}`,
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
);
}
}
async function main() {
console.log('🚀 Testing Manage API Endpoints\n');
console.log(`API Base URL: ${API_BASE_URL}`);
console.log(`Client ID: ${CLIENT_ID}\n`);
try {
// Test projects first (creates a project we can use for other tests)
const { projectId } = await testProjects();
// Test clients
await testClients(projectId);
// Test references (requires a project)
await testReferences(projectId);
// Summary
console.log(`\n${'='.repeat(60)}`);
console.log('📊 Test Summary\n');
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
console.log(`Total tests: ${results.length}`);
console.log(`✅ Successful: ${successful}`);
console.log(`❌ Failed: ${failed}\n`);
if (failed > 0) {
console.log('Failed tests:');
results
.filter((r) => !r.success)
.forEach((r) => {
console.log(`${r.name} (${r.status})`);
if (r.error) console.log(` Error: ${r.error}`);
});
}
} catch (error) {
console.error('Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);

View File

@@ -3,15 +3,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
import { generateId, slug } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo';
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import { getStringHeaders, getTimestamp } from './track.controller';
export async function postEvent(
request: FastifyRequest<{
Body: DeprecatedPostEventPayload;
Body: PostEventPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -13,7 +13,7 @@ import {
getSettingsForProject,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zReport } from '@openpanel/validation';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -139,7 +139,7 @@ export async function events(
});
}
const chartSchemeFull = zReport
const chartSchemeFull = zChartInputBase
.pick({
breakdowns: true,
interval: true,

View File

@@ -96,6 +96,8 @@ export async function getPages(
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
});
}
@@ -168,6 +170,8 @@ export function getOverviewGeneric(
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
}),
);
};

View File

@@ -1,649 +0,0 @@
import crypto from 'node:crypto';
import { HttpError } from '@/utils/errors';
import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server';
import {
db,
getClientByIdCached,
getId,
getProjectByIdCached,
} from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
// Validation schemas
const zCreateProject = z.object({
name: z.string().min(1),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
cors: z.array(z.string()).default([]),
crossDomain: z.boolean().optional().default(false),
types: z
.array(z.enum(['website', 'app', 'backend']))
.optional()
.default([]),
});
const zUpdateProject = z.object({
name: z.string().min(1).optional(),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
cors: z.array(z.string()).optional(),
crossDomain: z.boolean().optional(),
allowUnsafeRevenueTracking: z.boolean().optional(),
});
const zCreateClient = z.object({
name: z.string().min(1),
projectId: z.string().optional(),
type: z.enum(['read', 'write', 'root']).optional().default('write'),
});
const zUpdateClient = z.object({
name: z.string().min(1).optional(),
});
const zCreateReference = z.object({
projectId: z.string(),
title: z.string().min(1),
description: z.string().optional(),
datetime: z.string(),
});
const zUpdateReference = z.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
datetime: z.string().optional(),
});
// Projects CRUD
export async function listProjects(
request: FastifyRequest,
reply: FastifyReply,
) {
const projects = await db.project.findMany({
where: {
organizationId: request.client!.organizationId,
deleteAt: null,
},
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: projects });
}
export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const project = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
reply.send({ data: project });
}
export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply,
) {
const parsed = zCreateProject.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { name, domain, cors, crossDomain, types } = parsed.data;
// Generate a default client secret
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const clientData = {
organizationId: request.client!.organizationId,
name: 'First client',
type: 'write' as const,
secret: await hashPassword(secret),
};
const project = await db.project.create({
data: {
id: await getId('project', name),
organizationId: request.client!.organizationId,
name,
domain: domain ? stripTrailingSlash(domain) : null,
cors: cors.map((c) => stripTrailingSlash(c)),
crossDomain: crossDomain ?? false,
allowUnsafeRevenueTracking: false,
filters: [],
types,
clients: {
create: clientData,
},
},
include: {
clients: {
select: {
id: true,
},
},
},
});
// Clear cache
await Promise.all([
getProjectByIdCached.clear(project.id),
project.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
]);
reply.send({
data: {
...project,
client: project.clients[0]
? {
id: project.clients[0].id,
secret,
}
: null,
},
});
}
export async function updateProject(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateProject>;
}>,
reply: FastifyReply,
) {
const parsed = zUpdateProject.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify project exists and belongs to organization
const existing = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
include: {
clients: {
select: {
id: true,
},
},
},
});
if (!existing) {
throw new HttpError('Project not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.name !== undefined) {
updateData.name = parsed.data.name;
}
if (parsed.data.domain !== undefined) {
updateData.domain = parsed.data.domain
? stripTrailingSlash(parsed.data.domain)
: null;
}
if (parsed.data.cors !== undefined) {
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
}
if (parsed.data.crossDomain !== undefined) {
updateData.crossDomain = parsed.data.crossDomain;
}
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
updateData.allowUnsafeRevenueTracking =
parsed.data.allowUnsafeRevenueTracking;
}
const project = await db.project.update({
where: {
id: request.params.id,
},
data: updateData,
});
// Clear cache
await Promise.all([
getProjectByIdCached.clear(project.id),
existing.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
]);
reply.send({ data: project });
}
export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const project = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
await db.project.update({
where: {
id: request.params.id,
},
data: {
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
await getProjectByIdCached.clear(request.params.id);
reply.send({ success: true });
}
// Clients CRUD
export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply,
) {
const where: any = {
organizationId: request.client!.organizationId,
};
if (request.query.projectId) {
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: request.query.projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
where.projectId = request.query.projectId;
}
const clients = await db.client.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: clients });
}
export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const client = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!client) {
throw new HttpError('Client not found', { status: 404 });
}
reply.send({ data: client });
}
export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply,
) {
const parsed = zCreateClient.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { name, projectId, type } = parsed.data;
// If projectId is provided, verify it belongs to organization
if (projectId) {
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
}
// Generate secret
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const client = await db.client.create({
data: {
organizationId: request.client!.organizationId,
projectId: projectId || null,
name,
type: type || 'write',
secret: await hashPassword(secret),
},
});
await getClientByIdCached.clear(client.id);
reply.send({
data: {
...client,
secret, // Return plain secret only once
},
});
}
export async function updateClient(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateClient>;
}>,
reply: FastifyReply,
) {
const parsed = zUpdateClient.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify client exists and belongs to organization
const existing = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!existing) {
throw new HttpError('Client not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.name !== undefined) {
updateData.name = parsed.data.name;
}
const client = await db.client.update({
where: {
id: request.params.id,
},
data: updateData,
});
await getClientByIdCached.clear(client.id);
reply.send({ data: client });
}
export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const client = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!client) {
throw new HttpError('Client not found', { status: 404 });
}
await db.client.delete({
where: {
id: request.params.id,
},
});
await getClientByIdCached.clear(request.params.id);
reply.send({ success: true });
}
// References CRUD
export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply,
) {
const where: any = {};
if (request.query.projectId) {
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: request.query.projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
where.projectId = request.query.projectId;
} else {
// If no projectId, get all projects in org and filter references
const projects = await db.project.findMany({
where: {
organizationId: request.client!.organizationId,
},
select: { id: true },
});
where.projectId = {
in: projects.map((p) => p.id),
};
}
const references = await db.reference.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: references });
}
export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const reference = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!reference) {
throw new HttpError('Reference not found', { status: 404 });
}
if (reference.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
reply.send({ data: reference });
}
export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply,
) {
const parsed = zCreateReference.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { projectId, title, description, datetime } = parsed.data;
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
const reference = await db.reference.create({
data: {
projectId,
title,
description: description || null,
date: new Date(datetime),
},
});
reply.send({ data: reference });
}
export async function updateReference(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateReference>;
}>,
reply: FastifyReply,
) {
const parsed = zUpdateReference.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify reference exists and belongs to organization
const existing = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!existing) {
throw new HttpError('Reference not found', { status: 404 });
}
if (existing.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.title !== undefined) {
updateData.title = parsed.data.title;
}
if (parsed.data.description !== undefined) {
updateData.description = parsed.data.description ?? null;
}
if (parsed.data.datetime !== undefined) {
updateData.date = new Date(parsed.data.datetime);
}
const reference = await db.reference.update({
where: {
id: request.params.id,
},
data: updateData,
});
reply.send({ data: reference });
}
export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const reference = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!reference) {
throw new HttpError('Reference not found', { status: 404 });
}
if (reference.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
await db.reference.delete({
where: {
id: request.params.id,
},
});
reply.send({ success: true });
}

View File

@@ -118,11 +118,7 @@ async function fetchImage(
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return (
url.toLowerCase().endsWith('.ico') ||
contentType === 'image/x-icon' ||
contentType === 'image/vnd.microsoft.icon'
);
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
@@ -243,9 +239,7 @@ export async function getFavicon(
try {
const url = validateUrl(request.query.url);
if (!url) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
return reply.send(createFallbackImage());
return createFallbackImage();
}
const cacheKey = createCacheKey(url.toString());
@@ -266,65 +260,21 @@ export async function getFavicon(
} else {
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
logger.info('parseUrlMeta result', {
url: url.toString(),
favicon: meta?.favicon,
});
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Try standard favicon location first
const { origin } = url;
imageUrl = new URL(`${origin}/favicon.ico`);
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
}
}
logger.info('Fetching favicon', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
});
// Fetch the image
let { buffer, contentType, status } = await fetchImage(imageUrl);
const { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Favicon fetch result', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
status,
bufferLength: buffer.length,
contentType,
});
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
// try DuckDuckGo's favicon service as a fallback
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
const { hostname } = url;
const duckduckgoUrl = new URL(
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
);
logger.info('Trying DuckDuckGo favicon service', {
originalUrl: url.toString(),
duckduckgoUrl: duckduckgoUrl.toString(),
});
const duckduckgoResult = await fetchImage(duckduckgoUrl);
buffer = duckduckgoResult.buffer;
contentType = duckduckgoResult.contentType;
status = duckduckgoResult.status;
imageUrl = duckduckgoUrl;
logger.info('DuckDuckGo favicon result', {
status,
bufferLength: buffer.length,
contentType,
});
}
// Accept any response as long as we have valid image data
if (buffer.length === 0) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
}
@@ -335,31 +285,9 @@ export async function getFavicon(
contentType,
);
logger.info('Favicon processing result', {
originalUrl: url.toString(),
originalBufferLength: buffer.length,
processedBufferLength: processedBuffer.length,
});
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const isSvg = isSvgFile(imageUrl.toString(), contentType);
let responseContentType = contentType;
if (isIco) {
responseContentType = 'image/x-icon';
} else if (isSvg) {
responseContentType = 'image/svg+xml';
} else if (
processedBuffer.length < 5000 &&
buffer.length === processedBuffer.length
) {
// Image was returned as-is, keep original content type
responseContentType = contentType;
} else {
// Image was processed by Sharp, it's now a PNG
responseContentType = 'image/png';
}
const responseContentType = isIco ? 'image/x-icon' : contentType;
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);

View File

@@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import type {
DeprecatedIncrementProfilePayload,
DeprecatedUpdateProfilePayload,
} from '@openpanel/validation';
IncrementProfilePayload,
UpdateProfilePayload,
} from '@openpanel/sdk';
export async function updateProfile(
request: FastifyRequest<{
Body: DeprecatedUpdateProfilePayload;
Body: UpdateProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -52,7 +52,7 @@ export async function updateProfile(
export async function incrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
Body: IncrementProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
export async function decrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
Body: IncrementProfilePayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,22 +1,19 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { HttpError } from '@/utils/errors';
import { generateId, slug } 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 { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache, getRedisQueue } from '@openpanel/redis';
import {
type IDecrementPayload,
type IIdentifyPayload,
type IIncrementPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
import { getRedisCache } from '@openpanel/redis';
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
TrackPayload,
} from '@openpanel/sdk';
export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries(
@@ -39,28 +36,25 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
);
}
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
if (body.type === 'track') {
const identity = body.payload.properties?.__identify as
| IIdentifyPayload
| undefined;
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
return (
identity ||
(body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
: undefined)
);
}
return undefined;
return (
identity ||
(body?.payload?.profileId
? {
profileId: body.payload.profileId,
}
: undefined)
);
}
export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: ITrackHandlerPayload['payload'],
payload: TrackHandlerPayload['payload'],
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
@@ -87,7 +81,7 @@ export function getTimestamp(
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
// isTimestampFromThePast is true only if timestamp is older than 1 hour
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
@@ -97,113 +91,163 @@ export function getTimestamp(
};
}
interface TrackContext {
projectId: string;
ip: string;
ua?: string;
headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload;
currentDeviceId?: string;
previousDeviceId?: string;
geo: GeoLocation;
}
async function buildContext(
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
Body: TrackHandlerPayload;
}>,
validatedBody: ITrackHandlerPayload,
): Promise<TrackContext> {
const projectId = request.client?.projectId;
if (!projectId) {
throw new HttpError('Missing projectId', { status: 400 });
}
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
reply: FastifyReply,
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
? (validatedBody.payload.properties.__ip as string)
'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 headers = getStringHeaders(request.headers);
const projectId = request.client?.projectId;
const identity = getIdentity(validatedBody);
if (!projectId) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
}
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
if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId;
if (profileId) {
request.body.payload.profileId = profileId;
}
// Get geo location (needed for track and identify)
const geo = await getGeoLocation(ip);
// Generate device IDs if needed (for track)
let currentDeviceId: string | undefined;
let previousDeviceId: string | undefined;
if (validatedBody.type === 'track') {
const overrideDeviceId =
typeof validatedBody.payload.properties?.__deviceId === 'string'
? validatedBody.payload.properties.__deviceId
: undefined;
const [salts] = await Promise.all([getSalts()]);
currentDeviceId =
overrideDeviceId ||
(ua
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const previousDeviceId = ua
? generateDeviceId({
salt: salts.current,
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '');
previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
: '';
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
if (identity && Object.keys(identity).length > 1) {
promises.push(
identify({
payload: identity,
projectId,
geo,
ua,
}),
);
}
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises);
break;
}
case 'identify': {
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
projectId,
geo,
ua,
});
break;
}
case 'alias': {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
case 'increment': {
await increment({
payload: request.body.payload,
projectId,
});
break;
}
case 'decrement': {
await decrement({
payload: request.body.payload,
projectId,
});
break;
}
default: {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
}
return {
projectId,
ip,
ua,
headers,
timestamp: {
value: timestamp.timestamp,
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
currentDeviceId,
previousDeviceId,
geo,
};
reply.status(200).send();
}
async function handleTrack(
payload: ITrackPayload,
context: TrackContext,
): Promise<void> {
const {
projectId,
currentDeviceId,
previousDeviceId,
geo,
headers,
timestamp,
} = context;
if (!currentDeviceId || !previousDeviceId) {
throw new HttpError('Device ID generation failed', { status: 500 });
}
async function track({
payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers,
timestamp,
isTimestampFromThePast,
}: {
payload: TrackPayload;
currentDeviceId: string;
previousDeviceId: string;
projectId: string;
geo: GeoLocation;
headers: Record<string, string | undefined>;
timestamp: number;
isTimestampFromThePast: boolean;
}) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
@@ -212,51 +256,44 @@ async function handleTrack(
: currentDeviceId;
const jobId = [
slug(payload.name),
timestamp.value,
timestamp,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
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
if (context.identity && Object.keys(context.identity).length > 1) {
promises.push(handleIdentify(context.identity, context));
}
promises.push(
getEventsGroupQueueShard(groupId).add({
orderMs: timestamp.value,
data: {
projectId,
headers,
event: {
...payload,
timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
},
groupId,
jobId,
}),
);
await Promise.all(promises);
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
}
async function handleIdentify(
payload: IIdentifyPayload,
context: TrackContext,
): Promise<void> {
const { projectId, geo, ua } = context;
async function identify({
payload,
projectId,
geo,
ua,
}: {
payload: IdentifyPayload;
projectId: string;
geo: GeoLocation;
ua?: string;
}) {
const uaInfo = parseUserAgent(ua, payload.properties);
await upsertProfile({
...payload,
@@ -281,15 +318,17 @@ async function handleIdentify(
});
}
async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload,
projectId: string,
direction: 1 | -1,
): Promise<void> {
async function increment({
payload,
projectId,
}: {
payload: IncrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new HttpError('Profile not found', { status: 404 });
throw new Error('Not found');
}
const parsed = Number.parseInt(
@@ -298,12 +337,12 @@ async function adjustProfileProperty(
);
if (Number.isNaN(parsed)) {
throw new HttpError('Property value is not a number', { status: 400 });
throw new Error('Not number');
}
profile.properties = assocPath(
property.split('.'),
parsed + direction * (value || 1),
parsed + (value || 1),
profile.properties,
);
@@ -315,74 +354,40 @@ async function adjustProfileProperty(
});
}
async function handleIncrement(
payload: IIncrementPayload,
context: TrackContext,
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1);
}
async function handleDecrement(
payload: IDecrementPayload,
context: TrackContext,
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, -1);
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
reply: FastifyReply,
) {
// Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body);
if (!validationResult.success) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Validation failed',
errors: validationResult.error.errors,
});
async function decrement({
payload,
projectId,
}: {
payload: DecrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new Error('Not found');
}
const validatedBody = validationResult.data;
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
);
// Handle alias (not supported)
if (validatedBody.type === 'alias') {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
if (Number.isNaN(parsed)) {
throw new Error('Not number');
}
// Build request context
const context = await buildContext(request, validatedBody);
profile.properties = assocPath(
property.split('.'),
parsed - (value || 1),
profile.properties,
);
// Dispatch to appropriate handler
switch (validatedBody.type) {
case 'track':
await handleTrack(validatedBody.payload, context);
break;
case 'identify':
await handleIdentify(validatedBody.payload, context);
break;
case 'increment':
await handleIncrement(validatedBody.payload, context);
break;
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
reply.status(200).send();
await upsertProfile({
id: profile.id,
projectId,
properties: profile.properties,
isExternal: true,
});
}
export async function fetchDeviceId(
@@ -419,7 +424,7 @@ export async function fetchDeviceId(
});
try {
const multi = getRedisQueue().multi();
const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec();

View File

@@ -191,9 +191,7 @@ export async function polarWebhook(
where: {
subscriptionCustomerId: event.data.customer.id,
subscriptionId: event.data.id,
subscriptionStatus: {
in: ['active', 'past_due', 'unpaid'],
},
subscriptionStatus: 'active',
},
});

View File

@@ -1,13 +1,10 @@
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function clientHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,13 +1,10 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function duplicateHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,15 +1,17 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
type DeprecatedEventPayload = {
name: string;
properties: Record<string, unknown>;
timestamp: string;
};
export async function isBotHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: TrackHandlerPayload | DeprecatedEventPayload;
}>,
reply: FastifyReply,
) {
@@ -44,6 +46,6 @@ export async function isBotHook(
}
}
return reply.status(202).send();
return reply.status(202).send('OK');
}
}

View File

@@ -38,7 +38,6 @@ import exportRouter from './routes/export.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
@@ -144,7 +143,7 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const sessionId = decodeSessionToken(req.cookies?.session);
const sessionId = decodeSessionToken(req.cookies.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
);
@@ -152,15 +151,6 @@ const startServer = async () => {
} catch (e) {
req.session = EMPTY_SESSION;
}
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null),
);
req.session = session;
} catch (e) {
req.session = EMPTY_SESSION;
}
} else {
req.session = EMPTY_SESSION;
}
@@ -204,7 +194,6 @@ const startServer = async () => {
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
instance.register(manageRouter, { prefix: '/manage' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
// New Kubernetes-style health endpoints

View File

@@ -1,132 +0,0 @@
import * as controller from '@/controllers/manage.controller';
import { validateManageRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const manageRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({
fastify,
max: 20,
timeWindow: '10 seconds',
});
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
try {
const client = await validateManageRequest(req.headers);
req.client = client;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'Client ID seems to be malformed',
});
}
if (e instanceof Error) {
return reply
.status(401)
.send({ error: 'Unauthorized', message: e.message });
}
return reply
.status(401)
.send({ error: 'Unauthorized', message: 'Unexpected error' });
}
});
// Projects routes
fastify.route({
method: 'GET',
url: '/projects',
handler: controller.listProjects,
});
fastify.route({
method: 'GET',
url: '/projects/:id',
handler: controller.getProject,
});
fastify.route({
method: 'POST',
url: '/projects',
handler: controller.createProject,
});
fastify.route({
method: 'PATCH',
url: '/projects/:id',
handler: controller.updateProject,
});
fastify.route({
method: 'DELETE',
url: '/projects/:id',
handler: controller.deleteProject,
});
// Clients routes
fastify.route({
method: 'GET',
url: '/clients',
handler: controller.listClients,
});
fastify.route({
method: 'GET',
url: '/clients/:id',
handler: controller.getClient,
});
fastify.route({
method: 'POST',
url: '/clients',
handler: controller.createClient,
});
fastify.route({
method: 'PATCH',
url: '/clients/:id',
handler: controller.updateClient,
});
fastify.route({
method: 'DELETE',
url: '/clients/:id',
handler: controller.deleteClient,
});
// References routes
fastify.route({
method: 'GET',
url: '/references',
handler: controller.listReferences,
});
fastify.route({
method: 'GET',
url: '/references/:id',
handler: controller.getReference,
});
fastify.route({
method: 'POST',
url: '/references',
handler: controller.createReference,
});
fastify.route({
method: 'PATCH',
url: '/references/:id',
handler: controller.updateReference,
});
fastify.route({
method: 'DELETE',
url: '/references/:id',
handler: controller.deleteReference,
});
};
export default manageRouter;

View File

@@ -14,6 +14,22 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
method: 'POST',
url: '/',
handler: handler,
schema: {
body: {
type: 'object',
required: ['type', 'payload'],
properties: {
type: {
type: 'string',
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
},
payload: {
type: 'object',
additionalProperties: true,
},
},
},
},
});
fastify.route({

View File

@@ -9,7 +9,7 @@ import {
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { zReportInput } from '@openpanel/validation';
import { zChartInputAI } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
@@ -27,10 +27,7 @@ export function getReport({
- ${chartTypes.metric}
- ${chartTypes.bar}
`,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
@@ -75,10 +72,7 @@ export function getConversionReport({
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
@@ -100,10 +94,7 @@ export function getFunnelReport({
return tool({
description:
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',

View File

@@ -4,11 +4,10 @@ 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 {
DeprecatedPostEventPayload,
IProjectFilterIp,
IProjectFilterProfileId,
ITrackHandlerPayload,
} from '@openpanel/validation';
import { path } from 'ramda';
@@ -42,7 +41,7 @@ export class SdkAuthError extends Error {
export async function validateSdkRequest(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req;
@@ -236,40 +235,3 @@ export async function validateImportRequest(
return client;
}
export async function validateManageRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
)
) {
throw new Error('Manage: Client ID must be a valid UUIDv4');
}
const client = await getClientByIdCached(clientId);
if (!client) {
throw new Error('Manage: Invalid client id');
}
if (!client.secret) {
throw new Error('Manage: Client has no secret');
}
if (client.type !== ClientType.root) {
throw new Error(
'Manage: Only root clients are allowed to manage resources',
);
}
if (!(await verifyPassword(clientSecret, client.secret))) {
throw new Error('Manage: Invalid client secret');
}
return client;
}

View File

@@ -1,13 +1,7 @@
import urlMetadata from 'url-metadata';
function fallbackFavicon(url: string) {
try {
const hostname = new URL(url).hostname;
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
} catch {
// If URL parsing fails, use the original string
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
}
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,505 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
<meta name="author" content="OpenPanel">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta property="og:image" content="/ogimage.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="twitter:image" content="/ogimage.png">
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #e5e5e5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 18px;
line-height: 1.75;
padding: 2rem 1.5rem;
}
.container {
max-width: 700px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.5rem;
color: #ffffff;
}
h2 {
font-size: 1.875rem;
font-weight: 800;
line-height: 1.3;
margin-top: 3rem;
margin-bottom: 1.5rem;
color: #ffffff;
}
h3 {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.4;
margin-top: 2rem;
margin-bottom: 1rem;
color: #ffffff;
}
p {
margin-bottom: 1.25em;
}
strong {
font-weight: 700;
color: #ffffff;
}
a {
color: #3b82f6;
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover {
color: #60a5fa;
}
ul, ol {
margin-left: 1.5rem;
margin-bottom: 1.25em;
}
li {
margin-bottom: 0.5em;
}
blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1.5rem;
margin: 1.5rem 0;
font-style: italic;
color: #d1d5db;
}
table {
width: 100%;
border-collapse: collapse;
margin: 2rem 0;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #374151;
}
th {
font-weight: 700;
color: #ffffff;
background: #131313;
}
tr:hover {
background: #131313;
}
.screenshot {
margin: 0 -4rem 4rem;
position: relative;
z-index: 10;
}
@media (max-width: 840px) {
.screenshot {
margin: 0;
}
}
.screenshot-inner {
border-radius: 8px;
overflow: hidden;
padding: 0.5rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
}
.window-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0 0.25rem;
}
.window-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.window-dot.red {
background: #ef4444;
}
.window-dot.yellow {
background: #eab308;
}
.window-dot.green {
background: #22c55e;
}
.screenshot-image-wrapper {
width: 100%;
border: 1px solid #2a2a2a;
border-radius: 6px;
overflow: hidden;
background: #0a0a0a;
}
.screenshot img {
width: 100%;
height: auto;
display: block;
}
.cta {
background: #131313;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 2rem;
margin: 3rem 0;
text-align: center;
margin: 0 -4rem 4rem;
}
@media (max-width: 840px) {
.cta {
margin: 0;
}
}
.cta h2 {
margin-top: 0;
}
.cta a {
display: inline-block;
background: #fff;
color: #000;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin: 0.5rem;
transition: background 0.2s;
}
.cta a:hover {
background: #fff;
color: #000;
}
footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid #374151;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
.hero {
text-align: left;
margin-top: 4rem;
line-height: 1.5;
}
.hero p {
font-size: 1.25rem;
color: #8f8f8f;
margin-top: 1rem;
}
figcaption {
margin-top: 1rem;
font-size: 0.875rem;
text-align: center;
color: #9ca3af;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>Just Fucking Use OpenPanel</h1>
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
</div>
</div>
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
</figure>
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
<ul>
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
</ul>
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
<h2>The Web-Only Analytics Trap</h2>
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
<blockquote>
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
</blockquote>
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
<h2>Counter One Dollar Stats</h2>
<p>"$1/month for page views. Adorable."</p>
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
<p>You get:</p>
<ul>
<li>Funnels to see where users drop off</li>
<li>Retention analysis to see who comes back</li>
<li>Cohorts to segment your users</li>
<li>User profiles to understand individual behavior</li>
<li>Custom dashboards to see what matters to YOU</li>
<li>Revenue tracking to see what actually makes money</li>
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
</ul>
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
<h2>Why OpenPanel is the Answer</h2>
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
<ul>
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
</ul>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
</div>
</div>
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
</figure>
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
<h2>The Comparison Table (The Brutal Truth)</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Price at 20M events</th>
<th>What You Get</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mixpanel</strong></td>
<td>$2,300+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>PostHog</strong></td>
<td>$1,982+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>Plausible</strong></td>
<td>Various pricing</td>
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
</tr>
<tr>
<td><strong>One Dollar Stats</strong></td>
<td>$1/month</td>
<td>Page views (but cheaper!)</td>
</tr>
<tr style="background: #131313; border: 2px solid #3b82f6;">
<td><strong>OpenPanel</strong></td>
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
</tr>
</tbody>
</table>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
</div>
</div>
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
</figure>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
</div>
</div>
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
</figure>
<h2>The Bottom Fucking Line</h2>
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
<p>You have three choices:</p>
<ol>
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
</ol>
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
<div class="cta">
<h2>Ready to understand what your users actually do?</h2>
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
</div>
</div>
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
</figure>
<footer>
<p><strong>Just Fucking Use OpenPanel</strong></p>
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
</footer>
</div>
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -1,7 +0,0 @@
{
"name": "justfuckinguseopenpanel",
"compatibility_date": "2025-12-19",
"assets": {
"directory": "."
}
}

View File

@@ -1,256 +0,0 @@
---
title: Nuxt
---
import Link from 'next/link';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Nuxt analytics guide](/guides/nuxt-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!
Read more about server side tracking in the [Server Side Tracking](#track-server-events) section.
## Installation
<Steps>
### Install dependencies
```bash
pnpm install @openpanel/nuxt
```
### Initialize
Add the module to your `nuxt.config.ts`:
```typescript
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
},
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
##### Nuxt options
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
- `proxy` - Enable server-side proxy to avoid adblockers (default: `false`)
</Steps>
## Usage
### Using the composable
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component:
```vue
<script setup>
const op = useOpenPanel(); // Auto-imported!
function handleClick() {
op.track('button_click', { button: 'signup' });
}
</script>
<template>
<button @click="handleClick">Trigger event</button>
</template>
```
### Accessing via useNuxtApp
You can also access the OpenPanel instance directly via `useNuxtApp()`:
```vue
<script setup>
const { $openpanel } = useNuxtApp();
$openpanel.track('my_event', { foo: 'bar' });
</script>
```
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```vue
<script setup>
const op = useOpenPanel();
op.track('my_event', { foo: 'bar' });
</script>
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```vue
<script setup>
const op = useOpenPanel();
op.identify({
profileId: '123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
</script>
```
### Setting Global Properties
To set properties that will be sent with every event:
```vue
<script setup>
const op = useOpenPanel();
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
</script>
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```vue
<script setup>
const op = useOpenPanel();
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```vue
<script setup>
const op = useOpenPanel();
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Clearing User Data
To clear the current user's data:
```vue
<script setup>
const op = useOpenPanel();
op.clear();
</script>
```
## Server side
If you want to track server-side events, you should create an instance of our Javascript SDK. Import `OpenPanel` from `@openpanel/sdk`
<Callout>
When using server events it's important that you use a secret to authenticate the request. This is to prevent unauthorized requests since we cannot use cors headers.
You can use the same clientId but you should pass the associated client secret to the SDK.
</Callout>
```typescript
import { OpenPanel } from '@openpanel/sdk';
const opServer = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
});
opServer.track('my_server_event', { ok: '✅' });
// Pass `profileId` to track events for a specific user
opServer.track('my_server_event', { profileId: '123', ok: '✅' });
```
### Serverless & Edge Functions
If you log events in a serverless environment, make sure to await the event call to ensure it completes before the function terminates.
```typescript
import { OpenPanel } from '@openpanel/sdk';
const opServer = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
});
export default defineEventHandler(async (event) => {
// Await to ensure event is logged before function completes
await opServer.track('my_server_event', { foo: 'bar' });
return { message: 'Event logged!' };
});
```
### Proxy events
With the `proxy` option enabled, you can proxy your events through your server, which ensures all events are tracked since many adblockers block requests to third-party domains.
```typescript title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
proxy: true, // Enables proxy at /api/openpanel/*
},
});
```
When `proxy: true` is set:
- The module automatically sets `apiUrl` to `/api/openpanel`
- A server handler is registered at `/api/openpanel/**`
- All tracking requests route through your server
This helps bypass adblockers that might block requests to `api.openpanel.dev`.

View File

@@ -2,244 +2,4 @@
title: React
---
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
## Good to know
Keep in mind that all tracking here happens on the client!
For React SPAs, you can use `@openpanel/web` directly - no need for a separate React SDK. Simply create an OpenPanel instance and use it throughout your application.
## Installation
<Steps>
### Step 1: Install
```bash
npm install @openpanel/web
```
### Step 2: Initialize
Create a shared OpenPanel instance in your project:
```ts title="src/openpanel.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
### Step 3: Usage
Import and use the instance in your React components:
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleClick = () => {
op.track('button_click', { button: 'signup' });
};
return <button onClick={handleClick}>Trigger event</button>;
}
```
</Steps>
## Usage
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
useEffect(() => {
op.track('my_event', { foo: 'bar' });
}, []);
return <div>My Component</div>;
}
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```tsx
import { op } from '@/openpanel';
function LoginComponent() {
const handleLogin = (user: User) => {
op.identify({
profileId: user.id, // Required
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
tier: 'premium',
},
});
};
return <button onClick={() => handleLogin(user)}>Login</button>;
}
```
### Setting Global Properties
To set properties that will be sent with every event:
```tsx
import { op } from '@/openpanel';
function App() {
useEffect(() => {
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
}, []);
return <div>App</div>;
}
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleAction = () => {
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
};
return <button onClick={handleAction}>Increment</button>;
}
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleAction = () => {
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
};
return <button onClick={handleAction}>Decrement</button>;
}
```
### Clearing User Data
To clear the current user's data:
```tsx
import { op } from '@/openpanel';
function LogoutComponent() {
const handleLogout = () => {
op.clear();
// ... logout logic
};
return <button onClick={handleLogout}>Logout</button>;
}
```
### Revenue Tracking
Track revenue events:
```tsx
import { op } from '@/openpanel';
function CheckoutComponent() {
const handlePurchase = async () => {
// Track revenue immediately
await op.revenue(29.99, { currency: 'USD' });
// Or accumulate revenue and flush later
op.pendingRevenue(29.99, { currency: 'USD' });
op.pendingRevenue(19.99, { currency: 'USD' });
await op.flushRevenue(); // Sends both revenue events
// Clear pending revenue
op.clearRevenue();
};
return <button onClick={handlePurchase}>Purchase</button>;
}
```
### Optional: Create a Hook
If you prefer using a React hook pattern, you can create your own wrapper:
```ts title="src/hooks/useOpenPanel.ts"
import { op } from '@/openpanel';
export function useOpenPanel() {
return op;
}
```
Then use it in your components:
```tsx
import { useOpenPanel } from '@/hooks/useOpenPanel';
function MyComponent() {
const op = useOpenPanel();
useEffect(() => {
op.track('my_event', { foo: 'bar' });
}, []);
return <div>My Component</div>;
}
```
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated react sdk soon.

View File

@@ -216,4 +216,3 @@ tracker = OpenPanel::SDK::Tracker.new(
)
```

View File

@@ -2,219 +2,4 @@
title: Vue
---
import Link from 'next/link';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Vue analytics guide](/guides/vue-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!
For Vue SPAs, you can use `@openpanel/web` directly - no need for a separate Vue SDK. Simply create an OpenPanel instance and use it throughout your application.
## Installation
<Steps>
### Step 1: Install
```bash
pnpm install @openpanel/web
```
### Step 2: Initialize
Create a shared OpenPanel instance in your project:
```ts title="src/openpanel.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
### Step 3: Usage
Import and use the instance in your Vue components:
```vue
<script setup>
import { op } from '@/openpanel';
function handleClick() {
op.track('button_click', { button: 'signup' });
}
</script>
<template>
<button @click="handleClick">Trigger event</button>
</template>
```
</Steps>
## Usage
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```vue
<script setup>
import { op } from '@/openpanel';
op.track('my_event', { foo: 'bar' });
</script>
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```vue
<script setup>
import { op } from '@/openpanel';
op.identify({
profileId: '123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
</script>
```
### Setting Global Properties
To set properties that will be sent with every event:
```vue
<script setup>
import { op } from '@/openpanel';
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
</script>
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```vue
<script setup>
import { op } from '@/openpanel';
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```vue
<script setup>
import { op } from '@/openpanel';
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Clearing User Data
To clear the current user's data:
```vue
<script setup>
import { op } from '@/openpanel';
op.clear();
</script>
```
### Revenue Tracking
Track revenue events:
```vue
<script setup>
import { op } from '@/openpanel';
// Track revenue immediately
await op.revenue(29.99, { currency: 'USD' });
// Or accumulate revenue and flush later
op.pendingRevenue(29.99, { currency: 'USD' });
op.pendingRevenue(19.99, { currency: 'USD' });
await op.flushRevenue(); // Sends both revenue events
// Clear pending revenue
op.clearRevenue();
</script>
```
### Optional: Create a Composable
If you prefer using a composable pattern, you can create your own wrapper:
```ts title="src/composables/useOpenPanel.ts"
import { op } from '@/openpanel';
export function useOpenPanel() {
return op;
}
```
Then use it in your components:
```vue
<script setup>
import { useOpenPanel } from '@/composables/useOpenPanel';
const op = useOpenPanel();
op.track('my_event', { foo: 'bar' });
</script>
```
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated vue sdk soon.

View File

@@ -7,12 +7,11 @@ description: Learn how to authenticate with the OpenPanel API using client crede
To authenticate with the OpenPanel API, you need to use your `clientId` and `clientSecret`. Different API endpoints may require different access levels:
- **Track API**: Default client works with `write` mode
- **Track API**: Default client works with `track` mode
- **Export API**: Requires `read` or `root` mode
- **Insights API**: Requires `read` or `root` mode
- **Manage API**: Requires `root` mode only
The default client (created with a project) has `write` mode and does not have access to the Export, Insights, or Manage APIs. You'll need to create additional clients with appropriate access levels.
The default client does not have access to the Export or Insights APIs.
## Headers
@@ -49,29 +48,15 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
Common authentication errors:
- Invalid client ID or secret
- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client)
- Malformed client ID (must be a valid UUIDv4)
- Client type mismatch (e.g., `write` client trying to access Export API)
## Client Types
OpenPanel supports three client types with different access levels:
| Type | Description | Access |
|------|-------------|--------|
| `write` | Write access | Track API only |
| `read` | Read-only access | Export API, Insights API |
| `root` | Full access | All APIs including Manage API |
**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely.
- Client doesn't have required permissions
- Malformed client ID
## Rate Limiting
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
- **Track API**: Higher limits for event tracking
- **Export/Insights APIs**: 100 requests per 10 seconds
- **Manage API**: 20 requests per 10 seconds
- **Export/Insights APIs**: Lower limits for data retrieval
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.

View File

@@ -1,332 +0,0 @@
---
title: Clients
description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels.
---
## Authentication
To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Clients API requests should be made to:
```
https://api.openpanel.dev/manage/clients
```
## Client Types
OpenPanel supports three client types with different access levels:
| Type | Description | Use Case |
|------|-------------|----------|
| `read` | Read-only access | Export data, view insights, read-only operations |
| `write` | Write access | Track events, send data to OpenPanel |
| `root` | Full access | Manage resources, access Manage API |
**Note**: Only `root` clients can access the Manage API.
## Endpoints
### List Clients
Retrieve all clients in your organization, optionally filtered by project.
```
GET /manage/clients
```
#### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `projectId` | string | Optional. Filter clients by project ID |
#### Example Request
```bash
# List all clients
curl 'https://api.openpanel.dev/manage/clients' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
# List clients for a specific project
curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"name": "First client",
"type": "write",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
},
{
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "Read-only Client",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z"
}
]
}
```
**Note**: Client secrets are never returned in list or get responses for security reasons.
### Get Client
Retrieve a specific client by ID.
```
GET /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"name": "First client",
"type": "write",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
}
```
### Create Client
Create a new API client. A secure secret is automatically generated and returned once.
```
POST /manage/clients
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Client name (minimum 1 character) |
| `projectId` | string | No | Associate client with a specific project |
| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) |
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/clients' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "My API Client",
"projectId": "my-project",
"type": "read"
}'
```
#### Response
```json
{
"data": {
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "My API Client",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"secret": "sec_b2521ca283bf903b46b3"
}
}
```
**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client.
### Update Client
Update an existing client's name.
```
PATCH /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | No | New client name (minimum 1 character) |
**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client.
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "Updated Client Name"
}'
```
#### Response
```json
{
"data": {
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "Updated Client Name",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:30:00.000Z"
}
}
```
### Delete Client
Permanently delete a client. This action cannot be undone.
```
DELETE /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client.
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Client not found"
}
```
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The Clients API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Security Best Practices
1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems
2. **Use Appropriate Client Types**: Use the minimum required access level for each use case
3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets
4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code
5. **Monitor Client Usage**: Regularly review and remove unused clients
## Notes
- Client IDs are UUIDs (Universally Unique Identifiers)
- Client secrets are automatically generated with the format `sec_` followed by random hex characters
- Secrets are hashed using argon2 before storage
- Clients can be associated with a project or exist at the organization level
- Clients are scoped to your organization - you can only manage clients in your organization
- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution)

View File

@@ -1,140 +0,0 @@
---
title: Manage API Overview
description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API.
---
## Overview
The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks.
## Authentication
The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization.
To authenticate with the Manage API, you need:
- A client with `type: 'root'`
- Your `clientId` and `clientSecret`
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Manage API requests should be made to:
```
https://api.openpanel.dev/manage
```
## Available Resources
The Manage API provides CRUD operations for three resource types:
### Projects
Manage your analytics projects programmatically:
- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects
- Automatically creates a default write client when creating a project
- Supports project configuration including domains, CORS settings, and project types
### Clients
Manage API clients for your projects:
- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients
- Supports different client types: `read`, `write`, and `root`
- Auto-generates secure secrets on creation (returned once)
### References
Manage reference points for your analytics:
- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references
- Useful for marking important dates or events in your analytics timeline
- Can be filtered by project
## Common Features
All endpoints share these common characteristics:
### Organization Scope
All operations are scoped to your organization. You can only manage resources that belong to your organization.
### Response Format
Successful responses follow this structure:
```json
{
"data": {
// Resource data
}
}
```
For list endpoints:
```json
{
"data": [
// Array of resources
]
}
```
### Error Handling
The API uses standard HTTP response codes:
- `200 OK` - Request successful
- `400 Bad Request` - Invalid request parameters
- `401 Unauthorized` - Authentication failed
- `404 Not Found` - Resource not found
- `429 Too Many Requests` - Rate limit exceeded
## Rate Limiting
The Manage API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Use Cases
The Manage API is ideal for:
- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure
- **Automation**: Automatically create projects and clients for new deployments
- **Bulk Operations**: Programmatically manage multiple resources
- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline
- **Administrative Tools**: Build custom admin interfaces
## Security Best Practices
1. **Root Clients Only**: Only root clients can access the Manage API
2. **Store Credentials Securely**: Never expose root client credentials in client-side code
3. **Use HTTPS**: Always use HTTPS for API requests
4. **Rotate Credentials**: Regularly rotate your root client credentials
5. **Limit Access**: Restrict root client creation to trusted administrators
## Getting Started
1. **Create a Root Client**: Use the dashboard to create a root client in your organization
2. **Store Credentials**: Securely store your root client ID and secret
3. **Make Your First Request**: Start with listing projects to verify authentication
Example:
```bash
curl 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
## Next Steps
- Read the [Projects documentation](/docs/api/manage/projects) to manage projects
- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients
- Read the [References documentation](/docs/api/manage/references) to manage reference points

View File

@@ -1,5 +0,0 @@
{
"title": "Manage",
"pages": ["projects", "clients", "references"],
"defaultOpen": false
}

View File

@@ -1,327 +0,0 @@
---
title: Projects
description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API.
---
## Authentication
To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Projects API requests should be made to:
```
https://api.openpanel.dev/manage/projects
```
## Endpoints
### List Projects
Retrieve all projects in your organization.
```
GET /manage/projects
```
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "my-project",
"name": "My Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null
}
]
}
```
### Get Project
Retrieve a specific project by ID.
```
GET /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "my-project",
"name": "My Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null
}
}
```
### Create Project
Create a new project in your organization. A default write client is automatically created with the project.
```
POST /manage/projects
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Project name (minimum 1 character) |
| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) |
| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) |
| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) |
| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) |
#### Project Types
- `website`: Web-based project
- `app`: Mobile application
- `backend`: Backend/server-side project
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "My New Project",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"types": ["website"]
}'
```
#### Response
```json
{
"data": {
"id": "my-new-project",
"name": "My New Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null,
"client": {
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"secret": "sec_6c8ae85a092d6c66b242"
}
}
}
```
**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately.
### Update Project
Update an existing project's configuration.
```
PATCH /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Request Body
All fields are optional. Only include fields you want to update.
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Project name (minimum 1 character) |
| `domain` | string \| null | Primary domain (URL format, empty string, or null) |
| `cors` | string[] | Array of allowed CORS origins |
| `crossDomain` | boolean | Enable cross-domain tracking |
| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret |
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "Updated Project Name",
"crossDomain": true,
"allowUnsafeRevenueTracking": false
}'
```
#### Response
```json
{
"data": {
"id": "my-project",
"name": "Updated Project Name",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com"],
"crossDomain": true,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"deleteAt": null
}
}
```
### Delete Project
Soft delete a project. The project will be scheduled for deletion after 24 hours.
```
DELETE /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time.
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Project not found"
}
```
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The Projects API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Notes
- Project IDs are automatically generated from the project name using a slug format
- If a project ID already exists, a numeric suffix is added
- CORS domains are automatically normalized (trailing slashes removed)
- The default client created with a project has `type: 'write'`
- Projects are scoped to your organization - you can only manage projects in your organization
- Soft-deleted projects are excluded from list endpoints

View File

@@ -1,344 +0,0 @@
---
title: References
description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline.
---
## Authentication
To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All References API requests should be made to:
```
https://api.openpanel.dev/manage/references
```
## What are References?
References are markers you can add to your analytics timeline to track important events such as:
- Product launches
- Marketing campaign start dates
- Feature releases
- Website redesigns
- Major announcements
References appear in your analytics charts and help you correlate changes in metrics with specific events.
## Endpoints
### List References
Retrieve all references in your organization, optionally filtered by project.
```
GET /manage/references
```
#### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `projectId` | string | Optional. Filter references by project ID |
#### Example Request
```bash
# List all references
curl 'https://api.openpanel.dev/manage/references' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
# List references for a specific project
curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
},
{
"id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96",
"title": "Marketing Campaign Start",
"description": "Q1 2024 campaign launched",
"date": "2024-01-20T09:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-18T10:00:00.000Z",
"updatedAt": "2024-01-18T10:00:00.000Z"
}
]
}
```
### Get Reference
Retrieve a specific reference by ID.
```
GET /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
}
}
```
### Create Reference
Create a new reference point for a project.
```
POST /manage/references
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `projectId` | string | Yes | The ID of the project this reference belongs to |
| `title` | string | Yes | Reference title (minimum 1 character) |
| `description` | string | No | Optional description or notes |
| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) |
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/references' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"projectId": "my-project",
"title": "Product Launch",
"description": "Version 2.0 released with new features",
"datetime": "2024-01-15T10:00:00.000Z"
}'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released with new features",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
}
}
```
**Note**: The `date` field in the response is parsed from the `datetime` string you provided.
### Update Reference
Update an existing reference.
```
PATCH /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Request Body
All fields are optional. Only include fields you want to update.
| Parameter | Type | Description |
|-----------|------|-------------|
| `title` | string | Reference title (minimum 1 character) |
| `description` | string \| null | Description or notes (set to `null` to clear) |
| `datetime` | string | Date and time for the reference (ISO 8601 format) |
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"title": "Product Launch v2.1",
"description": "Updated: Version 2.1 released with bug fixes",
"datetime": "2024-01-15T10:00:00.000Z"
}'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch v2.1",
"description": "Updated: Version 2.1 released with bug fixes",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T09:30:00.000Z"
}
}
```
### Delete Reference
Permanently delete a reference. This action cannot be undone.
```
DELETE /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["title"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Reference not found"
}
```
This error can occur if:
- The reference ID doesn't exist
- The reference belongs to a different organization
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The References API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Date Format
References use ISO 8601 date format. Examples:
- `2024-01-15T10:00:00.000Z` - UTC timezone
- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5)
- `2024-01-15` - Date only (time defaults to 00:00:00)
The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp.
## Use Cases
References are useful for:
- **Product Launches**: Mark when new versions or features are released
- **Marketing Campaigns**: Track campaign start and end dates
- **Website Changes**: Note when major redesigns or updates occur
- **Business Events**: Record important business milestones
- **A/B Testing**: Mark when experiments start or end
- **Seasonal Events**: Track holidays, sales periods, or seasonal changes
## Notes
- Reference IDs are UUIDs (Universally Unique Identifiers)
- References are scoped to projects - each reference belongs to a specific project
- References are scoped to your organization - you can only manage references for projects in your organization
- The `description` field is optional and can be set to `null` to clear it
- References appear in analytics charts to help correlate metrics with events
- When filtering by `projectId`, the project must exist and belong to your organization

View File

@@ -1,4 +1,4 @@
{
"title": "API",
"pages": ["track", "export", "insights", "manage"]
"pages": ["track", "export", "insights"]
}

View File

@@ -1,101 +0,0 @@
---
title: Migration from v1 to v2
description: Finally we feel ready to release v2 for all self-hostings. This is a big one!
---
## What's New in v2
- **Redesigned dashboard** - New UI built with Tanstack
- **Revenue tracking** - Track revenue alongside your analytics
- **Sessions** - View individual user sessions
- **Real-time view** - Live event stream
- **Customizable dashboards** - Grafana-style widget layouts
- **Improved report builder** - Faster and more flexible
- **General improvements** - We have also made a bunch of bug fixes, minor improvements and much more
## Migrating from v1
### Ensure you're on the self-hosting branch
Sometimes we add new helper scripts and what not. Always make sure you're on the latest commit before continuing.
```bash
cd ./self-hosting
git fetch origin
git checkout self-hosting
git pull origin self-hosting
```
### Envs
Since we have migrated to tanstack from nextjs we first need to update our envs. We have added a dedicated page for the [environment variables here](/docs/self-hosting/environment-variables).
```js title=".env"
NEXT_PUBLIC_DASHBOARD_URL="..." // [!code --]
NEXT_PUBLIC_API_URL="..." // [!code --]
NEXT_PUBLIC_SELF_HOSTED="..." // [!code --]
DASHBOARD_URL="..." // [!code ++]
API_URL="..." // [!code ++]
SELF_HOSTED="..." // [!code ++]
```
### Clickhouse 24 -> 25
We have updated Clickhouse to 25, this is important to not skip, otherwise your OpenPanel instance wont work.
You should edit your `./self-hosting/docker-compose.yml`
```js title="./self-hosting/docker-compose.yml"
services:
op-ch:
image: clickhouse/clickhouse-server:24.3.2-alpine // [!code --]
image: clickhouse/clickhouse-server:25.10.2.65 // [!code ++]
```
Since version 25 clickhouse enabled default user setup, this means that we need to disable it to avoid connection issues. With this setting we can still access our clickhouse instance (internally) without having a user.
```
services:
op-ch:
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
```
### Use our latest docker images
Last thing to do is to start using our latest docker images.
> Note: Before you might have been using the latest tag, which is not recommended. Change it to the actual latest version instead.
```js title="./self-hosting/docker-compose.yml"
services:
op-api:
image: lindesvard/openpanel-api:latest // [!code --]
image: lindesvard/openpanel-api:2.0.0 // [!code ++]
op-worker:
image: lindesvard/openpanel-worker:latest // [!code --]
image: lindesvard/openpanel-worker:2.0.0 // [!code ++]
op-dashboard:
image: lindesvard/openpanel-dashboard:latest // [!code --]
image: lindesvard/openpanel-dashboard:2.0.0 // [!code ++]
```
### Done?
When you're done with above steps you should need to restart all services. This will take quite some time depending on your hardware and how many events you have. Since we have made significant changes to the database schema and data we need to run migrations.
```bash
./stop
./start
```
## Using Coolify?
If you're using Coolify and running OpenPanel v1 you'll need to apply the above changes. You can take a look at our [Coolify PR](https://github.com/coollabsio/coolify/pull/7653) which shows what you need to change.
## Any issues with migrations?
If you stumble upon any issues during migrations, please reach out to us on [Discord](https://discord.gg/openpanel) and we'll try our best to help you out.

View File

@@ -3,20 +3,6 @@ title: Changelog for self-hosting
description: This is a list of changes that have been made to the self-hosting setup.
---
## 2.0.0
We have released the first stable version of OpenPanel v2. This is a big one!
Read more about it in our [migration guide](/docs/migration/migrate-v1-to-v2).
TLDR;
- Clickhouse upgraded from 24.3.2-alpine to 25.10.2.65
- Add `CLICKHOUSE_SKIP_USER_SETUP=1` to op-ch service
- `NEXT_PUBLIC_DASHBOARD_URL` -> `DASHBOARD_URL`
- `NEXT_PUBLIC_API_URL` -> `API_URL`
- `NEXT_PUBLIC_SELF_HOSTED` -> `SELF_HOSTED`
## 1.2.0
We have renamed `SELF_HOSTED` to `NEXT_PUBLIC_SELF_HOSTED`. It's important to rename this env before your upgrade to this version.
@@ -44,7 +30,7 @@ If you upgrading from a previous version, you'll need to edit your `.env` file i
### Removed Clickhouse Keeper
In 0.0.6 we introduced a cluster mode for Clickhouse. This was a mistake and we have removed it.
In 0.0.6 we introduced a cluster mode for Clickhouse. This was a misstake and we have removed it.
Remove op-zk from services and volumes

View File

@@ -109,8 +109,8 @@ Coolify automatically handles these variables:
- `DATABASE_URL`: PostgreSQL connection string
- `REDIS_URL`: Redis connection string
- `CLICKHOUSE_URL`: ClickHouse connection string
- `API_URL`: API endpoint URL (set via `SERVICE_FQDN_OPAPI`)
- `DASHBOARD_URL`: Dashboard URL (set via `SERVICE_FQDN_OPDASHBOARD`)
- `NEXT_PUBLIC_API_URL`: API endpoint URL (set via `SERVICE_FQDN_OPAPI`)
- `NEXT_PUBLIC_DASHBOARD_URL`: Dashboard URL (set via `SERVICE_FQDN_OPDASHBOARD`)
- `COOKIE_SECRET`: Automatically generated secret
You can configure optional variables like `ALLOW_REGISTRATION`, `RESEND_API_KEY`, `OPENAI_API_KEY`, etc. through Coolify's environment variable interface.

View File

@@ -126,7 +126,7 @@ If you want to use specific image versions, edit the `docker-compose.yml` file a
```yaml
op-api:
image: lindesvard/openpanel-api:2.0.0 # Specify version
image: lindesvard/openpanel-api:v1.0.0 # Specify version
```
### Scaling Workers

View File

@@ -54,8 +54,8 @@ Edit the `.env` file or environment variables in Dokploy. You **must** set these
```bash
# Required: Set these to your actual domain
API_URL=https://yourdomain.com/api
DASHBOARD_URL=https://yourdomain.com
NEXT_PUBLIC_API_URL=https://yourdomain.com/api
NEXT_PUBLIC_DASHBOARD_URL=https://yourdomain.com
# Database Configuration (automatically set by Dokploy)
OPENPANEL_POSTGRES_DB=openpanel-db
@@ -71,7 +71,7 @@ OPENPANEL_EMAIL_SENDER=noreply@yourdomain.com
```
<Callout type="warn">
⚠️ **Critical**: Unlike Coolify, Dokploy does not support `SERVICE_FQDN_*` variables. You **must** hardcode `API_URL` and `DASHBOARD_URL` with your actual domain values.
⚠️ **Critical**: Unlike Coolify, Dokploy does not support `SERVICE_FQDN_*` variables. You **must** hardcode `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_DASHBOARD_URL` with your actual domain values.
</Callout>
</Step>
@@ -133,8 +133,8 @@ If you're using Cloudflare in front of Dokploy, remember to purge the Cloudflare
For Dokploy, you **must** hardcode these variables (unlike Coolify, Dokploy doesn't support `SERVICE_FQDN_*` variables):
- `API_URL` - Full API URL (e.g., `https://analytics.example.com/api`)
- `DASHBOARD_URL` - Full Dashboard URL (e.g., `https://analytics.example.com`)
- `NEXT_PUBLIC_API_URL` - Full API URL (e.g., `https://analytics.example.com/api`)
- `NEXT_PUBLIC_DASHBOARD_URL` - Full Dashboard URL (e.g., `https://analytics.example.com`)
Dokploy automatically sets:
- `OPENPANEL_POSTGRES_DB` - PostgreSQL database name
@@ -166,9 +166,9 @@ If API requests fail after deployment:
1. **Verify environment variables**:
```bash
# Check that API_URL is set correctly
docker exec <op-api-container> env | grep API_URL
docker exec <op-dashboard-container> env | grep API_URL
# Check that NEXT_PUBLIC_API_URL is set correctly
docker exec <op-api-container> env | grep NEXT_PUBLIC_API_URL
docker exec <op-dashboard-container> env | grep NEXT_PUBLIC_API_URL
```
2. **Check "Strip external path" setting**:
@@ -188,7 +188,7 @@ If account creation fails:
# In Dokploy, view logs for op-api service
```
2. Verify `API_URL` matches your domain:
2. Verify `NEXT_PUBLIC_API_URL` matches your domain:
- Should be `https://yourdomain.com/api`
- Not `http://localhost:3000` or similar
@@ -240,7 +240,7 @@ The Dokploy template differs from Coolify in these ways:
1. **Environment Variables**:
- Dokploy does not support `SERVICE_FQDN_*` variables
- Must hardcode `API_URL` and `DASHBOARD_URL`
- Must hardcode `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_DASHBOARD_URL`
2. **Domain Configuration**:
- Must manually configure domain paths

View File

@@ -116,7 +116,7 @@ Remove `convert_any_join` from ClickHouse settings. Used for compatibility with
## Application URLs
### API_URL
### NEXT_PUBLIC_API_URL
**Type**: `string`
**Required**: Yes
@@ -126,10 +126,10 @@ Public API URL exposed to the browser. Used by the dashboard frontend and API se
**Example**:
```bash
API_URL=https://analytics.example.com/api
NEXT_PUBLIC_API_URL=https://analytics.example.com/api
```
### DASHBOARD_URL
### NEXT_PUBLIC_DASHBOARD_URL
**Type**: `string`
**Required**: Yes
@@ -139,7 +139,7 @@ Public dashboard URL exposed to the browser. Used by the dashboard frontend and
**Example**:
```bash
DASHBOARD_URL=https://analytics.example.com
NEXT_PUBLIC_DASHBOARD_URL=https://analytics.example.com
```
### API_CORS_ORIGINS
@@ -175,48 +175,6 @@ COOKIE_SECRET=your-random-secret-here
Never use the default value in production! Always generate a unique secret.
</Callout>
### COOKIE_TLDS
**Type**: `string` (comma-separated)
**Required**: No
**Default**: None
Custom multi-part TLDs for cookie domain handling. Use this when deploying on domains with public suffixes that aren't recognized by default (e.g., `.my.id`, `.web.id`, `.co.id`).
**Example**:
```bash
# For domains like abc.my.id
COOKIE_TLDS=my.id
# Multiple TLDs
COOKIE_TLDS=my.id,web.id,co.id
```
<Callout>
This is required when using domain suffixes that are public suffixes (like `.co.uk`). Without this, the browser will reject authentication cookies. Common examples include Indonesian domains (`.my.id`, `.web.id`, `.co.id`).
</Callout>
### CUSTOM_COOKIE_DOMAIN
**Type**: `string`
**Required**: No
**Default**: None
Override the automatic cookie domain detection and set a specific domain for authentication cookies. Useful when proxying the API through your main domain or when you need precise control over cookie scope.
**Example**:
```bash
# Set cookies only on the main domain
CUSTOM_COOKIE_DOMAIN=.example.com
# Set cookies on a specific subdomain
CUSTOM_COOKIE_DOMAIN=.app.example.com
```
<Callout>
When set, this completely bypasses the automatic domain parsing logic. The cookie will always be set as secure. Include a leading dot (`.`) to allow the cookie to be shared across subdomains.
</Callout>
### DEMO_USER_ID
**Type**: `string`
@@ -410,7 +368,7 @@ SLACK_STATE_SECRET=your-state-secret
## Self-hosting
### SELF_HOSTED
### NEXT_PUBLIC_SELF_HOSTED
**Type**: `boolean`
**Required**: No
@@ -420,7 +378,7 @@ Enable self-hosted mode. Set to `true` or `1` to enable self-hosting features. U
**Example**:
```bash
SELF_HOSTED=true
NEXT_PUBLIC_SELF_HOSTED=true
```
## Worker & Queue
@@ -826,8 +784,8 @@ For a basic self-hosted installation, these variables are required:
- `DATABASE_URL` - PostgreSQL connection
- `REDIS_URL` - Redis connection
- `CLICKHOUSE_URL` - ClickHouse connection
- `API_URL` - API endpoint URL
- `DASHBOARD_URL` - Dashboard URL
- `NEXT_PUBLIC_API_URL` - API endpoint URL
- `NEXT_PUBLIC_DASHBOARD_URL` - Dashboard URL
- `COOKIE_SECRET` - Session encryption secret
### Optional but Recommended

View File

@@ -163,7 +163,7 @@ For complete AI configuration details, see the [Environment Variables documentat
If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually.
Without this setting we won't be able to listen for expired keys which we use for calculating currently active visitors.
Without this setting we wont be able to listen for expired keys which we use for caluclating currently active vistors.
> You will see a warning in the logs if this needs to be set manually.

View File

@@ -6,7 +6,6 @@ difficulty: intermediate
timeToComplete: 10
date: 2025-12-15
lastUpdated: 2025-12-15
team: OpenPanel Team
steps:
- name: "Add the dependency"
anchor: "install"

View File

@@ -1,354 +0,0 @@
---
title: "How to add analytics to Nuxt"
description: "Add privacy-first analytics to your Nuxt app in under 5 minutes with OpenPanel's official Nuxt module."
difficulty: beginner
timeToComplete: 5
date: 2025-01-07
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the module"
anchor: "install"
- name: "Configure the module"
anchor: "setup"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Set up server-side tracking"
anchor: "server"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Nuxt
This guide walks you through adding OpenPanel to a Nuxt 3 application. The official `@openpanel/nuxt` module makes integration effortless with auto-imported composables, automatic page view tracking, and a built-in proxy option to bypass ad blockers.
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It uses cookieless tracking by default, so you won't need cookie consent banners for basic analytics.
## Prerequisites
- A Nuxt 3 project
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the module [#install]
Start by installing the OpenPanel Nuxt module. This package includes everything you need for client-side tracking, including the auto-imported `useOpenPanel` composable.
```bash
npm install @openpanel/nuxt
```
If you prefer pnpm or yarn, those work too.
## Configure the module [#setup]
Add the module to your `nuxt.config.ts` and configure it with your Client ID. The module automatically sets up page view tracking and makes the `useOpenPanel` composable available throughout your app.
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
},
});
```
That's it. Page views are now being tracked automatically as users navigate your app.
### Configuration options
| Option | Default | Description |
|--------|---------|-------------|
| `clientId` | — | Your OpenPanel client ID (required) |
| `apiUrl` | `https://api.openpanel.dev` | The API URL to send events to |
| `trackScreenViews` | `true` | Automatically track page views |
| `trackOutgoingLinks` | `true` | Track clicks on external links |
| `trackAttributes` | `true` | Track elements with `data-track` attributes |
| `trackHashChanges` | `false` | Track hash changes in URL |
| `disabled` | `false` | Disable all tracking |
| `proxy` | `false` | Route events through your server |
### Using environment variables
For production applications, store your Client ID in environment variables.
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: process.env.NUXT_PUBLIC_OPENPANEL_CLIENT_ID,
trackScreenViews: true,
},
});
```
```bash title=".env"
NUXT_PUBLIC_OPENPANEL_CLIENT_ID=your-client-id
```
## Track custom events [#events]
Page views only tell part of the story. To understand how users interact with your product, track custom events like button clicks, form submissions, or feature usage.
### Using the composable
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component without importing anything.
```vue title="components/SignupButton.vue"
<script setup>
const op = useOpenPanel();
function handleClick() {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
}
</script>
<template>
<button type="button" @click="handleClick">Sign Up</button>
</template>
```
### Accessing via useNuxtApp
You can also access the OpenPanel instance through `useNuxtApp()` if you prefer.
```vue
<script setup>
const { $openpanel } = useNuxtApp();
$openpanel.track('my_event', { foo: 'bar' });
</script>
```
### Track form submissions
Form tracking helps you understand conversion rates and identify where users drop off.
```vue title="components/ContactForm.vue"
<script setup>
const op = useOpenPanel();
const email = ref('');
async function handleSubmit() {
op.track('form_submitted', {
form_name: 'contact',
form_location: 'homepage',
});
// Your form submission logic
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="Enter your email" />
<button type="submit">Submit</button>
</form>
</template>
```
### Use data attributes for declarative tracking
The SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
```vue
<template>
<button
data-track="button_clicked"
data-track-button_name="signup"
data-track-button_location="hero"
>
Sign Up
</button>
</template>
```
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
## Identify users [#identify]
Anonymous tracking is useful, but identifying users unlocks more valuable insights. You can track behavior across sessions, segment users by properties, and build cohort analyses.
Call `identify` after a user logs in or when you have their information available.
```vue title="components/UserProfile.vue"
<script setup>
const op = useOpenPanel();
const props = defineProps(['user']);
watch(() => props.user, (user) => {
if (user) {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
}
}, { immediate: true });
</script>
<template>
<div>Welcome, {{ user?.firstName }}!</div>
</template>
```
### Set global properties
Properties set with `setGlobalProperties` are included with every event. This is useful for app version tracking, feature flags, or A/B test variants.
```vue title="app.vue"
<script setup>
const op = useOpenPanel();
onMounted(() => {
op.setGlobalProperties({
app_version: '1.0.0',
environment: useRuntimeConfig().public.environment,
});
});
</script>
```
### Clear user data on logout
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
```vue title="components/LogoutButton.vue"
<script setup>
const op = useOpenPanel();
function handleLogout() {
op.clear();
navigateTo('/login');
}
</script>
<template>
<button @click="handleLogout">Logout</button>
</template>
```
## Set up server-side tracking [#server]
For tracking events in server routes, API endpoints, or server middleware, use the `@openpanel/sdk` package. Server-side tracking requires a client secret for authentication.
### Install the SDK
```bash
npm install @openpanel/sdk
```
### Create a server instance
```ts title="server/utils/op.ts"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
});
```
### Track events in server routes
```ts title="server/api/webhook.post.ts"
export default defineEventHandler(async (event) => {
const body = await readBody(event);
await op.track('webhook_received', {
source: body.source,
event_type: body.type,
});
return { success: true };
});
```
Never expose your client secret on the client side. Keep it in server-only code.
### Awaiting in serverless environments
If you're deploying to a serverless platform like Vercel or Netlify, make sure to await the tracking call to ensure it completes before the function terminates.
```ts
export default defineEventHandler(async (event) => {
// Always await in serverless environments
await op.track('my_server_event', { foo: 'bar' });
return { message: 'Event logged!' };
});
```
## Bypass ad blockers with proxy [#proxy]
Many ad blockers block requests to third-party analytics domains. The Nuxt module includes a built-in proxy that routes events through your own server.
Enable the proxy option in your configuration:
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
proxy: true, // Routes events through /api/openpanel/*
},
});
```
When `proxy: true` is set:
- The module automatically sets `apiUrl` to `/api/openpanel`
- A server handler is registered at `/api/openpanel/**`
- All tracking requests route through your Nuxt server
This makes tracking requests invisible to browser extensions that block third-party analytics.
## Verify your setup [#verify]
Open your Nuxt app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing within seconds.
If events aren't showing up, check the browser console for errors. The most common issues are:
- Incorrect client ID
- Ad blockers intercepting requests (enable the proxy option)
- Client ID exposed in server-only code
The Network tab in your browser's developer tools can help you confirm that requests are being sent.
## Next steps
The [Nuxt SDK reference](/docs/sdks/nuxt) covers additional features like incrementing user properties and event filtering. If you're interested in understanding how OpenPanel handles privacy, read our article on [cookieless analytics](/articles/cookieless-analytics).
<Faqs>
<FaqItem question="Does OpenPanel work with Nuxt 3 and Nuxt 4?">
Yes. The `@openpanel/nuxt` module supports both Nuxt 3 and Nuxt 4. It uses Nuxt's module system and auto-imports, so everything works seamlessly with either version.
</FaqItem>
<FaqItem question="Is the useOpenPanel composable auto-imported?">
Yes. The module automatically registers the `useOpenPanel` composable, so you can use it in any component without importing it. You can also access the instance via `useNuxtApp().$openpanel`.
</FaqItem>
<FaqItem question="Does OpenPanel use cookies?">
No. OpenPanel uses cookieless tracking by default. This means you don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
</FaqItem>
<FaqItem question="How do I avoid ad blockers?">
Enable the `proxy: true` option in your configuration. This routes all tracking requests through your Nuxt server at `/api/openpanel/*`, which ad blockers don't typically block.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely.
</FaqItem>
</Faqs>

View File

@@ -6,7 +6,6 @@ difficulty: beginner
timeToComplete: 7
date: 2025-12-15
lastUpdated: 2025-12-15
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

View File

@@ -121,7 +121,7 @@ export default async function Page({
/>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
{author?.image ? (
{author.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
@@ -134,7 +134,7 @@ export default async function Page({
)}
</div>
<div className="col flex-1">
<p className="font-medium">{author?.name || 'OpenPanel Team'}</p>
<p className="font-medium">{author.name}</p>
<div className="row gap-4 items-center">
<p className="text-muted-foreground text-sm">
{guide?.data.date.toLocaleDateString()}

View File

@@ -1,408 +0,0 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { FaqItem, Faqs } from '@/components/faq';
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import {
BarChartIcon,
CheckIcon,
CodeIcon,
GlobeIcon,
HeartHandshakeIcon,
LinkIcon,
MailIcon,
MessageSquareIcon,
SparklesIcon,
UsersIcon,
ZapIcon,
} from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
export const metadata: Metadata = getPageMetadata({
title: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
url: url('/open-source'),
image: getOgImageUrl('/open-source'),
});
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
url: url('/open-source'),
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntity: {
'@type': 'Offer',
name: 'Free Analytics for Open Source Projects',
description:
'Free analytics service for open source projects up to 2.5M events per month',
price: '0',
priceCurrency: 'USD',
},
};
export default function OpenSourcePage() {
return (
<div>
<Script
id="open-source-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<div className="col center-center flex-1">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title={
<>
Free Analytics for
<br />
Open Source Projects
</>
}
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
/>
<div className="col gap-4 justify-center items-center mt-8">
<Button size="lg" asChild>
<Link href="mailto:oss@openpanel.dev">
Apply for Free Access
<MailIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Up to 2.5M events/month No credit card required
</p>
</div>
</div>
</HeroContainer>
<div className="container">
<div className="col gap-16">
{/* What You Get Section */}
<Section className="my-0">
<SectionHeader
title="What you get"
description="Everything you need to understand your users and grow your open source project."
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<FeatureCard
title="2.5 Million Events/Month"
description="More than enough for most open source projects. Track page views, user actions, and custom events without worrying about limits."
icon={BarChartIcon}
/>
<FeatureCard
title="Full Feature Access"
description="Same powerful capabilities as paid plans. Funnels, retention analysis, custom dashboards, and real-time analytics."
icon={ZapIcon}
/>
<FeatureCard
title="Unlimited Team Members"
description="Invite your entire contributor team. Collaborate with maintainers and core contributors on understanding your project's growth."
icon={UsersIcon}
/>
<FeatureCard
title="Priority Support"
description="Dedicated help for open source maintainers. Get faster responses and priority assistance when you need it."
icon={MessageSquareIcon}
/>
</div>
</Section>
{/* Why We Do This Section */}
<Section className="my-0">
<SectionHeader
title="Why we do this"
description="OpenPanel is built by and for the open source community. We believe in giving back."
/>
<div className="col gap-6 mt-8">
<p className="text-muted-foreground">
We started OpenPanel because we believed analytics tools
shouldn't be complicated or locked behind expensive enterprise
subscriptions. As an open source project ourselves, we
understand the challenges of building and growing a project
without the resources of big corporations.
</p>
<div className="grid md:grid-cols-3 gap-4">
<FeatureCard
title="Built for OSS"
description="OpenPanel is open source. We know what it's like to build in the open."
icon={CodeIcon}
/>
<FeatureCard
title="No Barriers"
description="Analytics shouldn't be a barrier to understanding your users. We're removing that barrier."
icon={HeartHandshakeIcon}
/>
<FeatureCard
title="Giving Back"
description="We're giving back to the projects that inspire us and the community that supports us."
icon={SparklesIcon}
/>
</div>
</div>
</Section>
{/* What We Ask In Return Section */}
<Section className="my-0">
<SectionHeader
title="What we ask in return"
description="We keep it simple. Just a small way to help us grow and support more projects."
/>
<div className="row gap-6 mt-8">
<div className="col gap-6">
<FeatureCard
title="Backlink to OpenPanel"
description="A simple link on your website or README helps others discover OpenPanel. It's a win-win for the community."
icon={LinkIcon}
>
<p className="text-sm text-muted-foreground mt-2">
Example: "Analytics powered by{' '}
<Link
href="https://openpanel.dev"
className="text-primary hover:underline"
>
OpenPanel
</Link>
"
</p>
</FeatureCard>
<FeatureCard
title="Display a Widget"
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
icon={GlobeIcon}
>
<a
href="https://openpanel.dev"
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: '8px',
width: '250px',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</FeatureCard>
<p className="text-muted-foreground">
That's it. No complicated requirements, no hidden fees, no
catch. We just want to help open source projects succeed.
</p>
</div>
<div>
<div className="text-center text-xs text-muted-foreground">
<iframe
title="Realtime Widget"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
width="300"
height="400"
className="rounded-xl border mb-2"
/>
Analytics from{' '}
<a className="underline" href="https://openpanel.dev">
OpenPanel.dev
</a>
</div>
</div>
</div>
</Section>
{/* Eligibility Criteria Section */}
<Section className="my-0">
<SectionHeader
title="Eligibility criteria"
description="We want to support legitimate open source projects that are making a difference."
/>
<div className="col gap-4 mt-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">OSI-Approved License</h3>
<p className="text-sm text-muted-foreground">
Your project must use an OSI-approved open source license
(MIT, Apache, GPL, etc.)
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">Public Repository</h3>
<p className="text-sm text-muted-foreground">
Your code must be publicly available on GitHub, GitLab, or
similar platforms
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">Active Development</h3>
<p className="text-sm text-muted-foreground">
Show evidence of active development and a growing
community
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">
Non-Commercial Primary Purpose
</h3>
<p className="text-sm text-muted-foreground">
The primary purpose should be non-commercial, though
commercial OSS projects may be considered
</p>
</div>
</div>
</div>
</div>
</Section>
{/* How to Apply Section */}
<Section className="my-0">
<SectionHeader
title="How to apply"
description="Getting started is simple. Just send us an email with a few details about your project."
/>
<div className="col gap-6 mt-8">
<div className="grid md:grid-cols-3 gap-6">
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
1
</div>
<h3 className="font-semibold">Send us an email</h3>
<p className="text-sm text-muted-foreground">
Reach out to{' '}
<Link
href="mailto:oss@openpanel.dev"
className="text-primary hover:underline"
>
oss@openpanel.dev
</Link>{' '}
with your project details
</p>
</div>
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
2
</div>
<h3 className="font-semibold">Include project info</h3>
<p className="text-sm text-muted-foreground">
Share your project URL, license type, and a brief
description of what you're building
</p>
</div>
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
3
</div>
<h3 className="font-semibold">We'll review</h3>
<p className="text-sm text-muted-foreground">
We'll evaluate your project and respond within a few
business days
</p>
</div>
</div>
<div className="mt-4">
<Button size="lg" asChild>
<Link href="mailto:oss@openpanel.dev?subject=Open Source Program Application">
Apply Now
<MailIcon className="size-4" />
</Link>
</Button>
</div>
</div>
</Section>
{/* FAQ Section */}
<Section className="my-0">
<SectionHeader
title="Frequently asked questions"
description="Everything you need to know about our open source program."
/>
<div className="mt-8">
<Faqs>
<FaqItem question="What counts as an open-source project?">
We consider any project with an OSI-approved open source
license (MIT, Apache, GPL, BSD, etc.) that is publicly
available and actively maintained. The project should have a
non-commercial primary purpose, though we may consider
commercial open source projects on a case-by-case basis.
</FaqItem>
<FaqItem question="What happens if I exceed 2.5M events per month?">
We understand that successful projects grow. If you
consistently exceed 2.5M events, we'll reach out to discuss
options. We're flexible and want to support your success. In
most cases, we can work out a solution that works for both of
us.
</FaqItem>
<FaqItem question="Can commercial open source projects apply?">
Yes, we consider commercial open source projects on a
case-by-case basis. If your project is open source but has
commercial offerings, please mention this in your application
and we'll evaluate accordingly.
</FaqItem>
<FaqItem question="How long does the free access last?">
As long as your project remains eligible and active, your free
access continues. We review projects periodically to ensure
they still meet our criteria, but we're committed to
supporting projects long-term.
</FaqItem>
<FaqItem question="Do I need to display the widget?">
No, displaying the widget is completely optional. We only
require a backlink to OpenPanel on your website or README. The
widget is just a nice way to showcase your analytics if you
want to.
</FaqItem>
<FaqItem question="What if my project is very small or just starting?">
We welcome projects of all sizes! Whether you're just getting
started or have a large community, if you meet our eligibility
criteria, we'd love to help. Small projects often benefit the
most from understanding their users early on.
</FaqItem>
</Faqs>
</div>
</Section>
<CtaBanner
title="Ready to get free analytics for your open source project?"
description="Join other open source projects using OpenPanel to understand their users and grow their communities. Apply today and get started in minutes."
ctaText="Apply for Free Access"
ctaLink="mailto:oss@openpanel.dev?subject=Open Source Program Application"
/>
</div>
</div>
</div>
);
}

View File

@@ -7,9 +7,10 @@ import Image from 'next/image';
const images = [
{
name: 'Lucide Animated',
url: 'https://lucide-animated.com',
logo: '/logos/lucide-animated.png',
name: 'Helpy UI',
url: 'https://helpy-ui.com',
logo: '/logos/helpy-ui.png',
className: 'size-12',
},
{
name: 'KiddoKitchen',
@@ -66,7 +67,10 @@ export function WhyOpenPanel() {
alt={image.name}
width={64}
height={64}
className={cn('size-16 object-contain dark:invert')}
className={cn(
'size-16 object-contain dark:invert',
image.className,
)}
/>
</a>
</div>

View File

@@ -27,6 +27,10 @@ interface IPInfoResponse {
latitude: number | undefined;
longitude: number | undefined;
};
isp: string | null;
asn: string | null;
organization: string | null;
hostname: string | null;
isLocalhost: boolean;
isPrivate: boolean;
}
@@ -86,6 +90,84 @@ function isPrivateIP(ip: string): boolean {
return false;
}
async function getIPInfo(ip: string): Promise<IPInfo> {
if (!ip || ip === '127.0.0.1' || ip === '::1') {
return {
ip,
location: {
country: undefined,
city: undefined,
region: undefined,
latitude: undefined,
longitude: undefined,
},
isp: null,
asn: null,
organization: null,
hostname: null,
};
}
// Get geolocation
const geo = await getGeoLocation(ip);
// Get ISP/ASN info
let isp: string | null = null;
let asn: string | null = null;
let organization: string | null = null;
if (!isPrivateIP(ip)) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,reverse`,
{
signal: controller.signal,
},
);
clearTimeout(timeout);
if (response.ok) {
const data = await response.json();
if (data.status !== 'fail') {
isp = data.isp || null;
asn = data.as ? `AS${data.as.split(' ')[0]}` : null;
organization = data.org || null;
}
}
} catch {
// Ignore errors
}
}
// Reverse DNS lookup for hostname
let hostname: string | null = null;
try {
const hostnames = await dns.reverse(ip);
hostname = hostnames[0] || null;
} catch {
// Ignore errors
}
return {
ip,
location: {
country: geo.country,
city: geo.city,
region: geo.region,
latitude: geo.latitude,
longitude: geo.longitude,
},
isp,
asn,
organization,
hostname,
};
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const ipParam = searchParams.get('ip');
@@ -127,17 +209,12 @@ export async function GET(request: Request) {
}
try {
const geo = await fetch('https://api.openpanel.dev/misc/geo', {
headers: request.headers,
})
.then((res) => res.json())
.then((data) => data.selected.geo);
const info = await getIPInfo(ipToLookup);
const isLocalhost = ipToLookup === '127.0.0.1' || ipToLookup === '::1';
const isPrivate = isPrivateIP(ipToLookup);
const response: IPInfoResponse = {
location: geo,
ip: ipToLookup,
...info,
isLocalhost,
isPrivate,
};

View File

@@ -12,11 +12,7 @@ import {
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
type PageProps = {
params: Promise<{ slug: string[] }>;
};
export default async function Page(props: PageProps) {
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -43,7 +39,9 @@ export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();

View File

@@ -1,8 +1,8 @@
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: { children: React.ReactNode }) {
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions()}>
{children}

View File

@@ -28,7 +28,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: { children: React.ReactNode }) {
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html
lang="en"

View File

@@ -26,13 +26,6 @@ async function getOgData(
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
};
}
case 'open-source': {
return {
title: 'Free analytics for open source projects',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
};
}
case 'pricing': {
return {
title: 'Pricing',
@@ -91,34 +84,6 @@ async function getOgData(
data?.data.description || 'Whooops, could not find this page',
};
}
case 'tools': {
if (segments.length > 1) {
const tool = segments[1];
switch (tool) {
case 'ip-lookup':
return {
title: 'IP Lookup Tool',
description:
'Find detailed information about any IP address including geolocation, ISP, and network details.',
};
case 'url-checker':
return {
title: 'URL Checker',
description:
'Analyze any website for SEO, social media, technical, and security information. Get comprehensive insights about any URL.',
};
default:
return {
title: 'Tools',
description: 'Free web tools for developers and website owners',
};
}
}
return {
title: 'Tools',
description: 'Free web tools for developers and website owners',
};
}
default: {
const data = await pageSource.getPage(segments);
return {

View File

@@ -1,18 +1,11 @@
import { url } from '@/lib/layout.shared';
import {
articleSource,
compareSource,
guideSource,
pageSource,
source,
} from '@/lib/source';
import { articleSource, compareSource, pageSource, source } from '@/lib/source';
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await articleSource.getPages();
const docs = await source.getPages();
const pages = await pageSource.getPages();
const guides = await guideSource.getPages();
return [
{
url: url('/'),
@@ -56,12 +49,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'yearly' as const,
priority: 0.5,
})),
...guides.map((item) => ({
url: url(item.url),
lastModified: item.data.date,
changeFrequency: 'monthly' as const,
priority: 0.5,
})),
...docs.map((item) => ({
url: url(item.url),
changeFrequency: 'monthly' as const,

View File

@@ -275,6 +275,49 @@ export default function IPLookupPage() {
</div>
)}
{/* Network Information */}
{(result.isp ||
result.asn ||
result.organization ||
result.hostname) && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Network className="size-5" />
Network Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{result.isp && (
<InfoCard
icon={<Building2 className="size-5" />}
label="ISP"
value={result.isp}
/>
)}
{result.asn && (
<InfoCard
icon={<Network className="size-5" />}
label="ASN"
value={result.asn}
/>
)}
{result.organization && (
<InfoCard
icon={<Building2 className="size-5" />}
label="Organization"
value={result.organization}
/>
)}
{result.hostname && (
<InfoCard
icon={<Server className="size-5" />}
label="Hostname"
value={result.hostname}
/>
)}
</div>
</div>
)}
{/* Map Preview */}
{result.location.latitude && result.location.longitude && (
<div>

View File

@@ -71,7 +71,6 @@ export function FeatureCard({
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</FeatureCardContainer>
);
}

View File

@@ -25,10 +25,6 @@ export async function Footer() {
{ title: 'About', url: '/about' },
{ title: 'Contact', url: '/contact' },
{ title: 'Become a supporter', url: '/supporter' },
{
title: 'Free analytics for open source projects',
url: '/open-source',
},
]}
/>
</div>
@@ -79,28 +75,10 @@ export async function Footer() {
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
<div className="container col md:row justify-between gap-8">
<div>
<a
href="https://openpanel.dev"
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: '8px',
width: '100%',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
<Link href="/" className="row items-center font-medium -ml-3">
<Logo className="h-6" />
{baseOptions().nav?.title}
</Link>
</div>
<Social />
</div>

View File

@@ -34,6 +34,7 @@ COPY packages/payments/package.json packages/payments/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/sdks/_info/package.json packages/sdks/_info/
COPY patches ./patches
# Copy tracking script to self-hosting dashboard
@@ -92,6 +93,7 @@ COPY --from=build /app/packages/payments/package.json ./packages/payments/
COPY --from=build /app/packages/constants/package.json ./packages/constants/
COPY --from=build /app/packages/validation/package.json ./packages/validation/
COPY --from=build /app/packages/integrations/package.json ./packages/integrations/
COPY --from=build /app/packages/sdks/sdk/package.json ./packages/sdks/sdk/
COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/
COPY --from=build /app/patches ./patches
@@ -130,6 +132,7 @@ COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info
COPY --from=build /app/tooling/typescript ./tooling/typescript

View File

@@ -10,6 +10,7 @@
"cf-typegen": "wrangler types",
"build": "pnpm with-env vite build",
"serve": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
@@ -24,8 +25,7 @@
"@faker-js/faker": "^9.6.0",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@nivo/sankey": "^0.99.0",
"@number-flow/react": "0.5.10",
"@number-flow/react": "0.3.5",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/integrations": "workspace:^",
@@ -84,16 +84,9 @@
"@types/d3": "^7.4.3",
"ai": "^4.2.10",
"bind-event-listener": "^3.0.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-javascript": "^6.2.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.35.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"codemirror": "^6.0.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"debounce": "^2.2.0",
@@ -156,7 +149,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "1.20.3",
"@cloudflare/vite-plugin": "^1.13.12",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
@@ -177,6 +170,6 @@
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "4.59.1"
"wrangler": "^4.42.2"
}
}
}

View File

@@ -1,6 +1,20 @@
import type { NumberFlowProps } from '@number-flow/react';
import ReactAnimatedNumber from '@number-flow/react';
import { useEffect, useState } from 'react';
// NumberFlow is breaking ssr and forces loaders to fetch twice
export function AnimatedNumber(props: NumberFlowProps) {
return <ReactAnimatedNumber {...props} />;
const [Component, setComponent] =
useState<React.ComponentType<NumberFlowProps> | null>(null);
useEffect(() => {
import('@number-flow/react').then(({ default: NumberFlow }) => {
setComponent(NumberFlow);
});
}, []);
if (!Component) {
return <>{props.value}</>;
}
return <Component {...props} />;
}

View File

@@ -4,17 +4,11 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { PublicPageCard } from '../public-page-card';
import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({
shareId,
shareType = 'overview',
}: {
shareId: string;
shareType?: 'overview' | 'dashboard' | 'report';
}) {
export function ShareEnterPassword({ shareId }: { shareId: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
@@ -31,7 +25,6 @@ export function ShareEnterPassword({
defaultValues: {
password: '',
shareId,
shareType,
},
});
@@ -39,31 +32,46 @@ export function ShareEnterPassword({
mutation.mutate({
password: data.password,
shareId,
shareType,
});
});
const typeLabel =
shareType === 'dashboard'
? 'Dashboard'
: shareType === 'report'
? 'Report'
: 'Overview';
return (
<PublicPageCard
title={`${typeLabel} is locked`}
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
>
<form onSubmit={onSubmit} className="col gap-4">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</PublicPageCard>
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</div>
<div className="p-6 text-sm max-w-sm col gap-0.5">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
</p>
<p>
The best web and product analytics tool out there (our honest
opinion).
</p>
<p>
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
</div>
);
}

View File

@@ -5,15 +5,9 @@ import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export const ChartTooltipContainer = ({
children,
className,
}: { children: React.ReactNode; className?: string }) => {
}: { children: React.ReactNode }) => {
return (
<div
className={cn(
'min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
className,
)}
>
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
{children}
</div>
);

View File

@@ -1,7 +1,6 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zReport } from '@openpanel/validation';
import { z } from 'zod';
import { zChartInputAI } from '@openpanel/validation';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
@@ -78,10 +77,7 @@ export const ChatMessage = memo(
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zReport.extend({
startDate: z.string(),
endDate: z.string(),
}).safeParse(result.report);
const report = zChartInputAI.safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>

View File

@@ -1,6 +1,6 @@
import { pushModal } from '@/modals';
import type {
IReport,
IChartInputAi,
IChartRange,
IChartType,
IInterval,
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
}: { report: IChartInputAi; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);

View File

@@ -1,65 +0,0 @@
import { cn } from '@/utils/cn';
import { type VariantProps, cva } from 'class-variance-authority';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
const deltaChipVariants = cva(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
{
variants: {
variant: {
inc: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
dec: 'bg-red-500/10 text-red-600 dark:text-red-400',
default: 'bg-muted text-muted-foreground',
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
},
);
type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
children: React.ReactNode;
inverted?: boolean;
};
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
sm: 12,
md: 16,
lg: 20,
};
const getVariant = (variant: DeltaChipProps['variant'], inverted?: boolean) => {
if (inverted) {
return variant === 'inc' ? 'dec' : variant === 'dec' ? 'inc' : variant;
}
return variant;
};
export function DeltaChip({
variant,
size,
inverted,
children,
}: DeltaChipProps) {
return (
<div
className={cn(
deltaChipVariants({ variant: getVariant(variant, inverted), size }),
)}
>
{variant === 'inc' ? (
<ArrowUpIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : variant === 'dec' ? (
<ArrowDownIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : null}
<span>{children}</span>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function useColumns() {
accessorKey: 'name',
header: 'Name',
cell({ row }) {
const { name, path, duration, properties, revenue } = row.original;
const { name, path, duration, properties } = row.original;
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
@@ -42,10 +42,6 @@ export function useColumns() {
);
}
if (name === 'revenue' && revenue) {
return `${name} (${number.currency(revenue / 100)})`;
}
return name.replace(/_/g, ' ');
};
@@ -106,7 +102,7 @@ export function useColumns() {
if (profile) {
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`}
href={`/profiles/${profile.id}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
@@ -117,7 +113,7 @@ export function useColumns() {
if (profileId && profileId !== deviceId) {
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(profileId)}`}
href={`/profiles/${profileId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Unknown
@@ -128,7 +124,7 @@ export function useColumns() {
if (deviceId) {
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(deviceId)}`}
href={`/profiles/${deviceId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Anonymous

View File

@@ -1,95 +0,0 @@
import type { IServiceReport } from '@openpanel/db';
import { useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
const ResponsiveGridLayout = WidthProvider(Responsive);
export type Layout = ReactGridLayout.Layout;
export const useReportLayouts = (
reports: NonNullable<IServiceReport>[],
): ReactGridLayout.Layouts => {
return useMemo(() => {
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
};
export function GrafanaGrid({
layouts,
children,
transitions,
onLayoutChange,
onDragStop,
onResizeStop,
isDraggable,
isResizable,
}: {
children: React.ReactNode;
transitions?: boolean;
} & Pick<
ReactGridLayout.ResponsiveProps,
| 'layouts'
| 'onLayoutChange'
| 'onDragStop'
| 'onResizeStop'
| 'isDraggable'
| 'isResizable'
>) {
return (
<>
<style>{`
.react-grid-item {
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<div className="-m-4">
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
onLayoutChange={onLayoutChange}
onDragStop={onDragStop}
onResizeStop={onResizeStop}
isDraggable={isDraggable}
isResizable={isResizable}
>
{children}
</ResponsiveGridLayout>
</div>
</>
);
}

View File

@@ -1,201 +0,0 @@
import { countries } from '@/translations/countries';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { InsightPayload } from '@openpanel/validation';
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
import { last } from 'ramda';
import { useState } from 'react';
import { DeltaChip } from '../delta-chip';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Badge } from '../ui/badge';
function formatWindowKind(windowKind: string): string {
switch (windowKind) {
case 'yesterday':
return 'Yesterday';
case 'rolling_7d':
return '7 Days';
case 'rolling_30d':
return '30 Days';
}
return windowKind;
}
interface InsightCardProps {
insight: RouterOutputs['insight']['list'][number];
className?: string;
onFilter?: () => void;
}
export function InsightCard({
insight,
className,
onFilter,
}: InsightCardProps) {
const payload = insight.payload;
const dimensions = payload?.dimensions;
const availableMetrics = Object.entries(payload?.metrics ?? {});
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
const currentMetricKey = availableMetrics[metricIndex][0];
const currentMetricEntry = availableMetrics[metricIndex][1];
const metricUnit = currentMetricEntry?.unit;
const currentValue = currentMetricEntry?.current ?? null;
const compareValue = currentMetricEntry?.compare ?? null;
const direction = currentMetricEntry?.direction ?? 'flat';
const isIncrease = direction === 'up';
const isDecrease = direction === 'down';
const deltaText =
metricUnit === 'ratio'
? `${Math.abs((currentMetricEntry?.delta ?? 0) * 100).toFixed(1)}pp`
: `${Math.abs((currentMetricEntry?.changePct ?? 0) * 100).toFixed(1)}%`;
// Format metric values
const formatValue = (value: number | null): string => {
if (value == null) return '-';
if (metricUnit === 'ratio') return `${(value * 100).toFixed(1)}%`;
return Math.round(value).toLocaleString();
};
// Get the metric label
const metricKeyToLabel = (key: string) =>
key === 'share' ? 'Share' : key === 'pageviews' ? 'Pageviews' : 'Sessions';
const metricLabel = metricKeyToLabel(currentMetricKey);
const renderTitle = () => {
if (
dimensions[0]?.key === 'country' ||
dimensions[0]?.key === 'referrer_name' ||
dimensions[0]?.key === 'device'
) {
return (
<span className="capitalize flex items-center gap-2">
<SerieIcon name={dimensions[0]?.value} /> {insight.displayName}
</span>
);
}
if (insight.displayName.startsWith('http')) {
return (
<span className="flex items-center gap-2">
<SerieIcon
name={dimensions[0]?.displayName ?? dimensions[0]?.value}
/>
<span className="line-clamp-2">{dimensions[1]?.displayName}</span>
</span>
);
}
return insight.displayName;
};
return (
<div
className={cn(
'card p-4 h-full flex flex-col hover:bg-def-50 transition-colors group/card',
className,
)}
>
<div
className={cn(
'row justify-between h-4 items-center',
onFilter && 'group-hover/card:hidden',
)}
>
<Badge variant="outline" className="-ml-2">
{formatWindowKind(insight.windowKind)}
</Badge>
{/* Severity: subtle dot instead of big pill */}
{insight.severityBand && (
<div className="flex items-center gap-1 shrink-0">
<span
className={cn(
'h-2 w-2 rounded-full',
insight.severityBand === 'severe'
? 'bg-red-500'
: insight.severityBand === 'moderate'
? 'bg-yellow-500'
: 'bg-blue-500',
)}
/>
<span className="text-[11px] text-muted-foreground capitalize">
{insight.severityBand}
</span>
</div>
)}
</div>
{onFilter && (
<div className="row group-hover/card:flex hidden h-4 justify-between gap-2">
{availableMetrics.length > 1 ? (
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={() =>
setMetricIndex((metricIndex + 1) % availableMetrics.length)
}
>
<RotateCcwIcon className="size-2" />
Show{' '}
{metricKeyToLabel(
availableMetrics[
(metricIndex + 1) % availableMetrics.length
][0],
)}
</button>
) : (
<div />
)}
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={onFilter}
>
Filter <FilterIcon className="size-2" />
</button>
</div>
)}
<div className="font-semibold text-sm leading-snug line-clamp-2 mt-2">
{renderTitle()}
</div>
{/* Metric row */}
<div className="mt-auto pt-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] text-muted-foreground mb-1">
{metricLabel}
</div>
<div className="col gap-1">
<div className="text-2xl font-semibold tracking-tight">
{formatValue(currentValue)}
</div>
{/* Inline compare, smaller */}
{compareValue != null && (
<div className="text-xs text-muted-foreground">
vs {formatValue(compareValue)}
</div>
)}
</div>
</div>
{/* Delta chip */}
<DeltaChip
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
size="sm"
>
{deltaText}
</DeltaChip>
</div>
</div>
</div>
);
}

View File

@@ -1,53 +1,18 @@
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { JsonEditor } from '@/components/json-editor';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { path, mergeDeepRight } from 'ramda';
import { useEffect } from 'react';
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
const DEFAULT_TRANSFORMER = `(payload) => {
return payload;
}`;
// Convert Record<string, string> to array format for form
function headersToArray(
headers: Record<string, string> | undefined,
): { key: string; value: string }[] {
if (!headers || Object.keys(headers).length === 0) {
return [];
}
return Object.entries(headers).map(([key, value]) => ({ key, value }));
}
// Convert array format back to Record<string, string> for API
function headersToRecord(
headers: { key: string; value: string }[],
): Record<string, string> {
return headers.reduce(
(acc, { key, value }) => {
if (key.trim()) {
acc[key.trim()] = value;
}
return acc;
},
{} as Record<string, string>,
);
}
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
@@ -56,13 +21,6 @@ export function WebhookIntegrationForm({
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
// Convert headers from Record to array format for form UI
const defaultHeaders =
defaultValues?.config && 'headers' in defaultValues.config
? headersToArray(defaultValues.config.headers)
: [];
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
@@ -72,68 +30,18 @@ export function WebhookIntegrationForm({
type: 'webhook' as const,
url: '',
headers: {},
mode: 'message' as const,
javascriptTemplate: undefined,
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
// Use a separate form for headers array to work with useFieldArray
const headersForm = useForm<{ headers: { key: string; value: string }[] }>({
defaultValues: {
headers: defaultHeaders,
},
});
const headersArray = useFieldArray({
control: headersForm.control,
name: 'headers',
});
// Watch headers array and sync to main form
const watchedHeaders = useWatch({
control: headersForm.control,
name: 'headers',
});
// Sync headers array changes back to main form
useEffect(() => {
if (watchedHeaders) {
const validHeaders = watchedHeaders.filter(
(h): h is { key: string; value: string } =>
h !== undefined &&
typeof h.key === 'string' &&
typeof h.value === 'string',
);
form.setValue('config.headers', headersToRecord(validHeaders), {
shouldValidate: false,
});
}
}, [watchedHeaders, form]);
const mode = form.watch('config.mode');
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError(error) {
// Handle validation errors from tRPC
if (error.data?.code === 'BAD_REQUEST') {
const errorMessage = error.message || 'Invalid JavaScript template';
toast.error(errorMessage);
// Set form error if it's a JavaScript template error
if (errorMessage.includes('JavaScript template')) {
form.setError('config.javascriptTemplate', {
type: 'manual',
message: errorMessage,
});
}
} else {
toast.error('Failed to create integration');
}
onError() {
toast.error('Failed to create integration');
},
}),
);
@@ -162,176 +70,7 @@ export function WebhookIntegrationForm({
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<WithLabel
label="Headers"
info="Add custom HTTP headers to include with webhook requests"
>
<div className="col gap-2">
{headersArray.fields.map((field, index) => (
<div key={field.id} className="row gap-2">
<Input
placeholder="Header Name"
{...headersForm.register(`headers.${index}.key`)}
className="flex-1"
/>
<Input
placeholder="Header Value"
{...headersForm.register(`headers.${index}.value`)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => headersArray.remove(index)}
className="text-destructive"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => headersArray.append({ key: '', value: '' })}
className="self-start"
icon={PlusIcon}
>
Add Header
</Button>
</div>
</WithLabel>
<Controller
control={form.control}
name="config.mode"
render={({ field }) => (
<WithLabel
label="Payload Format"
info="Choose how to format the webhook payload"
>
<Combobox
{...field}
className="w-full"
placeholder="Select format"
items={[
{
label: 'Message',
value: 'message' as const,
},
{
label: 'JavaScript',
value: 'javascript' as const,
},
]}
value={field.value ?? 'message'}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
{mode === 'javascript' && (
<Controller
control={form.control}
name="config.javascriptTemplate"
render={({ field }) => (
<WithLabel
label="JavaScript Transform"
info={
<div className="prose dark:prose-invert max-w-none">
<p>
Write a JavaScript function that transforms the event
payload. The function receives <code>payload</code> as a
parameter and should return an object.
</p>
<p className="text-sm font-semibold mt-2">
Available in payload:
</p>
<ul className="text-sm">
<li>
<code>payload.name</code> - Event name
</li>
<li>
<code>payload.profileId</code> - User profile ID
</li>
<li>
<code>payload.properties</code> - Full properties object
</li>
<li>
<code>payload.properties.your.property</code> - Nested
property value
</li>
<li>
<code>payload.profile.firstName</code> - Profile property
</li>
<li>
<div className="flex gap-x-2 flex-wrap mt-1">
<code>country</code>
<code>city</code>
<code>device</code>
<code>os</code>
<code>browser</code>
<code>path</code>
<code>createdAt</code>
and more...
</div>
</li>
</ul>
<p className="text-sm font-semibold mt-2">
Available helpers:
</p>
<ul className="text-sm">
<li>
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
<code>Array</code>, <code>String</code>,{' '}
<code>Object</code>
</li>
</ul>
<p className="text-sm mt-2">
<strong>Example:</strong>
</p>
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
{`(payload) => ({
event: payload.name,
user: payload.profileId,
data: payload.properties,
timestamp: new Date(payload.createdAt).toISOString(),
location: \`\${payload.city}, \${payload.country}\`
})`}
</pre>
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
<strong>Security:</strong> Network calls, file system
access, and other dangerous operations are blocked.
</p>
</div>
}
>
<JsonEditor
value={field.value ?? DEFAULT_TRANSFORMER}
onChange={(value) => {
field.onChange(value);
// Clear error when user starts typing
if (form.formState.errors.config?.javascriptTemplate) {
form.clearErrors('config.javascriptTemplate');
}
}}
placeholder={DEFAULT_TRANSFORMER}
minHeight="300px"
language="javascript"
/>
{form.formState.errors.config?.javascriptTemplate && (
<p className="mt-1 text-sm text-destructive">
{form.formState.errors.config.javascriptTemplate.message}
</p>
)}
</WithLabel>
)}
/>
)}
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -1,218 +0,0 @@
'use client';
import { basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import {
Compartment,
EditorState,
type Extension,
} from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from './theme-provider';
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
language?: 'json' | 'javascript';
onValidate?: (isValid: boolean, error?: string) => void;
}
export function JsonEditor({
value,
onChange,
placeholder = '{}',
className = '',
minHeight = '200px',
language = 'json',
onValidate,
}: JsonEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartmentRef = useRef<Compartment | null>(null);
const languageCompartmentRef = useRef<Compartment | null>(null);
const { appTheme } = useTheme();
const [isValid, setIsValid] = useState(true);
const [error, setError] = useState<string | undefined>();
const isUpdatingRef = useRef(false);
const validateContent = (content: string) => {
if (!content.trim()) {
setIsValid(true);
setError(undefined);
onValidate?.(true);
return;
}
if (language === 'json') {
try {
JSON.parse(content);
setIsValid(true);
setError(undefined);
onValidate?.(true);
} catch (e) {
setIsValid(false);
const errorMsg =
e instanceof Error ? e.message : 'Invalid JSON syntax';
setError(errorMsg);
onValidate?.(false, errorMsg);
}
} else if (language === 'javascript') {
// No frontend validation for JavaScript - validation happens in tRPC
setIsValid(true);
setError(undefined);
onValidate?.(true);
}
};
// Create editor once on mount
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
const themeCompartment = new Compartment();
themeCompartmentRef.current = themeCompartment;
const languageCompartment = new Compartment();
languageCompartmentRef.current = languageCompartment;
const extensions: Extension[] = [
basicSetup,
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
EditorState.tabSize.of(2),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
isUpdatingRef.current = true;
const newValue = update.state.doc.toString();
onChange(newValue);
validateContent(newValue);
// Reset flag after a short delay
setTimeout(() => {
isUpdatingRef.current = false;
}, 0);
}
}),
EditorView.theme({
'&': {
fontSize: '14px',
minHeight,
maxHeight: '400px',
},
'&.cm-editor': {
borderRadius: '6px',
border: `1px solid ${
isValid ? 'hsl(var(--border))' : 'hsl(var(--destructive))'
}`,
overflow: 'hidden',
},
'.cm-scroller': {
minHeight,
maxHeight: '400px',
overflow: 'auto',
},
'.cm-content': {
padding: '12px 12px 12px 0',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
minHeight,
},
'.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'hsl(var(--muted))',
borderRight: '1px solid hsl(var(--border))',
paddingLeft: '8px',
},
'.cm-lineNumbers .cm-gutterElement': {
color: 'hsl(var(--muted-foreground))',
paddingRight: '12px',
paddingLeft: '4px',
},
}),
themeCompartment.of(appTheme === 'dark' ? [oneDark] : []),
];
const state = EditorState.create({
doc: value,
extensions,
});
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// Initial validation
validateContent(value);
return () => {
view.destroy();
viewRef.current = null;
themeCompartmentRef.current = null;
};
}, []); // Only create once
// Update theme using compartment
useEffect(() => {
if (!viewRef.current || !themeCompartmentRef.current) return;
viewRef.current.dispatch({
effects: themeCompartmentRef.current.reconfigure(
appTheme === 'dark' ? [oneDark] : [],
),
});
}, [appTheme]);
// Update language using compartment
useEffect(() => {
if (!viewRef.current || !languageCompartmentRef.current) return;
viewRef.current.dispatch({
effects: languageCompartmentRef.current.reconfigure(
language === 'javascript' ? [javascript()] : [json()],
),
});
validateContent(value);
}, [language, value]);
// Update editor content when value changes externally
useEffect(() => {
if (!viewRef.current || isUpdatingRef.current) return;
const currentContent = viewRef.current.state.doc.toString();
if (currentContent !== value) {
viewRef.current.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
// Validate after external update
validateContent(value);
}
}, [value]);
return (
<div className={className}>
<div
ref={editorRef}
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
/>
{!isValid && (
<p className="mt-1 text-sm text-destructive">
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
</p>
)}
</div>
);
}

View File

@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/posthog-alternative">
<a href="https://openpanel.dev/compare/mixpanel-alternative">
Posthog alternative
</a>
</li>

View File

@@ -149,7 +149,7 @@ export function useColumns() {
}
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(event.profileId)}`}
href={`/profiles/${event.profileId}`}
className="inline-flex min-w-full flex-none items-center gap-2"
>
{event.profileId}

View File

@@ -1,82 +0,0 @@
import { PromptCard } from '@/components/organization/prompt-card';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { op } from '@/utils/op';
import { MessageSquareIcon } from 'lucide-react';
import { useEffect, useMemo } from 'react';
const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30;
export default function FeedbackPrompt() {
const { isSelfHosted } = useAppContext();
const [feedbackPromptSeen, setFeedbackPromptSeen] = useCookieStore(
'feedback-prompt-seen',
'',
{ maxAge: THIRTY_DAYS_IN_SECONDS },
);
const shouldShow = useMemo(() => {
if (isSelfHosted) {
return false;
}
if (!feedbackPromptSeen) {
return true;
}
try {
const lastSeenDate = new Date(feedbackPromptSeen);
const now = new Date();
const daysSinceLastSeen =
(now.getTime() - lastSeenDate.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceLastSeen >= 30;
} catch {
// If date parsing fails, show the prompt
return true;
}
}, [isSelfHosted, feedbackPromptSeen]);
const handleGiveFeedback = () => {
// Open userjot widget
if (typeof window !== 'undefined' && 'uj' in window) {
(window.uj as any).showWidget();
}
// Set cookie with current timestamp
setFeedbackPromptSeen(new Date().toISOString());
op.track('feedback_prompt_button_clicked');
};
const handleClose = () => {
// Set cookie with current timestamp when closed
setFeedbackPromptSeen(new Date().toISOString());
};
useEffect(() => {
if (shouldShow) {
op.track('feedback_prompt_viewed');
}
}, [shouldShow]);
return (
<PromptCard
title="Share Your Feedback"
subtitle="Help us improve OpenPanel with your insights"
onClose={handleClose}
show={shouldShow}
gradientColor="rgb(59 130 246)"
>
<div className="px-6 col gap-4">
<p className="text-sm text-foreground leading-normal">
Your feedback helps us build features you actually need. Share your
thoughts, report bugs, or suggest improvements
</p>
<Button className="self-start" onClick={handleGiveFeedback}>
Give Feedback
</Button>
</div>
</PromptCard>
);
}

View File

@@ -1,66 +0,0 @@
import { Button } from '@/components/ui/button';
import { AnimatePresence, motion } from 'framer-motion';
import { XIcon } from 'lucide-react';
interface PromptCardProps {
title: string;
subtitle: string;
onClose: () => void;
children: React.ReactNode;
gradientColor?: string;
show: boolean;
}
export function PromptCard({
title,
subtitle,
onClose,
children,
gradientColor = 'rgb(16 185 129)',
show,
}: PromptCardProps) {
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
>
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
<div className="relative px-6 col gap-1">
<div
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
style={{
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 70%)`,
}}
/>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold max-w-[200px] leading-snug">
{title}
</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,7 +1,7 @@
import { PromptCard } from '@/components/organization/prompt-card';
import { LinkButton } from '@/components/ui/button';
import { Button, LinkButton } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { AnimatePresence, motion } from 'framer-motion';
import {
AwardIcon,
HeartIcon,
@@ -9,6 +9,7 @@ import {
MessageCircleIcon,
RocketIcon,
SparklesIcon,
XIcon,
ZapIcon,
} from 'lucide-react';
@@ -77,43 +78,70 @@ export default function SupporterPrompt() {
}
return (
<PromptCard
title="Support OpenPanel"
subtitle="Help us build the future of open analytics"
onClose={() => setSupporterPromptClosed(true)}
show={!supporterPromptClosed}
gradientColor="rgb(16 185 129)"
>
<div className="col gap-3 px-6">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="px-6">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
<AnimatePresence>
{!supporterPromptClosed && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</PromptCard>
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
<div>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setSupporterPromptClosed(true)}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Help us build the future of open analytics
</p>
</div>
<div className="col gap-3">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="pt-2">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,75 +0,0 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { InsightCard } from '../insights/insight-card';
import { Skeleton } from '../skeleton';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '../ui/carousel';
interface OverviewInsightsProps {
projectId: string;
}
export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
const trpc = useTRPC();
const [filters, setFilter] = useEventQueryFilters();
const { data: insights, isLoading } = useQuery(
trpc.insight.list.queryOptions({
projectId,
limit: 20,
}),
);
if (isLoading) {
const keys = Array.from({ length: 4 }, (_, i) => `insight-skeleton-${i}`);
return (
<div className="col-span-6">
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent className="-ml-4">
{keys.map((key) => (
<CarouselItem
key={key}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<Skeleton className="h-36 w-full" />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
);
}
if (!insights || insights.length === 0) return null;
return (
<div className="col-span-6 -mx-4">
<Carousel opts={{ align: 'start' }} className="w-full group">
<CarouselContent className="mr-4">
{insights.map((insight) => (
<CarouselItem
key={insight.id}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<InsightCard
insight={insight}
onFilter={() => {
insight.payload.dimensions.forEach((dim) => {
void setFilter(dim.key, dim.value, 'is');
});
}}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
<CarouselNext className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
</Carousel>
</div>
);
}

View File

@@ -1,153 +0,0 @@
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import React from 'react';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import type { IInterval } from '@openpanel/validation';
import { SerieIcon } from '../report-chart/common/serie-icon';
type Data = {
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
};
type Context = {
interval: IInterval;
};
export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
({ context: { interval }, data }) => {
const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber();
if (!data || data.length === 0) {
return null;
}
const firstItem = data[0];
// Get all payload items from the first data point
// Keys are in format "prefix:name:payload" or "name:payload"
const payloadItems = Object.keys(firstItem)
.filter((key) => key.endsWith(':payload'))
.map((key) => {
const payload = firstItem[key as keyof typeof firstItem] as {
name: string;
prefix?: string;
color: string;
};
// Extract the base key (without :payload) to access sessions/pageviews/revenue
const baseKey = key.replace(':payload', '');
return {
payload,
baseKey,
};
})
.filter(
(item) =>
item.payload &&
typeof item.payload === 'object' &&
'name' in item.payload,
);
// Sort by sessions (descending)
const sorted = payloadItems.sort((a, b) => {
const aSessions =
(firstItem[
`${a.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const bSessions =
(firstItem[
`${b.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
return bSessions - aSessions;
});
const limit = 3;
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<>
{visible.map((item, index) => {
const sessions =
(firstItem[
`${item.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const pageviews =
(firstItem[
`${item.baseKey}:pageviews` as keyof typeof firstItem
] as number) ?? 0;
const revenue = firstItem[
`${item.baseKey}:revenue` as keyof typeof firstItem
] as number | undefined;
return (
<React.Fragment key={item.baseKey}>
{index === 0 && firstItem.date && (
<ChartTooltipHeader>
<div>{formatDate(new Date(firstItem.date))}</div>
</ChartTooltipHeader>
)}
<ChartTooltipItem color={item.payload.color}>
<div className="flex items-center gap-1">
<SerieIcon name={item.payload.prefix || item.payload.name} />
<div className="font-medium">
{item.payload.prefix && (
<>
<span className="text-muted-foreground">
{item.payload.prefix}
</span>
<span className="mx-1">/</span>
</>
)}
{item.payload.name || 'Not set'}
</div>
</div>
<div className="col gap-1 text-sm">
{revenue !== undefined && revenue > 0 && (
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Revenue</span>
<span style={{ color: '#3ba974' }}>
{number.currency(revenue / 100, { short: true })}
</span>
</div>
)}
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Pageviews</span>
<span>{number.short(pageviews)}</span>
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Sessions</span>
<span>{number.short(sessions)}</span>
</div>
</div>
</ChartTooltipItem>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground text-sm">
and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'}
</div>
)}
</>
);
},
);

View File

@@ -1,303 +0,0 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { OverviewLineChartTooltip } from './overview-line-chart-tooltip';
type SeriesData =
RouterOutputs['overview']['topGenericSeries']['items'][number];
interface OverviewLineChartProps {
data: RouterOutputs['overview']['topGenericSeries'];
interval: IInterval;
searchQuery?: string;
className?: string;
}
function transformDataForRecharts(
items: SeriesData[],
searchQuery?: string,
): Array<{
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
}> {
// Filter items by search query
const filteredItems = searchQuery
? items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: items;
// Limit to top 15
const topItems = filteredItems.slice(0, 15);
// Get all unique dates from all items
const allDates = new Set<string>();
topItems.forEach((item) => {
item.data.forEach((d) => allDates.add(d.date));
});
const sortedDates = Array.from(allDates).sort();
// Transform to recharts format
return sortedDates.map((date) => {
const timestamp = new Date(date).getTime();
const result: Record<string, any> = {
date,
timestamp,
};
topItems.forEach((item, index) => {
const dataPoint = item.data.find((d) => d.date === date);
if (dataPoint) {
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix ? `${item.prefix}:${item.name}` : item.name;
result[`${key}:sessions`] = dataPoint.sessions;
result[`${key}:pageviews`] = dataPoint.pageviews;
if (dataPoint.revenue !== undefined) {
result[`${key}:revenue`] = dataPoint.revenue;
}
result[`${key}:payload`] = {
name: item.name,
prefix: item.prefix,
color: getChartColor(index),
};
}
});
return result as typeof result & {
date: string;
timestamp: number;
};
});
}
export function OverviewLineChart({
data,
interval,
searchQuery,
className,
}: OverviewLineChartProps) {
const number = useNumber();
const chartData = useMemo(
() => transformDataForRecharts(data.items, searchQuery),
[data.items, searchQuery],
);
const visibleItems = useMemo(() => {
const filtered = searchQuery
? data.items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: data.items;
return filtered.slice(0, 15);
}, [data.items, searchQuery]);
const xAxisProps = useXAxisProps({ interval, hide: false });
const yAxisProps = useYAxisProps({});
if (visibleItems.length === 0) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">
{searchQuery ? 'No results found' : 'No data available'}
</div>
</div>
);
}
return (
<div className={cn('w-full p-4', className)}>
<div className="h-[358px] w-full">
<OverviewLineChartTooltip.TooltipProvider interval={interval}>
<ResponsiveContainer>
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<OverviewLineChartTooltip.Tooltip />} />
{visibleItems.map((item, index) => {
const color = getChartColor(index);
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix
? `${item.prefix}:${item.name}`
: item.name;
return (
<Line
key={key}
type="monotone"
dataKey={`${key}:sessions`}
stroke={color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</OverviewLineChartTooltip.TooltipProvider>
</div>
{/* Legend */}
<LegendScrollable items={visibleItems} />
</div>
);
}
function LegendScrollable({
items,
}: {
items: SeriesData[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when items change
useEffect(() => {
requestAnimationFrame(updateGradients);
}, [items, updateGradients]);
return (
<div className="relative mt-4 -mb-2">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable legend */}
<div
ref={scrollRef}
className="flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 hide-scrollbar text-xs"
>
{items.map((item, index) => {
const color = getChartColor(index);
return (
<div
className="flex shrink-0 items-center gap-1"
key={item.prefix ? `${item.prefix}:${item.name}` : item.name}
style={{ color }}
>
<SerieIcon name={item.prefix || item.name} />
<span className="font-semibold whitespace-nowrap">
{item.prefix && (
<>
<span className="text-muted-foreground">{item.prefix}</span>
<span className="mx-1">/</span>
</>
)}
{item.name || 'Not set'}
</span>
</div>
);
})}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
);
}
export function OverviewLineChartLoading({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
export function OverviewLineChartEmpty({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">No data available</div>
</div>
);
}

View File

@@ -1,270 +0,0 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { ModalContent } from '@/modals/Modal/Container';
import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import type React from 'react';
import { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
// Revenue pie chart component
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">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-def-200"
/>
<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>
);
}
// Base data type that all items must conform to
export interface OverviewListItem {
sessions: number;
pageviews: number;
revenue?: number;
}
interface OverviewListModalProps<T extends OverviewListItem> {
/** Modal title */
title: string;
/** Search placeholder text */
searchPlaceholder?: string;
/** The data to display */
data: T[];
/** Extract a unique key for each item */
keyExtractor: (item: T) => string;
/** Filter function for search - receives item and lowercase search query */
searchFilter: (item: T, query: string) => boolean;
/** Render the main content cell (first column) */
renderItem: (item: T) => React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
/** Optional header content (appears below title/search) */
headerContent?: React.ReactNode;
/** Column name for the first column */
columnName?: string;
/** Whether to show pageviews column */
showPageviews?: boolean;
/** Whether to show sessions column */
showSessions?: boolean;
}
export function OverviewListModal<T extends OverviewListItem>({
title,
searchPlaceholder = 'Search...',
data,
keyExtractor,
searchFilter,
renderItem,
footer,
headerContent,
columnName = 'Name',
showPageviews = true,
showSessions = true,
}: OverviewListModalProps<T>) {
const [searchQuery, setSearchQuery] = useState('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const number = useNumber();
// Filter data based on search query
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => searchFilter(item, queryLower));
}, [data, searchQuery, searchFilter]);
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(
...filteredData.map((item) => item.sessions),
);
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
);
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews =
showPageviews && filteredData.some((item) => item.pageviews > 0);
return { maxSessions, totalRevenue, hasRevenue, hasPageviews };
}, [filteredData, showPageviews]);
// Virtual list setup
const virtualizer = useVirtualizer({
count: filteredData.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
{/* Sticky Header */}
<div className="flex-shrink-0 border-b border-border">
<div className="p-6 pb-4">
<DialogTitle className="text-lg font-semibold mb-4">
{title}
</DialogTitle>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{headerContent}
</div>
{/* Column Headers */}
<div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{
gridTemplateColumns:
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
{hasRevenue && <div className="text-right">Revenue</div>}
{hasPageviews && <div className="text-right">Views</div>}
{showSessions && <div className="text-right">Sessions</div>}
</div>
</div>
{/* Virtualized Scrollable Body */}
<div
ref={scrollAreaRef}
className="flex-1 min-h-0 overflow-y-auto"
style={{ maxHeight: '60vh' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const item = filteredData[virtualRow.index];
if (!item) return null;
const percentage = item.sessions / maxSessions;
const revenuePercentage =
totalRevenue > 0 ? (item.revenue ?? 0) / totalRevenue : 0;
return (
<div
key={keyExtractor(item)}
className="absolute top-0 left-0 w-full group/row"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Background bar */}
<div className="absolute inset-0 overflow-hidden">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
style={{ width: `${percentage * 100}%` }}
/>
</div>
{/* Row content */}
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns:
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">
{renderItem(item)}
</div>
{/* Revenue cell */}
{hasRevenue && (
<div className="flex items-center justify-end gap-2">
<span
className="font-semibold font-mono text-sm"
style={{ color: '#3ba974' }}
>
{(item.revenue ?? 0) > 0
? number.currency((item.revenue ?? 0) / 100, {
short: true,
})
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
)}
{/* Pageviews cell */}
{hasPageviews && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.pageviews)}
</div>
)}
{/* Sessions cell */}
{showSessions && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.sessions)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Empty state */}
{filteredData.length === 0 && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{searchQuery ? 'No results found' : 'No data available'}
</div>
)}
</div>
{/* Fixed Footer */}
{footer && (
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
)}
</ModalContent>
);
}

View File

@@ -1,4 +1,5 @@
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { useNumber } from '@/hooks/use-numer-formatter';
@@ -7,14 +8,18 @@ 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 { createPortal } from 'react-dom';
import {
Bar,
BarChart,
CartesianGrid,
Customized,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps {
projectId: string;
@@ -81,8 +86,10 @@ export function OverviewLiveHistogram({
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="sessionCount"
className="fill-chart-0"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>

View File

@@ -1,7 +1,7 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
import { Area, AreaChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
@@ -144,33 +144,51 @@ export function OverviewMetricCard({
<div className={cn('group relative p-4')}>
<div
className={cn(
'absolute left-4 right-4 bottom-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 style={{ height: 20 }}>
{({ width }) => (
<BarChart
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={20}
height={height / 4}
data={data}
style={{
background: 'transparent',
}}
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
onMouseMove={(event) => {
setCurrentIndex(event.activeTooltipIndex ?? null);
}}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Tooltip content={() => null} cursor={false} />
<Bar
<defs>
<linearGradient
id={`colorUv${id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={graphColors}
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor={graphColors}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Tooltip content={() => null} />
<Area
dataKey={'current'}
type="step"
fill={graphColors}
fill={`url(#colorUv${id})`}
fillOpacity={1}
strokeWidth={0}
stroke={graphColors}
strokeWidth={1}
isAnimationActive={false}
/>
</BarChart>
</AreaChart>
)}
</AutoSizer>
</div>
@@ -207,11 +225,13 @@ export function OverviewMetricCardNumber({
isLoading?: boolean;
}) {
return (
<div className={cn('min-w-0 col gap-2', className)}>
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
</div>
</div>
{isLoading ? (
<div className="flex items-end justify-between gap-4">
@@ -219,13 +239,13 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
{value}
<div className="flex items-end justify-between gap-4">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
{enhancer}
</div>
)}
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
{enhancer}
</div>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
@@ -11,12 +13,7 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -34,7 +31,6 @@ export default function OverviewTopDevices({
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [chartType] = useState<IChartType>('bar');
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
device: {
@@ -320,7 +316,6 @@ export default function OverviewTopDevices({
});
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -333,67 +328,31 @@ export default function OverviewTopDevices({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={filteredData}
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -425,8 +384,7 @@ export default function OverviewTopDevices({
})
}
/>
<div className="flex-1" />
<OverviewViewToggle />
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>

View File

@@ -1,172 +1,225 @@
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IReportInput } from '@openpanel/validation';
import type { IChartType } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
type EventTableItem,
OverviewWidgetTableEvents,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidgetV2 } from './useOverviewWidget';
import { useOverviewWidget } from './useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }),
);
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', {
const [chartType, setChartType] = useState<IChartType>('bar');
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
your: {
title: 'Events',
btn: 'Events',
meta: {
filters: [
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
eventName: '*',
title: 'Top events',
btn: 'Your',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
all: {
title: 'Top events',
btn: 'All',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [...filters],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: !conversions || conversions.length === 0,
meta: {
filters: [
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
eventName: '*',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',
},
},
},
link_out: {
title: 'Link out',
btn: 'Link out',
meta: {
filters: [],
eventName: 'link_out',
breakdownProperty: 'properties.href',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
id: 'A',
name: 'link_out',
filters: [],
},
],
breakdowns: [
{
id: 'A',
name: 'properties.href',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Link out',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
const report: IReportInput = useMemo(
() => ({
limit: 1000,
projectId,
startDate,
endDate,
series: [
{
type: 'event' as const,
segment: 'event' as const,
filters: [...filters, ...(widget.meta?.filters ?? [])],
id: 'A',
name: widget.meta?.eventName ?? '*',
},
],
breakdowns: [
{
id: 'A',
name: widget.meta?.breakdownProperty ?? 'name',
},
],
chartType: 'bar' as const,
interval,
range,
previous,
metric: 'sum' as const,
}),
[projectId, startDate, endDate, filters, widget, interval, range, previous],
);
const query = useQuery(trpc.chart.aggregate.queryOptions(report));
const tableData: EventTableItem[] = useMemo(() => {
if (!query.data?.series) return [];
return query.data.series.map((serie) => ({
id: serie.id,
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
count: serie.metrics.sum,
}));
}, [query.data]);
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData;
}
const queryLower = searchQuery.toLowerCase();
return tableData.filter((item) =>
item.name?.toLowerCase().includes(queryLower),
);
}, [tableData, searchQuery]);
const tabs = useMemo(
() =>
widgets
.filter((item) => item.hide !== true)
.map((w) => ({
key: w.key,
label: w.btn,
})),
[widgets],
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.breakdownProperty) {
setFilter(widget.meta.breakdownProperty, name);
} else {
setFilter('name', name);
}
}}
/>
)}
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-3">
<ReportChart
options={{
hideID: true,
columns: ['Event'],
renderSerieName(names) {
return names[1];
},
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
</WidgetBody>
<WidgetFooter>
<div className="flex-1" />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>

View File

@@ -1,15 +1,18 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import type { IGetTopGenericInput } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import {
OVERVIEW_COLUMNS_NAME,
OVERVIEW_COLUMNS_NAME_PLURAL,
} from './overview-constants';
import { OverviewListModal } from './overview-list-modal';
import { OverviewWidgetTableGeneric } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopGenericModalProps {
@@ -21,55 +24,83 @@ export default function OverviewTopGenericModal({
projectId,
column,
}: OverviewTopGenericModalProps) {
const [_filters, setFilter] = useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
filters: _filters,
startDate,
endDate,
range,
column,
}),
const query = useInfiniteQuery(
trpc.overview.topGeneric.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
range,
limit: 50,
column,
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length === 0) {
return null;
}
return pages.length + 1;
},
},
),
);
const data = query.data?.pages.flat() || [];
const isEmpty = !query.hasNextPage && !query.isFetching;
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
const columnName = OVERVIEW_COLUMNS_NAME[column];
return (
<OverviewListModal
title={`Top ${columnNamePlural}`}
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
data={query.data ?? []}
keyExtractor={(item) => (item.prefix ?? '') + item.name}
searchFilter={(item, query) =>
item.name?.toLowerCase().includes(query) ||
item.prefix?.toLowerCase().includes(query) ||
false
}
columnName={columnName}
renderItem={(item) => (
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter(column, item.name);
}}
<ModalContent>
<ModalHeader title={`Top ${columnNamePlural}`} />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTableGeneric
data={data}
column={{
name: columnName,
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(column, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
disabled={isEmpty}
>
{item.prefix && (
<span className="mr-1 inline-flex items-center gap-1">
<span>{item.prefix}</span>
<ChevronRightIcon className="size-3" />
</span>
)}
{item.name || 'Not set'}
</button>
Load more
</Button>
</div>
)}
/>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -1,8 +1,10 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { countries } from '@/translations/countries';
@@ -11,20 +13,10 @@ import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { ReportChartShortcut } from '../report-chart/shortcut';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import {
WidgetFooter,
WidgetHead,
WidgetHeadSearchable,
} from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -40,7 +32,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
@@ -57,8 +48,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
});
const number = useNumber();
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -71,74 +62,31 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.name?.toLowerCase().includes(queryLower) ||
item.prefix?.toLowerCase().includes(queryLower) ||
countries[item.name as keyof typeof countries]
?.toLowerCase()
.includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={filteredData}
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -182,7 +130,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
/>
)}
</WidgetBody>
<WidgetFooter className="row items-center justify-between">
<WidgetFooter>
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
@@ -191,19 +139,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
})
}
/>
<div className="flex-1" />
<OverviewViewToggle />
<span className="text-sm text-muted-foreground pr-2 ml-2">
Geo data provided by{' '}
<a
href="https://ipdata.co"
target="_blank"
rel="noopener noreferrer nofollow"
className="hover:underline"
>
MaxMind
</a>
</span>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
<Widget className="col-span-6 md:col-span-3">
@@ -211,8 +147,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
{...{
<ReportChart
options={{ hideID: true }}
report={{
projectId,
startDate,
endDate,
@@ -232,9 +169,12 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>

View File

@@ -1,11 +1,11 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
import { OverviewListModal } from './overview-list-modal';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { OverviewWidgetTablePages } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopPagesProps {
@@ -18,54 +18,44 @@ export default function OverviewTopPagesModal({
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topPages.queryOptions({
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
}),
const query = useInfiniteQuery(
trpc.overview.topPages.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
limit: 50,
},
{
getNextPageParam: (_, pages) => pages.length + 1,
},
),
);
const data = query.data?.pages.flat();
return (
<OverviewListModal
title="Top Pages"
searchPlaceholder="Search pages..."
data={query.data ?? []}
keyExtractor={(item) => item.path + item.origin}
searchFilter={(item, query) =>
item.path.toLowerCase().includes(query) ||
item.origin.toLowerCase().includes(query)
}
columnName="Path"
renderItem={(item) => (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path || <span className="opacity-40">Not set</span>}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<ExternalLinkIcon className="size-3 opacity-0 group-hover/row:opacity-100 transition-opacity" />
</a>
</div>
</Tooltiper>
)}
/>
<ModalContent>
<ModalHeader title="Top Pages" />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={'Sessions'}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
loading={query.isFetching}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -1,7 +1,7 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,9 +9,8 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { Widget, WidgetBody } from '../widget';
import OverviewDetailsButton from './overview-details-button';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableEntries,
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
@@ -26,11 +25,15 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Pages',
btn: 'Top pages',
meta: {
columns: {
sessions: 'Sessions',
},
},
},
entry: {
title: 'Entry Pages',
@@ -50,6 +53,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
},
},
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const trpc = useTRPC();
@@ -64,53 +71,37 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
}),
);
const filteredData = useMemo(() => {
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.path.toLowerCase().includes(queryLower) ||
item.origin.toLowerCase().includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
const data = query.data;
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<>
{widget.meta?.columns.sessions ? (
<OverviewWidgetTableEntries
data={filteredData}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
) : (
<OverviewWidgetTablePages
data={filteredData}
showDomain={!!domain}
/>
)}
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
</>
)}
</WidgetBody>
@@ -118,6 +109,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
<OverviewDetailsButton
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<Button
variant={'ghost'}

View File

@@ -1,5 +1,5 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,12 +9,7 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -28,18 +23,16 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const [view] = useOverviewView();
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'Refs',
btn: 'All',
},
referrer: {
title: 'Top urls',
btn: 'Urls',
btn: 'URLs',
},
referrer_type: {
title: 'Top types',
@@ -79,67 +72,31 @@ export default function OverviewTopSources({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={filteredData}
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -180,8 +137,7 @@ export default function OverviewTopSources({
})
}
/>
<div className="flex-1" />
<OverviewViewToggle />
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>

View File

@@ -1,408 +0,0 @@
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ResponsiveSankey } from '@nivo/sankey';
import { parseAsInteger, useQueryState } from 'nuqs';
import {
type ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTRPC } from '@/integrations/trpc/react';
import { truncate } from '@/utils/truncate';
import { useQuery } from '@tanstack/react-query';
import { ArrowRightIcon } from 'lucide-react';
import { useTheme } from '../theme-provider';
import { Widget, WidgetBody } from '../widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewUserJourneyProps {
projectId: string;
}
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
const showPath = (string: string) => {
try {
const url = new URL(string);
return url.pathname;
} catch {
return string;
}
};
const showDomain = (string: string) => {
try {
const url = new URL(string);
return url.hostname;
} catch {
return string;
}
};
function SankeyPortalTooltip({
children,
offset = 12,
padding = 8,
}: {
children: ReactNode;
offset?: number;
padding?: number;
}) {
const anchorRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const [pos, setPos] = useState<PortalTooltipPosition>({
left: 0,
top: 0,
ready: false,
});
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
useLayoutEffect(() => {
const el = anchorRef.current;
if (!el) return;
// Nivo renders the tooltip content inside an absolutely-positioned wrapper <div>.
// The wrapper is the immediate parent of our rendered content.
const wrapper = el.parentElement;
if (!wrapper) return;
const update = () => {
setAnchorRect(wrapper.getBoundingClientRect());
};
update();
const ro = new ResizeObserver(update);
ro.observe(wrapper);
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, []);
useLayoutEffect(() => {
if (!mounted) return;
if (!anchorRect) return;
const tooltipEl = tooltipRef.current;
if (!tooltipEl) return;
const rect = tooltipEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Start by following Nivo's tooltip anchor position.
let left = anchorRect.left + offset;
let top = anchorRect.top + offset;
// Clamp inside viewport with a little padding.
left = Math.min(
Math.max(padding, left),
Math.max(padding, vw - rect.width - padding),
);
top = Math.min(
Math.max(padding, top),
Math.max(padding, vh - rect.height - padding),
);
setPos({ left, top, ready: true });
}, [mounted, anchorRect, children, offset, padding]);
// SSR safety: on the server, just render the tooltip normally.
if (typeof document === 'undefined') {
return <>{children}</>;
}
return (
<>
{/* Render a tiny (screen-reader-only) anchor inside Nivo's tooltip wrapper. */}
<span ref={anchorRef} className="sr-only" />
{mounted &&
createPortal(
<div
ref={tooltipRef}
className="pointer-events-none fixed z-[9999]"
style={{
left: pos.left,
top: pos.top,
visibility: pos.ready ? 'visible' : 'hidden',
}}
>
{children}
</div>,
document.body,
)}
</>
);
}
export default function OverviewUserJourney({
projectId,
}: OverviewUserJourneyProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [steps, setSteps] = useQueryState(
'journeySteps',
parseAsInteger.withDefault(5).withOptions({ history: 'push' }),
);
const containerRef = useRef<HTMLDivElement>(null);
const trpc = useTRPC();
const query = useQuery(
trpc.overview.userJourney.queryOptions({
projectId,
filters,
startDate,
endDate,
range,
steps: steps ?? 5,
}),
);
const data = query.data;
const number = useNumber();
// Process data for Sankey - nodes are already sorted by step then value from backend
const sankeyData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
return {
nodes: data.nodes.map((node: any) => ({
...node,
// Store label for display in tooltips
label: node.label || node.id,
data: {
percentage: node.percentage,
value: node.value,
step: node.step,
label: node.label || node.id,
},
})),
links: data.links,
};
}, [data]);
const totalSessions = useMemo(() => {
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
// Total sessions used by backend for percentages is the sum of entry nodes (step 1).
// Fall back to summing all nodes if step is missing for some reason.
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
const base = step1.length > 0 ? step1 : sankeyData.nodes;
return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
}, [sankeyData.nodes]);
const stepOptions = [3, 5];
const { appTheme } = useTheme();
return (
<Widget className="col-span-6">
<WidgetHead>
<div className="title">User Journey</div>
<WidgetButtons>
{stepOptions.map((option) => (
<button
type="button"
key={option}
onClick={() => setSteps(option)}
className={cn((steps ?? 5) === option && 'active')}
>
{option} Steps
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
{query.isLoading ? (
<div className="flex items-center justify-center h-96">
<div className="text-sm text-muted-foreground">Loading...</div>
</div>
) : sankeyData.nodes.length === 0 ? (
<div className="flex items-center justify-center h-96">
<div className="text-sm text-muted-foreground">
No journey data available
</div>
</div>
) : (
<div
ref={containerRef}
className="w-full relative aspect-square md:aspect-[2]"
>
<ResponsiveSankey
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
data={sankeyData}
colors={(node: any) => node.nodeColor}
nodeBorderRadius={2}
animate={false}
nodeBorderWidth={0}
nodeOpacity={0.8}
linkContract={1}
linkOpacity={0.3}
linkBlendMode={'normal'}
nodeTooltip={({ node }: any) => {
const label = node?.data?.label ?? node?.label ?? node?.id;
const value = node?.data?.value ?? node?.value ?? 0;
const step = node?.data?.step;
const pct =
typeof node?.data?.percentage === 'number'
? node.data.percentage
: totalSessions > 0
? (value / totalSessions) * 100
: 0;
const color =
node?.color ??
node?.data?.nodeColor ??
node?.data?.color ??
node?.nodeColor ??
'#64748b';
return (
<SankeyPortalTooltip>
<ChartTooltipContainer className="min-w-[250px]">
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
<span className="opacity-40 mr-1">
{showDomain(label)}
</span>
{showPath(label)}
</div>
{typeof step === 'number' && (
<div className="shrink-0 text-muted-foreground">
Step {step}
</div>
)}
</ChartTooltipHeader>
<ChartTooltipItem color={color} innerClassName="gap-2">
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Sessions</div>
<div>{number.format(value)}</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Share</div>
<div>{number.format(round(pct, 1))} %</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</SankeyPortalTooltip>
);
}}
linkTooltip={({ link }: any) => {
const sourceLabel =
link?.source?.data?.label ??
link?.source?.label ??
link?.source?.id;
const targetLabel =
link?.target?.data?.label ??
link?.target?.label ??
link?.target?.id;
const value = link?.value ?? 0;
const sourceValue =
link?.source?.data?.value ?? link?.source?.value ?? 0;
const pctOfTotal =
totalSessions > 0 ? (value / totalSessions) * 100 : 0;
const pctOfSource =
sourceValue > 0 ? (value / sourceValue) * 100 : 0;
const sourceStep = link?.source?.data?.step;
const targetStep = link?.target?.data?.step;
const color =
link?.color ??
link?.source?.color ??
link?.source?.data?.nodeColor ??
'#64748b';
const sourceDomain = showDomain(sourceLabel);
const targetDomain = showDomain(targetLabel);
const isSameDomain = sourceDomain === targetDomain;
return (
<SankeyPortalTooltip>
<ChartTooltipContainer>
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
<span className="opacity-40 mr-1">
{showDomain(sourceLabel)}
</span>
{showPath(sourceLabel)}
<ArrowRightIcon className="size-2 inline-block mx-3" />
{!isSameDomain && (
<span className="opacity-40 mr-1">
{showDomain(targetLabel)}
</span>
)}
{showPath(targetLabel)}
</div>
{typeof sourceStep === 'number' &&
typeof targetStep === 'number' && (
<div className="shrink-0 text-muted-foreground">
{sourceStep} {targetStep}
</div>
)}
</ChartTooltipHeader>
<ChartTooltipItem color={color} innerClassName="gap-2">
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Sessions</div>
<div>{number.format(value)}</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono text-sm">
<div className="text-muted-foreground">
% of total
</div>
<div>{number.format(round(pctOfTotal, 1))} %</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono text-sm">
<div className="text-muted-foreground">
% of source
</div>
<div>{number.format(round(pctOfSource, 1))} %</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</SankeyPortalTooltip>
);
}}
label={(node: any) => {
const label = showPath(
node.data?.label || node.label || node.id,
);
return truncate(label, 30, 'middle');
}}
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
nodeSpacing={10}
/>
</div>
)}
</WidgetBody>
<WidgetFooter>
<div className="text-xs text-muted-foreground">
Shows the most common paths users take through your application
</div>
</WidgetFooter>
</Widget>
);
}

View File

@@ -1,54 +0,0 @@
import { LineChartIcon, TableIcon } from 'lucide-react';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { Button } from '../ui/button';
type ViewType = 'table' | 'chart';
interface OverviewViewToggleProps {
defaultView?: ViewType;
className?: string;
}
export function OverviewViewToggle({
defaultView = 'table',
className,
}: OverviewViewToggleProps) {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault(defaultView)
.withOptions({ history: 'push' }),
);
return (
<div className={className}>
<Button
size="icon"
variant="ghost"
onClick={() => {
setView(view === 'table' ? 'chart' : 'table');
}}
title={view === 'table' ? 'Switch to chart view' : 'Switch to table view'}
>
{view === 'table' ? (
<LineChartIcon size={16} />
) : (
<TableIcon size={16} />
)}
</Button>
</div>
);
}
export function useOverviewView() {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault('table')
.withOptions({ history: 'push' }),
);
return [view, setView] as const;
}

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