19 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
3ee1463d4f docs: add groups 2026-03-18 21:49:08 +01:00
Carl-Gerhard Lindesvärd
2dc622cbf2 fix group issues 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
995f32c5d8 group validation 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
fa78e63bc8 wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
e6d0b6544b fix 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
058c3621df fixes 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
c2d12c556d wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
05a2fb5846 wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
8fd8b9319d add buffer 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
0b5d4fa0d1 wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
0cfccd549b wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
289ffb7d6d wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
90881e5ffb wip 2026-03-18 21:16:16 +01:00
Carl-Gerhard Lindesvärd
765e4aa107 wip 2026-03-18 21:06:36 +01:00
Carl-Gerhard Lindesvärd
d1b39c4c93 fix: funnel on profile id
This will break mixed profile_id (anon + identified) but its worth it because its "correct". This will also be fixed when we have enabled backfill profile id on a session
2026-03-18 21:04:45 +01:00
Carl-Gerhard Lindesvärd
33431510b4 public: seo 2026-03-17 13:12:47 +01:00
Carl-Gerhard Lindesvärd
5557db83a6 fix: add filters for sessions table 2026-03-16 13:31:48 +01:00
Carl-Gerhard Lindesvärd
eab33d3127 fix: make table rows clickable 2026-03-16 13:30:34 +01:00
Carl-Gerhard Lindesvärd
4483e464d1 fix: optimize event buffer (#278)
* fix: how we fetch profiles in the buffer

* perf: optimize event buffer

* remove unused file

* fix

* wip

* wip: try groupmq 2

* try simplified event buffer with duration calculation on the fly instead
2026-03-16 13:29:40 +01:00
155 changed files with 8507 additions and 3464 deletions

View File

@@ -30,7 +30,6 @@
"@openpanel/logger": "workspace:*", "@openpanel/logger": "workspace:*",
"@openpanel/payments": "workspace:*", "@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*", "@openpanel/queue": "workspace:*",
"groupmq": "catalog:",
"@openpanel/redis": "workspace:*", "@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*", "@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*", "@openpanel/validation": "workspace:*",
@@ -40,6 +39,7 @@
"fastify": "^5.6.1", "fastify": "^5.6.1",
"fastify-metrics": "^12.1.0", "fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0", "fastify-raw-body": "^5.0.0",
"groupmq": "catalog:",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",

View File

@@ -63,6 +63,7 @@ async function main() {
imported_at: null, imported_at: null,
sdk_name: 'test-script', sdk_name: 'test-script',
sdk_version: '1.0.0', sdk_version: '1.0.0',
groups: [],
}); });
} }

View File

@@ -1,4 +1,4 @@
import { cacheable, cacheableLru } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import bots from './bots'; import bots from './bots';
// Pre-compile regex patterns at module load time // Pre-compile regex patterns at module load time
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot); const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
const includesBots = compiledBots.filter((bot) => 'includes' in bot); const includesBots = compiledBots.filter((bot) => 'includes' in bot);
export const isBot = cacheableLru( export const isBot = cacheable(
'is-bot', 'is-bot',
(ua: string) => { (ua: string) => {
// Check simple string patterns first (fast) // Check simple string patterns first (fast)
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
return null; return null;
}, },
{ 60 * 5
maxSize: 1000,
ttl: 60 * 5,
},
); );

View File

@@ -1,12 +1,5 @@
import type { FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket'; import type { WebSocket } from '@fastify/websocket';
import { import { eventBuffer } from '@openpanel/db';
eventBuffer,
getProfileById,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json'; import { setSuperJson } from '@openpanel/json';
import { import {
psubscribeToPublishedEvent, psubscribeToPublishedEvent,
@@ -14,10 +7,7 @@ import {
} from '@openpanel/redis'; } from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc'; import { getProjectAccess } from '@openpanel/trpc';
import { getOrganizationAccess } from '@openpanel/trpc/src/access'; import { getOrganizationAccess } from '@openpanel/trpc/src/access';
import type { FastifyRequest } from 'fastify';
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
}
export function wsVisitors( export function wsVisitors(
socket: WebSocket, socket: WebSocket,
@@ -25,27 +15,38 @@ export function wsVisitors(
Params: { Params: {
projectId: string; projectId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => { const sendCount = () => {
if (event?.projectId === params.projectId) { eventBuffer
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { .getActiveVisitorCount(params.projectId)
.then((count) => {
socket.send(String(count)); socket.send(String(count));
})
.catch(() => {
socket.send('0');
}); });
};
const unsubscribe = subscribeToPublishedEvent(
'events',
'batch',
({ projectId }) => {
if (projectId === params.projectId) {
sendCount();
}
} }
}); );
const punsubscribe = psubscribeToPublishedEvent( const punsubscribe = psubscribeToPublishedEvent(
'__keyevent@0__:expired', '__keyevent@0__:expired',
(key) => { (key) => {
const [projectId] = getLiveEventInfo(key); const [, , projectId] = key.split(':');
if (projectId && projectId === params.projectId) { if (projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { sendCount();
socket.send(String(count));
});
} }
}, }
); );
socket.on('close', () => { socket.on('close', () => {
@@ -62,18 +63,10 @@ export async function wsProjectEvents(
}; };
Querystring: { Querystring: {
token?: string; token?: string;
type?: 'saved' | 'received';
}; };
}>, }>
) { ) {
const { params, query } = req; const { params } = req;
const type = query.type || 'saved';
if (!['saved', 'received'].includes(type)) {
socket.send('Invalid type');
socket.close();
return;
}
const userId = req.session?.userId; const userId = req.session?.userId;
if (!userId) { if (!userId) {
@@ -87,24 +80,20 @@ export async function wsProjectEvents(
projectId: params.projectId, projectId: params.projectId,
}); });
if (!access) {
socket.send('No access');
socket.close();
return;
}
const unsubscribe = subscribeToPublishedEvent( const unsubscribe = subscribeToPublishedEvent(
'events', 'events',
type, 'batch',
async (event) => { ({ projectId, count }) => {
if (event.projectId === params.projectId) { if (projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId); socket.send(setSuperJson({ count }));
socket.send(
superjson.stringify(
access
? {
...event,
profile,
}
: transformMinimalEvent(event),
),
);
} }
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());
@@ -116,7 +105,7 @@ export async function wsProjectNotifications(
Params: { Params: {
projectId: string; projectId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const userId = req.session?.userId; const userId = req.session?.userId;
@@ -143,9 +132,9 @@ export async function wsProjectNotifications(
'created', 'created',
(notification) => { (notification) => {
if (notification.projectId === params.projectId) { if (notification.projectId === params.projectId) {
socket.send(superjson.stringify(notification)); socket.send(setSuperJson(notification));
} }
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());
@@ -157,7 +146,7 @@ export async function wsOrganizationEvents(
Params: { Params: {
organizationId: string; organizationId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const userId = req.session?.userId; const userId = req.session?.userId;
@@ -184,7 +173,7 @@ export async function wsOrganizationEvents(
'subscription_updated', 'subscription_updated',
(message) => { (message) => {
socket.send(setSuperJson(message)); socket.send(setSuperJson(message));
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());

View File

@@ -1,5 +1,4 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { HttpError } from '@/utils/errors';
import { stripTrailingSlash } from '@openpanel/common'; import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server'; import { hashPassword } from '@openpanel/common/server';
import { import {
@@ -10,6 +9,7 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { HttpError } from '@/utils/errors';
// Validation schemas // Validation schemas
const zCreateProject = z.object({ const zCreateProject = z.object({
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
// Projects CRUD // Projects CRUD
export async function listProjects( export async function listProjects(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
const projects = await db.project.findMany({ const projects = await db.project.findMany({
where: { where: {
@@ -74,7 +74,7 @@ export async function listProjects(
export async function getProject( export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const project = await db.project.findFirst({ const project = await db.project.findFirst({
where: { where: {
@@ -92,7 +92,7 @@ export async function getProject(
export async function createProject( export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateProject.safeParse(request.body); const parsed = zCreateProject.safeParse(request.body);
@@ -139,12 +139,9 @@ export async function createProject(
}, },
}); });
// Clear cache
await Promise.all([ await Promise.all([
getProjectByIdCached.clear(project.id), getProjectByIdCached.clear(project.id),
project.clients.map((client) => { ...project.clients.map((client) => getClientByIdCached.clear(client.id)),
getClientByIdCached.clear(client.id);
}),
]); ]);
reply.send({ reply.send({
@@ -165,7 +162,7 @@ export async function updateProject(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateProject>; Body: z.infer<typeof zUpdateProject>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateProject.safeParse(request.body); const parsed = zUpdateProject.safeParse(request.body);
@@ -223,12 +220,9 @@ export async function updateProject(
data: updateData, data: updateData,
}); });
// Clear cache
await Promise.all([ await Promise.all([
getProjectByIdCached.clear(project.id), getProjectByIdCached.clear(project.id),
existing.clients.map((client) => { ...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
getClientByIdCached.clear(client.id);
}),
]); ]);
reply.send({ data: project }); reply.send({ data: project });
@@ -236,7 +230,7 @@ export async function updateProject(
export async function deleteProject( export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const project = await db.project.findFirst({ const project = await db.project.findFirst({
where: { where: {
@@ -266,7 +260,7 @@ export async function deleteProject(
// Clients CRUD // Clients CRUD
export async function listClients( export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>, request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const where: any = { const where: any = {
organizationId: request.client!.organizationId, organizationId: request.client!.organizationId,
@@ -300,7 +294,7 @@ export async function listClients(
export async function getClient( export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const client = await db.client.findFirst({ const client = await db.client.findFirst({
where: { where: {
@@ -318,7 +312,7 @@ export async function getClient(
export async function createClient( export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateClient.safeParse(request.body); const parsed = zCreateClient.safeParse(request.body);
@@ -374,7 +368,7 @@ export async function updateClient(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateClient>; Body: z.infer<typeof zUpdateClient>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateClient.safeParse(request.body); const parsed = zUpdateClient.safeParse(request.body);
@@ -417,7 +411,7 @@ export async function updateClient(
export async function deleteClient( export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const client = await db.client.findFirst({ const client = await db.client.findFirst({
where: { where: {
@@ -444,7 +438,7 @@ export async function deleteClient(
// References CRUD // References CRUD
export async function listReferences( export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>, request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const where: any = {}; const where: any = {};
@@ -488,7 +482,7 @@ export async function listReferences(
export async function getReference( export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const reference = await db.reference.findUnique({ const reference = await db.reference.findUnique({
where: { where: {
@@ -516,7 +510,7 @@ export async function getReference(
export async function createReference( export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateReference.safeParse(request.body); const parsed = zCreateReference.safeParse(request.body);
@@ -559,7 +553,7 @@ export async function updateReference(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateReference>; Body: z.infer<typeof zUpdateReference>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateReference.safeParse(request.body); const parsed = zUpdateReference.safeParse(request.body);
@@ -616,7 +610,7 @@ export async function updateReference(
export async function deleteReference( export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const reference = await db.reference.findUnique({ const reference = await db.reference.findUnique({
where: { where: {

View File

@@ -3,14 +3,20 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { import {
getProfileById, getProfileById,
getSalts, getSalts,
groupBuffer,
replayBuffer, replayBuffer,
upsertProfile, upsertProfile,
} from '@openpanel/db'; } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue'; import {
type EventsQueuePayloadIncomingEvent,
getEventsGroupQueueShard,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { import {
type IAssignGroupPayload,
type IDecrementPayload, type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload, type IIdentifyPayload,
type IIncrementPayload, type IIncrementPayload,
type IReplayPayload, type IReplayPayload,
@@ -112,6 +118,7 @@ interface TrackContext {
identity?: IIdentifyPayload; identity?: IIdentifyPayload;
deviceId: string; deviceId: string;
sessionId: string; sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
geo: GeoLocation; geo: GeoLocation;
} }
@@ -141,19 +148,21 @@ async function buildContext(
validatedBody.payload.profileId = profileId; validatedBody.payload.profileId = profileId;
} }
const overrideDeviceId =
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined;
// Get geo location (needed for track and identify) // Get geo location (needed for track and identify)
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]); const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
const { deviceId, sessionId } = await getDeviceId({ const deviceIdResult = await getDeviceId({
projectId, projectId,
ip, ip,
ua, ua,
salts, salts,
overrideDeviceId: overrideDeviceId,
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined,
}); });
return { return {
@@ -166,8 +175,9 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast, isFromPast: timestamp.isTimestampFromThePast,
}, },
identity, identity,
deviceId, deviceId: deviceIdResult.deviceId,
sessionId, sessionId: deviceIdResult.sessionId,
session: deviceIdResult.session,
geo, geo,
}; };
} }
@@ -176,13 +186,14 @@ async function handleTrack(
payload: ITrackPayload, payload: ITrackPayload,
context: TrackContext context: TrackContext
): Promise<void> { ): Promise<void> {
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context; const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
context;
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? payload.profileId ? payload.profileId
? `${projectId}:${payload.profileId}` ? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}` : undefined
: deviceId; : deviceId;
const jobId = [ const jobId = [
slug(payload.name), slug(payload.name),
@@ -203,13 +214,14 @@ async function handleTrack(
} }
promises.push( promises.push(
getEventsGroupQueueShard(groupId).add({ getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value, orderMs: timestamp.value,
data: { data: {
projectId, projectId,
headers, headers,
event: { event: {
...payload, ...payload,
groups: payload.groups ?? [],
timestamp: timestamp.value, timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast, isTimestampFromThePast: timestamp.isFromPast,
}, },
@@ -217,6 +229,7 @@ async function handleTrack(
geo, geo,
deviceId, deviceId,
sessionId, sessionId,
session,
}, },
groupId, groupId,
jobId, jobId,
@@ -324,6 +337,36 @@ async function handleReplay(
await replayBuffer.add(row); await replayBuffer.add(row);
} }
async function handleGroup(
payload: IGroupPayload,
context: TrackContext
): Promise<void> {
const { id, type, name, properties = {} } = payload;
await groupBuffer.add({
id,
projectId: context.projectId,
type,
name,
properties,
});
}
async function handleAssignGroup(
payload: IAssignGroupPayload,
context: TrackContext
): Promise<void> {
const profileId = payload.profileId ?? context.deviceId;
if (!profileId) {
return;
}
await upsertProfile({
id: String(profileId),
projectId: context.projectId,
isExternal: !!payload.profileId,
groups: payload.groupIds,
});
}
export async function handler( export async function handler(
request: FastifyRequest<{ request: FastifyRequest<{
Body: ITrackHandlerPayload; Body: ITrackHandlerPayload;
@@ -372,6 +415,12 @@ export async function handler(
case 'replay': case 'replay':
await handleReplay(validatedBody.payload, context); await handleReplay(validatedBody.payload, context);
break; break;
case 'group':
await handleGroup(validatedBody.payload, context);
break;
case 'assign_group':
await handleAssignGroup(validatedBody.payload, context);
break;
default: default:
return reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,

View File

@@ -1,20 +1,19 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db'; import { createBotEvent } from '@openpanel/db';
import type { import type {
DeprecatedPostEventPayload, DeprecatedPostEventPayload,
ITrackHandlerPayload, ITrackHandlerPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isBot } from '@/bots';
export async function isBotHook( export async function isBotHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const bot = req.headers['user-agent'] const bot = req.headers['user-agent']
? isBot(req.headers['user-agent']) ? await isBot(req.headers['user-agent'])
: null; : null;
if (bot && req.client?.projectId) { if (bot && req.client?.projectId) {
@@ -44,6 +43,6 @@ export async function isBotHook(
} }
} }
return reply.status(202).send(); return reply.status(202).send({ bot });
} }
} }

View File

@@ -1,6 +1,5 @@
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook'; import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
@@ -13,7 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({ fastify.route({
method: 'POST', method: 'POST',
url: '/', url: '/',
handler: handler, handler,
}); });
fastify.route({ fastify.route({

View File

@@ -1,7 +1,12 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { generateDeviceId } from '@openpanel/common/server'; import { generateDeviceId } from '@openpanel/common/server';
import { getSafeJson } from '@openpanel/json'; import { getSafeJson } from '@openpanel/json';
import type {
EventsQueuePayloadCreateSessionEnd,
EventsQueuePayloadIncomingEvent,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { pick } from 'ramda';
export async function getDeviceId({ export async function getDeviceId({
projectId, projectId,
@@ -37,14 +42,20 @@ export async function getDeviceId({
ua, ua,
}); });
return await getDeviceIdFromSession({ return await getInfoFromSession({
projectId, projectId,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
}); });
} }
async function getDeviceIdFromSession({ interface DeviceIdResult {
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
}
async function getInfoFromSession({
projectId, projectId,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
@@ -52,7 +63,7 @@ async function getDeviceIdFromSession({
projectId: string; projectId: string;
currentDeviceId: string; currentDeviceId: string;
previousDeviceId: string; previousDeviceId: string;
}) { }): Promise<DeviceIdResult> {
try { try {
const multi = getRedisCache().multi(); const multi = getRedisCache().multi();
multi.hget( multi.hget(
@@ -65,21 +76,33 @@ async function getDeviceIdFromSession({
); );
const res = await multi.exec(); const res = await multi.exec();
if (res?.[0]?.[1]) { if (res?.[0]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>( const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[0]?.[1] as string) ?? '' (res?.[0]?.[1] as string) ?? ''
); );
if (data) { if (data) {
const sessionId = data.payload.sessionId; return {
return { deviceId: currentDeviceId, sessionId }; deviceId: currentDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
} }
} }
if (res?.[1]?.[1]) { if (res?.[1]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>( const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[1]?.[1] as string) ?? '' (res?.[1]?.[1] as string) ?? ''
); );
if (data) { if (data) {
const sessionId = data.payload.sessionId; return {
return { deviceId: previousDeviceId, sessionId }; deviceId: previousDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
} }
} }
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,7 @@
--- ---
date: 2025-07-18 date: 2025-07-18
title: "13 Best Mixpanel Alternatives & Competitors in 2026" title: "13 Best Product Analytics Tools in 2026 (Ranked & Compared)"
description: "Compare the best Mixpanel alternatives for product analytics in 2026. Side-by-side pricing, features, and privacy comparison of 7 top tools plus 6 honorable mentions — including open source and free options." description: "Compare the best product analytics tools in 2026. Side-by-side pricing, features, and privacy comparison of 13 platforms — including open source, self-hosted, and free options for every team size."
updated: 2026-02-16 updated: 2026-02-16
tag: Comparison tag: Comparison
team: OpenPanel Team team: OpenPanel Team

View File

@@ -3,7 +3,7 @@
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Amplitude Alternatives 2026 - Open Source, Free & Paid", "title": "Best Amplitude Alternatives 2026 - Open Source, Free & Paid",
"description": "Compare the best Amplitude alternatives in 2026: OpenPanel, PostHog, Mixpanel, Heap, and Plausible. Open source, privacy-first, and affordable options for every team size. See which fits you best.", "description": "Compare the best Amplitude alternatives in 2026: OpenPanel, PostHog, Heap, and Plausible. Open source, privacy-first, and affordable options for every team size. See which fits you best.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {

View File

@@ -2,8 +2,8 @@
"slug": "fullstory-alternative", "slug": "fullstory-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best FullStory Alternative 2026 - Open Source & Free", "title": "Best FullStory Alternatives 2026 — Cheaper & Privacy-First",
"description": "Looking for a FullStory alternative? OpenPanel offers product analytics with transparent pricing, self-hosting, and privacy-first tracking \u2014 no expensive session replay costs. Free to start.", "description": "FullStory pricing starts at $300/month. OpenPanel delivers product analytics — events, funnels, and retention — at $2.50/month or free to self-host. No enterprise contract required.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {

View File

@@ -2,8 +2,8 @@
"slug": "heap-alternative", "slug": "heap-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Heap Alternative 2026 - Open Source & Free", "title": "Best Heap Alternatives 2026 — After the Contentsquare Acquisition",
"description": "Looking for a Heap alternative? OpenPanel offers transparent pricing, lightweight analytics, and self-hosting without autocapture complexity. Open source and free to get started.", "description": "Heap was acquired by Contentsquare in 2023. If you're re-evaluating, OpenPanel is an open-source alternative with transparent pricing from $2.50/month, full self-hosting, and no sales call required.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
@@ -27,8 +27,8 @@
"overview": { "overview": {
"title": "Why consider OpenPanel over Heap?", "title": "Why consider OpenPanel over Heap?",
"paragraphs": [ "paragraphs": [
"Heap made its name with autocapture \u2014 the ability to automatically record every user interaction and analyze it retroactively. It's a compelling feature for teams that want to ask questions about user behavior without planning instrumentation in advance. But Heap's acquisition by Contentsquare, opaque enterprise pricing, and cloud-only architecture have many teams looking for alternatives.", "Heap was acquired by Contentsquare in September 2023. For many teams, that acquisition raised real questions: Will pricing change? Will the product roadmap shift to serve Contentsquare's enterprise customers? Will independent support continue? These concerns, combined with Heap's opaque pricing model and cloud-only architecture, have driven a wave of teams to evaluate alternatives.",
"OpenPanel takes a different approach with explicit event tracking, giving you precise control over what you measure and how. While you lose Heap's retroactive analysis capability, you gain transparency \u2014 both in your data collection and your costs. OpenPanel's pricing is publicly listed and event-based, starting at just $2.50 per month, compared to Heap's sales-required pricing that reportedly starts at $3,600 per year.", "OpenPanel takes a different approach with explicit event tracking, giving you precise control over what you measure and how. While you lose Heap's retroactive analysis capability, you gain transparency \u2014 both in your data collection and your costs. OpenPanel's pricing is publicly listed and event-based, starting at just $2.50 per month, compared to Heap's sales-required pricing that reportedly starts at $3,600 per year. And unlike Heap, OpenPanel is fully self-hostable and open source \u2014 no acquisition can change that.",
"For teams that value data sovereignty, OpenPanel offers full self-hosting via a simple Docker deployment \u2014 something Heap doesn't provide at all. Being open source under the MIT license means you can inspect every line of code, contribute improvements, and avoid the vendor lock-in risk that comes with Heap's proprietary, now-Contentsquare-owned platform.", "For teams that value data sovereignty, OpenPanel offers full self-hosting via a simple Docker deployment \u2014 something Heap doesn't provide at all. Being open source under the MIT license means you can inspect every line of code, contribute improvements, and avoid the vendor lock-in risk that comes with Heap's proprietary, now-Contentsquare-owned platform.",
"If you prefer intentional, controlled analytics over autocapture-everything, want transparent pricing without sales calls, and need the option to self-host \u2014 OpenPanel gives you solid product analytics with full ownership of your data." "If you prefer intentional, controlled analytics over autocapture-everything, want transparent pricing without sales calls, and need the option to self-host \u2014 OpenPanel gives you solid product analytics with full ownership of your data."
] ]
@@ -443,8 +443,8 @@
], ],
"articles": [ "articles": [
{ {
"title": "Find an alternative to Mixpanel", "title": "Best product analytics tools in 2026",
"url": "/articles/alternatives-to-mixpanel" "url": "/articles/mixpanel-alternatives"
}, },
{ {
"title": "9 best open source web analytics tools", "title": "9 best open source web analytics tools",

View File

@@ -2,13 +2,13 @@
"slug": "mixpanel-alternative", "slug": "mixpanel-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Mixpanel Alternative 2026 - Open Source & Free", "title": "OpenPanel vs Mixpanel (2026): Full Feature & Pricing Comparison",
"description": "Looking for a Mixpanel alternative? OpenPanel offers powerful product analytics at a fraction of the cost \u2014 with EU-only hosting, self-hosting, and full data ownership. Try free today.", "description": "Side-by-side comparison of OpenPanel and Mixpanel: pricing, features, self-hosting, privacy, and migration guide. See which product analytics platform is right for your team.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
"heading": "Best Mixpanel Alternative", "heading": "OpenPanel vs Mixpanel",
"subheading": "OpenPanel is an open-source, privacy-first alternative to Mixpanel. Get powerful product analytics\u2014events, funnels, retention, and user profiles\u2014without event-based pricing that scales to thousands per month or sending your data to US servers.", "subheading": "A complete side-by-side comparison of OpenPanel and Mixpanel \u2014 pricing, features, self-hosting, privacy, and what it takes to switch. Make an informed decision before you migrate.",
"badges": [ "badges": [
"Open-source", "Open-source",
"EU-only hosting", "EU-only hosting",

View File

@@ -2,8 +2,8 @@
"slug": "posthog-alternative", "slug": "posthog-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best PostHog Alternative 2026 - Open Source & Free", "title": "Best PostHog Alternatives in 2026 — Simpler, Free & Self-Hosted",
"description": "Looking for a PostHog alternative? OpenPanel offers simpler analytics with better privacy, a lighter SDK, and transparent pricing \u2014 no complex tiers. Open source and free to self-host.", "description": "Looking for a simpler PostHog alternative? OpenPanel is free, open-source, and self-hostable — 2.3 KB SDK, cookie-free tracking, and no complex feature flags or session replay you don't need.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {

View File

@@ -2,8 +2,8 @@
"slug": "smartlook-alternative", "slug": "smartlook-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Smartlook Alternative 2026 - Open Source & Free", "title": "5 Best Smartlook Alternatives in 2026 (Free & Open Source)",
"description": "Looking for a Smartlook alternative? OpenPanel offers product analytics with self-hosting, transparent pricing, and mobile SDKs \u2014 without session replay costs. Open source and free to start.", "description": "Replace Smartlook's session recording with OpenPanel — cookie-free product analytics with events, funnels, and retention. Open source, self-hostable, and no consent banners required.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {

View File

@@ -68,6 +68,34 @@ app.listen(3000, () => {
- `trackRequest` - A function that returns `true` if the request should be tracked. - `trackRequest` - A function that returns `true` if the request should be tracked.
- `getProfileId` - A function that returns the profile ID of the user making the request. - `getProfileId` - A function that returns the profile ID of the user making the request.
## Working with Groups
Groups let you track analytics at the account or company level. Since Express is a backend SDK, you can upsert groups and assign users from your route handlers.
See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
```ts
app.post('/login', async (req, res) => {
const user = await loginUser(req.body);
// Identify the user
req.op.identify({ profileId: user.id, email: user.email });
// Create/update the group entity
req.op.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan },
});
// Assign the user to the group
req.op.setGroup(user.organizationId);
res.json({ ok: true });
});
```
## Typescript ## Typescript
If `req.op` is not typed you can extend the `Request` interface. If `req.op` is not typed you can extend the `Request` interface.

View File

@@ -116,9 +116,38 @@ op.decrement({
}); });
``` ```
### Working with Groups
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
**Create or update a group:**
```js title="index.js"
import { op } from './op.ts'
op.upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'enterprise' },
});
```
**Assign the current user to a group** (call after `identify`):
```js title="index.js"
import { op } from './op.ts'
op.setGroup('org_acme');
// or multiple groups:
op.setGroups(['org_acme', 'team_eng']);
```
Once set, all subsequent `track()` calls will automatically include the group IDs.
### Clearing User Data ### Clearing User Data
To clear the current user's data: To clear the current user's data (including groups):
```js title="index.js" ```js title="index.js"
import { op } from './op.ts' import { op } from './op.ts'

View File

@@ -227,9 +227,32 @@ useOpenPanel().decrement({
}); });
``` ```
### Working with Groups
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
**Create or update a group:**
```tsx title="app/login/page.tsx"
useOpenPanel().upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'enterprise' },
});
```
**Assign the current user to a group** (call after `identify`):
```tsx title="app/login/page.tsx"
useOpenPanel().setGroup('org_acme');
```
Once set, all subsequent `track()` calls will automatically include the group IDs.
### Clearing User Data ### Clearing User Data
To clear the current user's data: To clear the current user's data (including groups):
```js title="index.js" ```js title="index.js"
useOpenPanel().clear() useOpenPanel().clear()

View File

@@ -174,9 +174,37 @@ function MyComponent() {
} }
``` ```
### Working with Groups
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
```tsx
import { op } from '@/openpanel';
function LoginComponent() {
const handleLogin = async (user: User) => {
// 1. Identify the user
op.identify({ profileId: user.id, email: user.email });
// 2. Create/update the group entity (only when data changes)
op.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan },
});
// 3. Link the user to their group — tags all future events
op.setGroup(user.organizationId);
};
return <button onClick={() => handleLogin(user)}>Login</button>;
}
```
### Clearing User Data ### Clearing User Data
To clear the current user's data: To clear the current user's data (including groups):
```tsx ```tsx
import { op } from '@/openpanel'; import { op } from '@/openpanel';

View File

@@ -59,7 +59,16 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
## Insights ## Insights
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured. A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed.
Each card shows:
- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
- **Absolute change**: The raw increase or decrease in sessions compared to the previous period
- **Percentage change**: How much that property grew or declined relative to its own previous value
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
--- ---

View File

@@ -0,0 +1,208 @@
---
title: Groups
description: Track analytics at the account, company, or team level — not just individual users.
---
import { Callout } from 'fumadocs-ui/components/callout';
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
This is especially useful for B2B SaaS products where a single paying account has many users.
## How Groups work
There are two separate concepts:
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
## Creating or updating a group
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
```typescript
op.upsertGroup({
id: 'org_acme', // Your group's unique ID
type: 'company', // Group type (company, workspace, team, etc.)
name: 'Acme Inc', // Display name
properties: {
plan: 'enterprise',
seats: 25,
industry: 'logistics',
},
});
```
### Group payload
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | `string` | Yes | Unique identifier for the group |
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
| `name` | `string` | Yes | Human-readable display name |
| `properties` | `object` | No | Custom metadata about the group |
## Managing groups in the dashboard
The easiest way to create, edit, and delete groups is directly in the OpenPanel dashboard. Navigate to your project and open the **Groups** section — from there you can manage group names, types, and properties without touching any code.
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
<Callout>
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
</Callout>
## Assigning a user to a group
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
```typescript
// After login
op.identify({ profileId: 'user_123' });
// Link the user to their organization
op.setGroup('org_acme');
```
For users that belong to multiple groups:
```typescript
op.setGroups(['org_acme', 'team_engineering']);
```
<Callout>
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
</Callout>
## Full login flow example
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
```typescript
// 1. Identify the user
op.identify({
profileId: 'user_123',
firstName: 'Jane',
email: 'jane@acme.com',
});
// 2. Assign the user to the group — the group doesn't need to exist yet
op.setGroup('org_acme');
// 3. All subsequent events are now tagged with the group
op.track('dashboard_viewed'); // → includes groups: ['org_acme']
op.track('report_exported'); // → includes groups: ['org_acme']
```
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
```typescript
op.identify({ profileId: 'user_123', email: 'jane@acme.com' });
// Sync group metadata from your backend
op.upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'pro' },
});
op.setGroup('org_acme');
```
## Per-event group override
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
```typescript
op.track('file_shared', {
filename: 'q4-report.pdf',
groups: ['org_acme', 'org_partner'], // Only applies to this event
});
```
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
## Clearing groups on logout
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
```typescript
function handleLogout() {
op.clear();
// redirect to login...
}
```
## Common patterns
### B2B SaaS — company accounts
```typescript
// On login
op.identify({ profileId: user.id, email: user.email });
op.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan, mrr: user.mrr },
});
op.setGroup(user.organizationId);
```
### Multi-tenant — workspaces
```typescript
// When user switches workspace
op.upsertGroup({
id: workspace.id,
type: 'workspace',
name: workspace.name,
});
op.setGroup(workspace.id);
```
### Teams within a company
```typescript
// User belongs to a company and a specific team
op.setGroups([user.organizationId, user.teamId]);
```
## API reference
### `upsertGroup(payload)`
Creates the group if it doesn't exist, or merges properties into the existing group.
```typescript
op.upsertGroup({
id: string; // Required
type: string; // Required
name: string; // Required
properties?: Record<string, unknown>;
});
```
### `setGroup(groupId)`
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
```typescript
op.setGroup('org_acme');
```
### `setGroups(groupIds)`
Same as `setGroup()` but for multiple group IDs at once.
```typescript
op.setGroups(['org_acme', 'team_engineering']);
```
## What to avoid
- **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
- **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
- **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
- **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.

View File

@@ -3,6 +3,7 @@
"install-openpanel", "install-openpanel",
"track-events", "track-events",
"identify-users", "identify-users",
"groups",
"revenue-tracking" "revenue-tracking"
] ]
} }

View File

@@ -0,0 +1,155 @@
{
"slug": "nextjs",
"audience": "nextjs",
"seo": {
"title": "Next.js Analytics — OpenPanel SDK for App Router & Pages Router",
"description": "Add analytics to your Next.js app in minutes. OpenPanel's Next.js SDK supports App Router, Pages Router, server-side events, and automatic route change tracking. Open source, cookieless, from $2.50/month.",
"noindex": false
},
"hero": {
"heading": "Analytics Built for Next.js",
"subheading": "Most analytics tools treat Next.js like a static site. OpenPanel was built for how Next.js actually works — App Router, server components, API routes, and route changes all tracked correctly. Install in 5 minutes, get events, funnels, and user profiles that work with SSR.",
"badges": [
"App Router support",
"Server-side events",
"Auto route tracking",
"2.3 KB client bundle"
]
},
"problem": {
"title": "Why standard analytics breaks in Next.js",
"intro": "Next.js is not a traditional website. Most analytics tools were built before App Router existed and show it.",
"items": [
{
"title": "GA4 breaks with App Router route changes",
"description": "Google Analytics was designed for traditional page loads. In Next.js App Router, client-side navigation doesn't trigger GA4's page view events unless you add custom workarounds. Your analytics misses a significant portion of page views."
},
{
"title": "Cookie consent is painful in Next.js",
"description": "Implementing a GDPR-compliant cookie consent flow in a Next.js app requires next/script, consent state management, and conditional script loading. It's a non-trivial engineering task just to run analytics."
},
{
"title": "No server-side tracking",
"description": "Most analytics SDKs are client-only. Server-side events — API route completions, background jobs, server actions — are invisible. You're missing half the picture."
},
{
"title": "Analytics libraries bloat your bundle",
"description": "Many analytics SDKs add 2050 KB to your JavaScript bundle. In a performance-conscious Next.js app, that overhead matters for Core Web Vitals."
}
]
},
"features": {
"title": "Analytics that works the way Next.js works",
"intro": "OpenPanel's Next.js SDK was designed for modern Next.js patterns — not adapted from a legacy client-only library.",
"items": [
{
"title": "Next.js SDK with App Router support",
"description": "Install @openpanel/nextjs, add the <OpenPanelComponent> to your root layout, and automatic route change tracking works immediately across App Router and Pages Router."
},
{
"title": "Automatic route change tracking",
"description": "Every router.push(), <Link> navigation, and back/forward browser action is captured as a page view. No custom useEffect or router event listeners needed."
},
{
"title": "Server-side event tracking",
"description": "Import openPanel in any server component, API route, or server action to track events server-side. Track form submissions, payment completions, and background jobs without any client involvement."
},
{
"title": "Cookieless by default",
"description": "The OpenPanel SDK uses no cookies. No consent management library, no conditional script loading, no GDPR consent modal needed — just install and track."
},
{
"title": "Identify users across sessions",
"description": "Call op.identify({ profileId, name, email }) after authentication to tie anonymous events to known users. Works with any auth solution including NextAuth.js, Clerk, and custom implementations."
},
{
"title": "TypeScript-first SDK",
"description": "Full TypeScript types for all methods. Autocomplete for event names and properties. Zero runtime errors from mistyped event calls."
},
{
"title": "2.3 KB gzipped client bundle",
"description": "The smallest full-featured analytics SDK for Next.js. No impact on Lighthouse scores or Core Web Vitals."
}
]
},
"benefits": {
"title": "Why Next.js developers choose OpenPanel",
"intro": "OpenPanel fits naturally into a modern Next.js codebase — not as an afterthought.",
"items": [
{
"title": "Works exactly how Next.js works",
"description": "Built for App Router, server components, and modern Next.js patterns. Not bolted onto a client-only library."
},
{
"title": "No consent infrastructure needed",
"description": "Cookieless tracking means no consent modal, no next/script loading gymnastics, no conditional initialization. Install once, works everywhere."
},
{
"title": "Track server and client events in the same dashboard",
"description": "Server actions, API endpoints, and client interactions all show up together. Full picture of what your app is doing."
},
{
"title": "Open source and self-hostable",
"description": "Run OpenPanel on your own infrastructure. Your Next.js app's analytics data never leaves your servers."
},
{
"title": "From $2.50/month",
"description": "No enterprise contract, no per-seat fees. Pay for events, get all features. Start free with 30-day trial."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from Next.js developers evaluating OpenPanel.",
"items": [
{
"question": "How do I install OpenPanel in a Next.js app?",
"answer": "Install @openpanel/nextjs, add <OpenPanelComponent clientId=\"...\" /> to your root layout.tsx, and you're tracking page views. For custom events, import and call op.track('event_name', { properties }) anywhere. See the full step-by-step guide at /guides/nextjs-analytics."
},
{
"question": "Does OpenPanel support Next.js App Router?",
"answer": "Yes. The <OpenPanelComponent> handles App Router route changes automatically using the usePathname hook internally. No custom setup is needed for client-side navigation tracking."
},
{
"question": "Can I track events in Server Components and API Routes?",
"answer": "Yes. Import { openPanel } from @openpanel/nextjs/server and call openPanel.track() in any server context including Server Components, Route Handlers, and Server Actions."
},
{
"question": "Does OpenPanel work with NextAuth.js or Clerk?",
"answer": "Yes. Call op.identify({ profileId: session.user.id, ... }) after authentication to link events to user identities. Works with any auth solution that gives you a user ID."
},
{
"question": "Does OpenPanel add significant bundle size to my Next.js app?",
"answer": "No. The client-side SDK is 2.3 KB gzipped. For reference, GA4 adds 50+ KB. OpenPanel has negligible impact on your bundle size or Core Web Vitals."
},
{
"question": "Can I self-host OpenPanel for my Next.js app?",
"answer": "Yes. OpenPanel is fully open source and can be self-hosted with Docker. Your Next.js app sends events to your own server. The self-hosted version has no event limits and is free to run."
}
]
},
"related_links": {
"guides": [
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
{ "title": "React analytics setup", "url": "/guides/react-analytics" },
{ "title": "Track custom events", "url": "/guides/track-custom-events" }
],
"articles": [
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,165 @@
{
"slug": "saas",
"audience": "saas",
"seo": {
"title": "SaaS Analytics — Track Events, Funnels & Retention Without Enterprise Pricing",
"description": "OpenPanel gives SaaS teams the product analytics they need to reduce churn and grow — events, funnels, retention, cohorts, and user profiles. Open source, from $2.50/month.",
"noindex": false
},
"hero": {
"heading": "Product Analytics for SaaS Teams",
"subheading": "Understanding why users churn, where trials drop off, and which features drive retention is the difference between a SaaS that grows and one that plateaus. OpenPanel gives you events, funnels, retention analysis, and user profiles — the core analytics stack for SaaS — without Mixpanel or Amplitude's enterprise pricing.",
"badges": [
"Funnel analysis",
"Retention tracking",
"User profiles",
"From $2.50/month"
]
},
"problem": {
"title": "Why SaaS teams outgrow their analytics tools",
"intro": "The analytics tools most SaaS teams start with either can't answer the right questions or become unaffordable as you grow.",
"items": [
{
"title": "Mixpanel and Amplitude become unaffordable at scale",
"description": "Event-based pricing sounds cheap at 10K events, but SaaS products are event-heavy. At 1M events/month you're paying $300800/month. At 10M, it's thousands. Your analytics bill grows faster than your revenue."
},
{
"title": "GA4 doesn't answer SaaS questions",
"description": "Google Analytics tells you pageviews. It can't tell you which users completed your onboarding flow, how feature adoption correlates with retention, or why your trial-to-paid conversion dropped last month."
},
{
"title": "Complex tools slow down your team",
"description": "Mixpanel's interface is powerful but has a steep learning curve. When it takes 30 minutes to build a simple funnel, your team stops using analytics to make decisions."
},
{
"title": "Cloud-only means full vendor dependence",
"description": "If Mixpanel raises prices, your data is hostage. No export path, no self-hosting option, and a pricing model that punishes growth."
}
]
},
"features": {
"title": "The full product analytics stack for SaaS",
"intro": "Everything your team needs to understand users, reduce churn, and grow — without the enterprise pricing.",
"items": [
{
"title": "Event tracking",
"description": "Track any user action — feature used, button clicked, settings changed, plan upgraded. One line of code. Works across web, mobile, and your backend."
},
{
"title": "Funnel analysis",
"description": "Build conversion funnels for trial signup, onboarding, activation, and upgrade flows. See the exact step losing you the most conversions and fix it."
},
{
"title": "Retention analysis",
"description": "Cohort-based retention charts show you whether users activated in week 1 are still active in week 8. Identify which actions predict long-term retention."
},
{
"title": "User profiles",
"description": "See every event a specific user triggered since they signed up. Walk through their session to diagnose support issues or understand power user behavior."
},
{
"title": "Cohort analysis",
"description": "Group users by signup date, plan, or any property and compare their behavior over time. Understand how product changes affect different user segments."
},
{
"title": "Real-time dashboard",
"description": "See new signups, trial activations, and feature usage as they happen. Know immediately when a new deployment changes user behavior."
},
{
"title": "Revenue tracking",
"description": "Send MRR, payment events, and plan upgrade data to OpenPanel. Correlate feature usage with revenue without a separate BI tool."
},
{
"title": "Multi-platform SDKs",
"description": "Track across web app, marketing site, iOS, Android, and backend in one unified analytics view. Events from all platforms share the same user profiles."
}
]
},
"benefits": {
"title": "Why SaaS teams choose OpenPanel",
"intro": "OpenPanel gives you the analytics depth of Mixpanel at a price that makes sense for growing SaaS products.",
"items": [
{
"title": "Predictable pricing as you scale",
"description": "Flat event tiers from $2.50 to $900/month. No per-seat fees, no MTU limits. Know exactly what you'll pay as your user base grows."
},
{
"title": "Answer the questions SaaS teams actually ask",
"description": "Which onboarding step has the highest drop-off? Which features are used by retained users vs churned users? OpenPanel is built for these questions."
},
{
"title": "Self-host to eliminate per-event costs",
"description": "Large SaaS products generate millions of events. Self-hosting eliminates the cost entirely — pay only for your server infrastructure."
},
{
"title": "All features from day one",
"description": "Funnels, retention, cohorts, user profiles, and dashboards are available at every pricing tier. No feature gating that forces upgrades."
},
{
"title": "Open source and auditable",
"description": "Your product data is sensitive. OpenPanel's open source codebase means you can audit exactly what's tracked and how it's stored."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from SaaS founders and product teams evaluating OpenPanel.",
"items": [
{
"question": "How is OpenPanel different from Mixpanel for SaaS?",
"answer": "OpenPanel covers the same core analytics (events, funnels, retention, cohorts, user profiles) at a fraction of the cost. Mixpanel adds features like Metric Trees and advanced experimentation, but most SaaS teams don't use them. OpenPanel focuses on the analytics that actually drive decisions."
},
{
"question": "Can OpenPanel replace both GA4 and Mixpanel?",
"answer": "Yes. OpenPanel includes web analytics (pageviews, referrers, UTM campaigns) alongside product analytics. Most SaaS teams can replace both tools with one OpenPanel deployment."
},
{
"question": "How do I track trial-to-paid conversions?",
"answer": "Track a trial_started event on signup and a plan_upgraded event on conversion. Build a funnel in OpenPanel between those two events. See conversion rate by cohort, traffic source, or any user property."
},
{
"question": "Does OpenPanel support multi-product or multi-tenant SaaS?",
"answer": "Yes. Use the profileId to identify individual users and pass organization or workspace IDs as properties. You can filter analytics by any property you send."
},
{
"question": "What happens to my data if I stop paying for OpenPanel cloud?",
"answer": "You can export all your event data via the API at any time. Alternatively, migrate to self-hosting — OpenPanel has no lock-in and no proprietary data format."
},
{
"question": "Can I track backend events from my SaaS API?",
"answer": "Yes. OpenPanel has server-side SDKs for Node.js, Python, and a REST API. Track subscription webhooks, background jobs, and server-side logic alongside client events."
},
{
"question": "Is OpenPanel GDPR compliant for SaaS?",
"answer": "Yes. Cookieless tracking by default, EU-only cloud hosting, and the option to self-host for complete data sovereignty. No consent banner required for product analytics."
}
]
},
"related_links": {
"guides": [
{ "title": "Track custom events", "url": "/guides/track-custom-events" },
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
{ "title": "React analytics setup", "url": "/guides/react-analytics" }
],
"articles": [
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,158 @@
{
"slug": "shopify",
"audience": "shopify",
"seo": {
"title": "Shopify Analytics — Cookie-Free Tracking Without Consent Banners",
"description": "Add product analytics to your Shopify store without GDPR consent banners. OpenPanel is cookieless by default — track events, funnels, and revenue from $2.50/month or free to self-host.",
"noindex": false
},
"hero": {
"heading": "Shopify Analytics That Actually Works",
"subheading": "Most analytics tools break on Shopify — cookie consent blocks tracking, GA4 loses attribution, and Shopify's built-in reports can't answer the questions that matter. OpenPanel tracks every event without cookies, so your data is complete and your checkout conversion doesn't suffer from a consent banner.",
"badges": [
"Cookie-free tracking",
"No consent banner needed",
"Revenue tracking",
"From $2.50/month"
]
},
"problem": {
"title": "Why analytics breaks on Shopify",
"intro": "Every major analytics tool creates a different problem for Shopify stores. Here's what you're actually dealing with.",
"items": [
{
"title": "GA4 consent mode destroys your data",
"description": "Cookie consent requirements mean 30-50% of visitors opt out. GA4's \"consent mode\" fills gaps with modeled data — you're making decisions on estimates, not reality."
},
{
"title": "Shopify's built-in analytics is shallow",
"description": "Pageviews and sales are there, but you can't build funnels, analyze drop-off by product page, or see which acquisition channel retains customers best."
},
{
"title": "Event tracking requires a developer",
"description": "Installing GA4 on Shopify correctly, with purchase events, add-to-cart, and checkout steps, requires custom code or expensive third-party apps."
},
{
"title": "Cookie banners hurt conversion",
"description": "A consent popup before checkout is a conversion killer. Every analytics tool that uses cookies forces you to choose between data and revenue."
}
]
},
"features": {
"title": "Everything you need to understand your Shopify store",
"intro": "OpenPanel is designed to give you complete visibility into your store's performance — without the privacy headaches.",
"items": [
{
"title": "Script tag install — no developer needed",
"description": "Add one script tag in Shopify's theme settings. Automatic page view tracking starts immediately. No Shopify app or coding required."
},
{
"title": "Cookieless tracking by default",
"description": "No cookies means no consent banner. Your analytics data is 100% complete — not modeled, not estimated, not filtered by opt-outs."
},
{
"title": "Purchase and revenue tracking",
"description": "Track add-to-cart, checkout started, and purchase events with revenue values. See exactly which campaigns and pages drive revenue, not just clicks."
},
{
"title": "Funnel analysis",
"description": "Build funnels from product page → add to cart → checkout → purchase. See exactly where you're losing customers and fix it."
},
{
"title": "UTM campaign attribution",
"description": "Track every paid ad, email campaign, and influencer link. See which traffic source actually converts to buyers, not just visitors."
},
{
"title": "User profiles",
"description": "See the complete journey of any customer — pages visited, products viewed, purchase history. Identify high-value customer behavior patterns."
},
{
"title": "Real-time dashboard",
"description": "Watch your store live. Launch a sale or send an email and see the traffic surge in real time."
},
{
"title": "EU data residency",
"description": "All data stored in the EU. No transfers to US servers. GDPR compliance without the legal complexity."
}
]
},
"benefits": {
"title": "Why Shopify store owners choose OpenPanel",
"intro": "OpenPanel was built to solve the exact problems that plague analytics on Shopify.",
"items": [
{
"title": "Complete data, no consent required",
"description": "Cookieless tracking means every visitor is counted. No consent mode models, no sampling, no gaps from opt-outs."
},
{
"title": "Cheaper than Shopify analytics apps",
"description": "Most Shopify analytics apps charge $50200/month. OpenPanel starts at $2.50/month with all features included."
},
{
"title": "Replace GA4 and your analytics app",
"description": "One tool covers web analytics (traffic, referrers, UTM) and product analytics (funnels, retention, revenue). Cancel two subscriptions."
},
{
"title": "Privacy-first out of the box",
"description": "EU hosting, no cookies, no fingerprinting. No compliance team required to use OpenPanel on your store."
},
{
"title": "Self-host for free",
"description": "For high-volume stores, self-hosting eliminates per-event costs entirely. One Docker deployment, unlimited events."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from Shopify store owners evaluating OpenPanel.",
"items": [
{
"question": "How do I install OpenPanel on Shopify?",
"answer": "Add the OpenPanel script tag via Shopify Admin → Online Store → Themes → Edit Code → theme.liquid. Paste the snippet before </head>. Automatic page views track immediately. For purchase events, add a few lines to your order confirmation page."
},
{
"question": "Does OpenPanel work without a cookie consent banner?",
"answer": "Yes. OpenPanel is cookieless by default and doesn't collect personal data. No consent banner is required under GDPR or CCPA for analytics-only use."
},
{
"question": "Can I track Shopify purchase events?",
"answer": "Yes. Use a simple script on your order confirmation page to send purchase amount, order ID, and product data to OpenPanel. No Shopify app required."
},
{
"question": "How does OpenPanel compare to Lucky Orange or Hotjar for Shopify?",
"answer": "Lucky Orange and Hotjar focus on heatmaps and session replay. OpenPanel focuses on event analytics, funnels, and retention — understanding what users do, not watching recordings. If you need both, they complement each other."
},
{
"question": "Will OpenPanel affect my Shopify store's page speed?",
"answer": "OpenPanel's JavaScript SDK is 2.3 KB gzipped — one of the lightest analytics trackers available. GA4 is 50+ KB. The performance impact is negligible."
},
{
"question": "Does OpenPanel work with Shopify Plus?",
"answer": "Yes. OpenPanel works on any Shopify plan including Plus. The script tag installation method works identically across all plans."
}
]
},
"related_links": {
"guides": [
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" }
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,157 @@
{
"slug": "wordpress",
"audience": "wordpress",
"seo": {
"title": "WordPress Analytics Without Cookies — A Google Analytics Alternative",
"description": "Add privacy-first analytics to your WordPress site without cookie consent banners. OpenPanel is cookieless, open source, and starts at $2.50/month — a better alternative to GA4 for WordPress.",
"noindex": false
},
"hero": {
"heading": "WordPress Analytics Without the Cookie Banner",
"subheading": "Google Analytics on WordPress means cookie consent popups, GDPR complexity, and sending your visitors' data to US servers. OpenPanel is a cookieless WordPress analytics plugin that gives you pageviews, events, and user behavior — with full GDPR compliance out of the box and none of the privacy headaches.",
"badges": [
"No cookie banner needed",
"GDPR compliant",
"Open source",
"Lightweight — 2.3 KB"
]
},
"problem": {
"title": "Why Google Analytics on WordPress creates problems",
"intro": "WordPress powers 40% of the web, but GA4 was not designed with WordPress privacy requirements in mind.",
"items": [
{
"title": "Google Analytics requires cookie consent on WordPress",
"description": "Installing GA4 on a WordPress site means a consent popup for every visitor. Under GDPR, you can't set GA4 cookies without explicit opt-in. That banner costs you conversions and corrupts your data."
},
{
"title": "GA4 sends data to US servers",
"description": "Google Analytics transfers visitor data to US servers. For European sites, this creates legal exposure under SCHREMS II — a risk WordPress site owners increasingly can't ignore."
},
{
"title": "WordPress analytics plugins are bloated",
"description": "Most GA4 WordPress plugins add significant weight to your site. Page speed suffers, Core Web Vitals scores drop, and you're still dealing with cookies."
},
{
"title": "You can't see user behavior, only pageviews",
"description": "GA4 tells you how many people visited. OpenPanel tells you what they did: which CTAs they clicked, where they dropped off, which blog posts convert to signups."
}
]
},
"features": {
"title": "Privacy-first analytics for WordPress",
"intro": "OpenPanel gives WordPress site owners complete visibility without the cookie compliance headaches.",
"items": [
{
"title": "Official WordPress plugin",
"description": "Install the OpenPanel WordPress plugin directly from the WordPress plugin directory. Activate it, paste your Client ID, and page view tracking starts immediately — no code required."
},
{
"title": "Automatic page view tracking",
"description": "Every WordPress page, post, and custom post type is tracked automatically. No configuration needed for standard pageview analytics."
},
{
"title": "Cookie-free by default",
"description": "No cookies means no consent banner. Compliant with GDPR, CCPA, and ePrivacy without any configuration."
},
{
"title": "Custom event tracking",
"description": "Track clicks, form submissions, and conversions with a one-line JavaScript call. Know which CTAs and forms drive the most leads."
},
{
"title": "UTM campaign tracking",
"description": "See exactly which emails, ads, and social posts drive traffic to your WordPress site — and which ones actually convert."
},
{
"title": "Lightweight script — 2.3 KB",
"description": "OpenPanel's tracker is 20x smaller than GA4. No impact on Core Web Vitals or PageSpeed scores."
},
{
"title": "Self-hostable on your own server",
"description": "Run OpenPanel on your own infrastructure. Your WordPress visitor data never leaves your servers."
}
]
},
"benefits": {
"title": "Why WordPress site owners switch to OpenPanel",
"intro": "OpenPanel replaces Google Analytics without sacrificing features — and fixes the privacy problems GA4 created.",
"items": [
{
"title": "No consent banner required",
"description": "Cookieless tracking means you're GDPR-compliant without a popup. No lost conversions, no fragmented data from opt-outs."
},
{
"title": "Replace Google Analytics without losing features",
"description": "Pageviews, referrers, countries, devices, UTM campaigns — all there. Plus events and funnels that GA4 requires complex configuration to support."
},
{
"title": "Your data stays out of Google's hands",
"description": "OpenPanel is EU-hosted with no data sharing. Ideal for European sites, publishers, and anyone avoiding Google's data monopoly."
},
{
"title": "Open source and auditable",
"description": "Unlike GA4, OpenPanel's code is public. You can see exactly what data is collected and how it's processed."
},
{
"title": "Affordable pricing",
"description": "From $2.50/month for small WordPress sites. Self-host for free if you prefer — full feature parity, no event limits."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from WordPress site owners evaluating OpenPanel.",
"items": [
{
"question": "How do I install OpenPanel on WordPress?",
"answer": "Add the OpenPanel script tag to your WordPress site via your theme's header.php, a child theme, or any \"header scripts\" plugin. Paste the snippet before </head>. Automatic page view tracking starts immediately — no additional configuration needed."
},
{
"question": "Do I need a cookie consent banner with OpenPanel?",
"answer": "No. OpenPanel uses cookieless tracking and doesn't collect personal data by default. Under GDPR, cookie consent is only required when you actually use cookies. OpenPanel doesn't, so no banner is needed."
},
{
"question": "Is there an OpenPanel WordPress plugin?",
"answer": "Yes. The official OpenPanel plugin is available in the WordPress plugin directory. Search for \"OpenPanel\" in Plugins → Add New, install it, and paste your Client ID. You can also add the script tag manually via your theme's header.php or any \"add code to header\" plugin if you prefer."
},
{
"question": "How does OpenPanel compare to MonsterInsights or Analytify?",
"answer": "Those plugins are wrappers around Google Analytics — they still send data to Google and still require cookies. OpenPanel is an independent analytics platform that's cookieless by default and stores data in the EU."
},
{
"question": "Can I track WooCommerce events?",
"answer": "Yes. You can track add-to-cart, checkout, and purchase events by adding a few lines of JavaScript to your WooCommerce templates or using WordPress hooks. No dedicated WooCommerce plugin required."
},
{
"question": "Does OpenPanel affect WordPress site speed?",
"answer": "OpenPanel's script is 2.3 KB gzipped. By comparison, GA4 adds 50+ KB. Switching from GA4 to OpenPanel will likely improve your Core Web Vitals scores."
}
]
},
"related_links": {
"guides": [
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" },
{ "title": "Website analytics setup", "url": "/guides/website-analytics-setup" },
{ "title": "OpenPanel WordPress plugin", "url": "https://sv.wordpress.org/plugins/openpanel/" }
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "How to self-host OpenPanel", "url": "/articles/self-hosted-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -34,9 +34,9 @@ const faqData = [
'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare). You can also read our comprehensive guide on the [best open source web analytics tools](/articles/open-source-web-analytics).', 'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare). You can also read our comprehensive guide on the [best open source web analytics tools](/articles/open-source-web-analytics).',
}, },
{ {
question: 'How does OpenPanel compare to Mixpanel?', question: 'Is OpenPanel a good Mixpanel alternative?',
answer: answer:
"OpenPanel offers similar powerful product analytics features as Mixpanel, but with the added benefits of being open-source, more affordable, and including web analytics capabilities.\n\nYou get Mixpanel's power with Plausible's simplicity.", "Yes — OpenPanel covers the core features most teams use in Mixpanel: event tracking, funnels, retention, cohorts, and user profiles. The key differences are pricing, privacy, and self-hosting.\n\nOpenPanel starts at $2.50/month and can be self-hosted for free, while Mixpanel is cloud-only and scales to hundreds or thousands per month. OpenPanel is also cookie-free by default with EU-only hosting, so no consent banners required — something Mixpanel can't offer.\n\nSee the full [OpenPanel vs Mixpanel comparison](/compare/mixpanel-alternative) for a side-by-side breakdown.",
}, },
{ {
question: 'How does OpenPanel compare to Plausible?', question: 'How does OpenPanel compare to Plausible?',

View File

@@ -0,0 +1,63 @@
import { BarChart2Icon, CoinsIcon, GithubIcon, ServerIcon } from 'lucide-react';
import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
const reasons = [
{
icon: CoinsIcon,
title: 'Fraction of the cost',
description:
"Mixpanel's pricing scales to hundreds or thousands per month as your event volume grows. OpenPanel starts at $2.50/month — or self-host for free with no event limits.",
},
{
icon: BarChart2Icon,
title: 'The features you actually use',
description:
'Events, funnels, retention, cohorts, user profiles, custom dashboards, and A/B testing — all there. OpenPanel covers every core analytics workflow from Mixpanel without the learning curve.',
},
{
icon: ServerIcon,
title: 'Actually self-hostable',
description:
'Mixpanel is cloud-only. OpenPanel runs on your own infrastructure with a simple Docker setup. Full data ownership, zero vendor lock-in.',
},
{
icon: GithubIcon,
title: 'Open source & transparent',
description:
"Mixpanel is a black box. OpenPanel's code is public on GitHub — audit it, contribute to it, or fork it. No surprises, no hidden data processing.",
},
];
export function MixpanelAlternative() {
return (
<Section className="container">
<SectionHeader
description="OpenPanel covers the product analytics features teams actually use — events, funnels, retention, cohorts, and user profiles — without Mixpanel's pricing, privacy trade-offs, or vendor lock-in."
label="Mixpanel Alternative"
title="Why teams switch from Mixpanel to OpenPanel"
/>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2">
{reasons.map((reason) => (
<FeatureCard
description={reason.description}
icon={reason.icon}
key={reason.title}
title={reason.title}
/>
))}
</div>
<div className="row mt-8 gap-4">
<GetStartedButton />
<Button asChild className="px-6" size="lg" variant="outline">
<Link href="/compare/mixpanel-alternative">
OpenPanel vs Mixpanel
</Link>
</Button>
</div>
</Section>
);
}

View File

@@ -4,6 +4,7 @@ import { FeatureSpotlight } from './_sections/feature-spotlight';
import { CtaBanner } from './_sections/cta-banner'; import { CtaBanner } from './_sections/cta-banner';
import { DataPrivacy } from './_sections/data-privacy'; import { DataPrivacy } from './_sections/data-privacy';
import { Faq } from './_sections/faq'; import { Faq } from './_sections/faq';
import { MixpanelAlternative } from './_sections/mixpanel-alternative';
import { Hero } from './_sections/hero'; import { Hero } from './_sections/hero';
import { Pricing } from './_sections/pricing'; import { Pricing } from './_sections/pricing';
import { Sdks } from './_sections/sdks'; import { Sdks } from './_sections/sdks';
@@ -63,6 +64,7 @@ export default function HomePage() {
<Testimonials /> <Testimonials />
<Pricing /> <Pricing />
<DataPrivacy /> <DataPrivacy />
<MixpanelAlternative />
<Sdks /> <Sdks />
<Faq /> <Faq />
<CtaBanner /> <CtaBanner />

View File

@@ -3,9 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = getPageMetadata({ export const metadata: Metadata = getPageMetadata({
url: '/tools/ip-lookup', url: '/tools/ip-lookup',
title: 'IP Lookup - Free IP Address Geolocation Tool', title: 'Free IP Address Lookup — Geolocation, ISP & ASN',
description: description:
'Find your IP address and get detailed geolocation information including country, city, ISP, ASN, and coordinates. Free IP lookup tool with map preview.', 'Instantly look up any IP address. Get country, city, region, ISP, ASN, and coordinates in seconds. Free tool, no signup required, powered by MaxMind GeoLite2.',
}); });
export default function IPLookupLayout({ export default function IPLookupLayout({

View File

@@ -1,25 +1,20 @@
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { Link } from '@tanstack/react-router';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
import { Tooltiper } from '@/components/ui/tooltip'; import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { Link } from '@tanstack/react-router';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent; type EventListItemProps = IServiceEventMinimal | IServiceEvent;
export function EventListItem(props: EventListItemProps) { export function EventListItem(props: EventListItemProps) {
const { organizationId, projectId } = useAppParams(); const { organizationId, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props; const { createdAt, name, path, meta } = props;
const profile = 'profile' in props ? props.profile : null; const profile = 'profile' in props ? props.profile : null;
const number = useNumber();
const renderName = () => { const renderName = () => {
if (name === 'screen_view') { if (name === 'screen_view') {
if (path.includes('/')) { if (path.includes('/')) {
@@ -32,83 +27,65 @@ export function EventListItem(props: EventListItemProps) {
return name.replace(/_/g, ' '); return name.replace(/_/g, ' ');
}; };
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
const isMinimal = 'minimal' in props; const isMinimal = 'minimal' in props;
return ( return (
<> <button
<button className={cn(
type="button" 'card flex w-full items-center justify-between rounded-lg p-4 transition-colors hover:bg-light-background',
onClick={() => { meta?.conversion &&
if (!isMinimal) { `bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`
pushModal('EventDetails', { )}
id: props.id, onClick={() => {
projectId, if (!isMinimal) {
createdAt, pushModal('EventDetails', {
}); id: props.id,
} projectId,
}} createdAt,
className={cn( });
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors', }
meta?.conversion && }}
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`, type="button"
)} >
> <div>
<div> <div className="flex items-center gap-4 text-left">
<div className="flex items-center gap-4 text-left "> <EventIcon meta={meta} name={name} size="sm" />
<EventIcon size="sm" name={name} meta={meta} /> <span className="font-medium">{renderName()}</span>
<span> </div>
<span className="font-medium">{renderName()}</span> <div className="pl-10">
{' '} <div className="flex origin-left scale-75 gap-1">
{renderDuration()} <SerieIcon name={props.country} />
</span> <SerieIcon name={props.os} />
</div> <SerieIcon name={props.browser} />
<div className="pl-10">
<div className="flex origin-left scale-75 gap-1">
<SerieIcon name={props.country} />
<SerieIcon name={props.os} />
<SerieIcon name={props.browser} />
</div>
</div> </div>
</div> </div>
<div className="flex gap-4"> </div>
{profile && ( <div className="flex gap-4">
<Tooltiper asChild content={getProfileName(profile)}> {profile && (
<Link <Tooltiper asChild content={getProfileName(profile)}>
onClick={(e) => { <Link
e.stopPropagation(); className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
}} onClick={(e) => {
to={'/$organizationId/$projectId/profiles/$profileId'} e.stopPropagation();
params={{ }}
organizationId, params={{
projectId, organizationId,
profileId: profile.id, projectId,
}} profileId: profile.id,
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline" }}
> to={'/$organizationId/$projectId/profiles/$profileId'}
{getProfileName(profile)} >
</Link> {getProfileName(profile)}
</Tooltiper> </Link>
)}
<Tooltiper asChild content={createdAt.toLocaleString()}>
<div className=" text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper> </Tooltiper>
</div> )}
</button>
</> <Tooltiper asChild content={createdAt.toLocaleString()}>
<div className="text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
</div>
</button>
); );
} }

View File

@@ -1,3 +1,4 @@
import { AnimatedNumber } from '../animated-number';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -8,71 +9,53 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws'; import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { useParams } from '@tanstack/react-router';
import { AnimatedNumber } from '../animated-number';
export default function EventListener({ export default function EventListener({
onRefresh, onRefresh,
}: { }: {
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const params = useParams({
strict: false,
});
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const counter = useDebounceState(0, 1000); const counter = useDebounceState(0, 1000);
useWS<IServiceEventMinimal | IServiceEvent>( useWS<{ count: number }>(
`/live/events/${projectId}`, `/live/events/${projectId}`,
(event) => { ({ count }) => {
if (event) { counter.set((prev) => prev + count);
const isProfilePage = !!params?.profileId;
if (isProfilePage) {
const profile = 'profile' in event ? event.profile : null;
if (profile?.id === params?.profileId) {
counter.set((prev) => prev + 1);
}
return;
}
counter.set((prev) => prev + 1);
}
}, },
{ {
debounce: { debounce: {
delay: 1000, delay: 1000,
maxWait: 5000, maxWait: 5000,
}, },
}, }
); );
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
onClick={() => { onClick={() => {
counter.set(0); counter.set(0);
onRefresh(); onRefresh();
}} }}
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none" type="button"
> >
<div className="relative"> <div className="relative">
<div <div
className={cn( className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all', 'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all'
)} )}
/> />
<div <div
className={cn( className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all', 'absolute top-0 left-0 h-3 w-3 rounded-full bg-emerald-500 transition-all'
)} )}
/> />
</div> </div>
{counter.debounced === 0 ? ( {counter.debounced === 0 ? (
'Listening' 'Listening'
) : ( ) : (
<AnimatedNumber value={counter.debounced} suffix=" new events" /> <AnimatedNumber suffix=" new events" value={counter.debounced} />
)} )}
</button> </button>
</TooltipTrigger> </TooltipTrigger>

View File

@@ -1,15 +1,14 @@
import type { IServiceEvent } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { EventIcon } from '@/components/events/event-icon'; import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links'; import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() { export function useColumns() {
const number = useNumber(); const number = useNumber();
@@ -28,17 +27,24 @@ export function useColumns() {
accessorKey: 'name', accessorKey: 'name',
header: 'Name', header: 'Name',
cell({ row }) { cell({ row }) {
const { name, path, duration, properties, revenue } = row.original; const { name, path, revenue } = row.original;
const fullTitle =
name === 'screen_view'
? path
: name === 'revenue' && revenue
? `${name} (${number.currency(revenue / 100)})`
: name.replace(/_/g, ' ');
const renderName = () => { const renderName = () => {
if (name === 'screen_view') { if (name === 'screen_view') {
if (path.includes('/')) { if (path.includes('/')) {
return <span className="max-w-md truncate">{path}</span>; return path;
} }
return ( return (
<> <>
<span className="text-muted-foreground">Screen: </span> <span className="text-muted-foreground">Screen: </span>
<span className="max-w-md truncate">{path}</span> {path}
</> </>
); );
} }
@@ -50,38 +56,26 @@ export function useColumns() {
return name.replace(/_/g, ' '); return name.replace(/_/g, ' ');
}; };
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return ( return (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button <button
type="button" className="shrink-0 transition-transform hover:scale-105"
className="transition-transform hover:scale-105"
onClick={() => { onClick={() => {
pushModal('EditEvent', { pushModal('EditEvent', {
id: row.original.id, id: row.original.id,
}); });
}} }}
type="button"
> >
<EventIcon <EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta} meta={row.original.meta}
name={row.original.name}
size="sm"
/> />
</button> </button>
<span className="flex gap-2"> <span className="flex min-w-0 flex-1 gap-2">
<button <button
type="button" className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
onClick={() => { onClick={() => {
pushModal('EventDetails', { pushModal('EventDetails', {
id: row.original.id, id: row.original.id,
@@ -89,11 +83,11 @@ export function useColumns() {
projectId: row.original.projectId, projectId: row.original.projectId,
}); });
}} }}
className="font-medium hover:underline" title={fullTitle}
type="button"
> >
{renderName()} <span className="block truncate">{renderName()}</span>
</button> </button>
{renderDuration()}
</span> </span>
</div> </div>
); );
@@ -107,8 +101,8 @@ export function useColumns() {
if (profile) { if (profile) {
return ( return (
<ProjectLink <ProjectLink
className="group row items-center gap-2 whitespace-nowrap font-medium hover:underline"
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${encodeURIComponent(profile.id)}`}
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
> >
<ProfileAvatar size="sm" {...profile} /> <ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)} {getProfileName(profile)}
@@ -119,8 +113,8 @@ export function useColumns() {
if (profileId && profileId !== deviceId) { if (profileId && profileId !== deviceId) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(profileId)}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
href={`/profiles/${encodeURIComponent(profileId)}`}
> >
Unknown Unknown
</ProjectLink> </ProjectLink>
@@ -130,8 +124,8 @@ export function useColumns() {
if (deviceId) { if (deviceId) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(deviceId)}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
href={`/profiles/${encodeURIComponent(deviceId)}`}
> >
Anonymous Anonymous
</ProjectLink> </ProjectLink>
@@ -152,10 +146,10 @@ export function useColumns() {
const { sessionId } = row.original; const { sessionId } = row.original;
return ( return (
<ProjectLink <ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
href={`/sessions/${encodeURIComponent(sessionId)}`}
> >
{sessionId.slice(0,6)} {sessionId.slice(0, 6)}
</ProjectLink> </ProjectLink>
); );
}, },
@@ -175,7 +169,7 @@ export function useColumns() {
cell({ row }) { cell({ row }) {
const { country, city } = row.original; const { country, city } = row.original;
return ( return (
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<SerieIcon name={country} /> <SerieIcon name={country} />
<span className="truncate">{city}</span> <span className="truncate">{city}</span>
</div> </div>
@@ -189,7 +183,7 @@ export function useColumns() {
cell({ row }) { cell({ row }) {
const { os } = row.original; const { os } = row.original;
return ( return (
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<SerieIcon name={os} /> <SerieIcon name={os} />
<span className="truncate">{os}</span> <span className="truncate">{os}</span>
</div> </div>
@@ -203,13 +197,39 @@ export function useColumns() {
cell({ row }) { cell({ row }) {
const { browser } = row.original; const { browser } = row.original;
return ( return (
<div className="row items-center gap-2 min-w-0"> <div className="row min-w-0 items-center gap-2">
<SerieIcon name={browser} /> <SerieIcon name={browser} />
<span className="truncate">{browser}</span> <span className="truncate">{browser}</span>
</div> </div>
); );
}, },
}, },
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
meta: {
hidden: true,
},
cell({ row }) {
const { groups } = row.original;
if (!groups?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<span
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
key={g}
>
{g}
</span>
))}
</div>
);
},
},
{ {
accessorKey: 'properties', accessorKey: 'properties',
header: 'Properties', header: 'Properties',
@@ -221,14 +241,14 @@ export function useColumns() {
const { properties } = row.original; const { properties } = row.original;
const filteredProperties = Object.fromEntries( const filteredProperties = Object.fromEntries(
Object.entries(properties || {}).filter( Object.entries(properties || {}).filter(
([key]) => !key.startsWith('__'), ([key]) => !key.startsWith('__')
), )
); );
const items = Object.entries(filteredProperties); const items = Object.entries(filteredProperties);
const limit = 2; const limit = 2;
const data = items.slice(0, limit).map(([key, value]) => ({ const data = items.slice(0, limit).map(([key, value]) => ({
name: key, name: key,
value: value, value,
})); }));
if (items.length > limit) { if (items.length > limit) {
data.push({ data.push({

View File

@@ -1,3 +1,17 @@
import type { IServiceEvent } from '@openpanel/db';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import EventListener from '../event-listener';
import { useColumns } from './columns';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { import {
OverviewFilterButton, OverviewFilterButton,
@@ -12,20 +26,6 @@ import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client'; import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IServiceEvent } from '@openpanel/db';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import { CalendarIcon, FilterIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import EventListener from '../event-listener';
import { useColumns } from './columns';
type Props = { type Props = {
query: UseInfiniteQueryResult< query: UseInfiniteQueryResult<
@@ -35,6 +35,7 @@ type Props = {
>, >,
unknown unknown
>; >;
showEventListener?: boolean;
}; };
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[]; const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
@@ -53,6 +54,7 @@ interface VirtualRowProps {
scrollMargin: number; scrollMargin: number;
isLoading: boolean; isLoading: boolean;
headerColumnsHash: string; headerColumnsHash: string;
onRowClick?: (row: any) => void;
} }
const VirtualRow = memo( const VirtualRow = memo(
@@ -62,12 +64,26 @@ const VirtualRow = memo(
headerColumns, headerColumns,
scrollMargin, scrollMargin,
isLoading, isLoading,
onRowClick,
}: VirtualRowProps) { }: VirtualRowProps) {
return ( return (
<div <div
className={cn(
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
onRowClick && 'cursor-pointer'
)}
data-index={virtualRow.index} data-index={virtualRow.index}
onClick={
onRowClick
? (e) => {
if ((e.target as HTMLElement).closest('a, button')) {
return;
}
onRowClick(row);
}
: undefined
}
ref={virtualRow.measureElement} ref={virtualRow.measureElement}
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
style={{ style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`, transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid', display: 'grid',
@@ -82,8 +98,8 @@ const VirtualRow = memo(
const width = `${cell.column.getSize()}px`; const width = `${cell.column.getSize()}px`;
return ( return (
<div <div
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
key={cell.id} key={cell.id}
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
style={{ style={{
width, width,
overflow: 'hidden', overflow: 'hidden',
@@ -113,16 +129,18 @@ const VirtualRow = memo(
prevProps.virtualRow.start === nextProps.virtualRow.start && prevProps.virtualRow.start === nextProps.virtualRow.start &&
prevProps.virtualRow.size === nextProps.virtualRow.size && prevProps.virtualRow.size === nextProps.virtualRow.size &&
prevProps.isLoading === nextProps.isLoading && prevProps.isLoading === nextProps.isLoading &&
prevProps.headerColumnsHash === nextProps.headerColumnsHash prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
prevProps.onRowClick === nextProps.onRowClick
); );
}, }
); );
const VirtualizedEventsTable = ({ const VirtualizedEventsTable = ({
table, table,
data, data,
isLoading, isLoading,
}: VirtualizedEventsTableProps) => { onRowClick,
}: VirtualizedEventsTableProps & { onRowClick?: (row: any) => void }) => {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const headerColumns = table.getAllLeafColumns().filter((col) => { const headerColumns = table.getAllLeafColumns().filter((col) => {
@@ -144,12 +162,12 @@ const VirtualizedEventsTable = ({
const headerColumnsHash = headerColumns.map((col) => col.id).join(','); const headerColumnsHash = headerColumns.map((col) => col.id).join(',');
return ( return (
<div <div
className="w-full overflow-x-auto rounded-md border bg-card"
ref={parentRef} ref={parentRef}
className="w-full overflow-x-auto border rounded-md bg-card"
> >
{/* Table Header */} {/* Table Header */}
<div <div
className="sticky top-0 z-10 bg-card border-b" className="sticky top-0 z-10 border-b bg-card"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: headerColumns gridTemplateColumns: headerColumns
@@ -163,8 +181,8 @@ const VirtualizedEventsTable = ({
const width = `${column.getSize()}px`; const width = `${column.getSize()}px`;
return ( return (
<div <div
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
key={column.id} key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{ style={{
width, width,
}} }}
@@ -177,8 +195,8 @@ const VirtualizedEventsTable = ({
{!isLoading && data.length === 0 && ( {!isLoading && data.length === 0 && (
<FullPageEmptyState <FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"} description={"Start sending events and you'll see them here"}
title="No events"
/> />
)} )}
@@ -193,20 +211,23 @@ const VirtualizedEventsTable = ({
> >
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index]; const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null; if (!row) {
return null;
}
return ( return (
<VirtualRow <VirtualRow
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
isLoading={isLoading}
key={row.id} key={row.id}
onRowClick={onRowClick}
row={row} row={row}
scrollMargin={rowVirtualizer.options.scrollMargin}
virtualRow={{ virtualRow={{
...virtualRow, ...virtualRow,
measureElement: rowVirtualizer.measureElement, measureElement: rowVirtualizer.measureElement,
}} }}
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
/> />
); );
})} })}
@@ -215,10 +236,18 @@ const VirtualizedEventsTable = ({
); );
}; };
export const EventsTable = ({ query }: Props) => { export const EventsTable = ({ query, showEventListener = false }: Props) => {
const { isLoading } = query; const { isLoading } = query;
const columns = useColumns(); const columns = useColumns();
const handleRowClick = useCallback((row: any) => {
pushModal('EventDetails', {
id: row.original.id,
createdAt: row.original.createdAt,
projectId: row.original.projectId,
});
}, []);
const data = useMemo(() => { const data = useMemo(() => {
if (isLoading) { if (isLoading) {
return LOADING_DATA; return LOADING_DATA;
@@ -272,13 +301,22 @@ export const EventsTable = ({ query }: Props) => {
return ( return (
<> <>
<EventsTableToolbar query={query} table={table} /> <EventsTableToolbar
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} /> query={query}
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}> showEventListener={showEventListener}
table={table}
/>
<VirtualizedEventsTable
data={data}
isLoading={isLoading}
onRowClick={handleRowClick}
table={table}
/>
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
<div <div
className={cn( className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity', 'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100', query.isFetchingNextPage && 'opacity-100'
)} )}
> >
<Loader2Icon className="size-4 animate-spin" /> <Loader2Icon className="size-4 animate-spin" />
@@ -291,24 +329,26 @@ export const EventsTable = ({ query }: Props) => {
function EventsTableToolbar({ function EventsTableToolbar({
query, query,
table, table,
showEventListener,
}: { }: {
query: Props['query']; query: Props['query'];
table: Table<IServiceEvent>; table: Table<IServiceEvent>;
showEventListener: boolean;
}) { }) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState( const [startDate, setStartDate] = useQueryState(
'startDate', 'startDate',
parseAsIsoDateTime, parseAsIsoDateTime
); );
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime); const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
return ( return (
<DataTableToolbarContainer> <DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2"> <div className="flex flex-1 flex-wrap items-center gap-2">
<EventListener onRefresh={() => query.refetch()} /> {showEventListener && (
<EventListener onRefresh={() => query.refetch()} />
)}
<Button <Button
variant="outline"
size="sm"
icon={CalendarIcon} icon={CalendarIcon}
onClick={() => { onClick={() => {
pushModal('DateRangerPicker', { pushModal('DateRangerPicker', {
@@ -320,6 +360,8 @@ function EventsTableToolbar({
endDate: endDate || undefined, endDate: endDate || undefined,
}); });
}} }}
size="sm"
variant="outline"
> >
{startDate && endDate {startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}` ? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`

View File

@@ -0,0 +1,130 @@
import { TrendingUpIcon } from 'lucide-react';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip as RechartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody } from '@/components/widget';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme';
type Props = {
data: { date: string; count: number }[];
};
function Tooltip(props: any) {
const number = useNumber();
const formatDate = useFormatDateInterval({ interval: 'day', short: false });
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[160px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-muted-foreground text-sm">
{formatDate(new Date(payload.timestamp))}
</div>
<div className="flex items-center gap-2">
<div
className="h-10 w-1 rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col gap-1">
<div className="text-muted-foreground text-sm">Total members</div>
<div
className="font-semibold text-lg"
style={{ color: getChartColor(0) }}
>
{number.format(payload.cumulative)}
</div>
</div>
</div>
{payload.count > 0 && (
<div className="text-muted-foreground text-xs">
+{number.format(payload.count)} new
</div>
)}
</div>
);
}
export function GroupMemberGrowth({ data }: Props) {
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps({});
const color = getChartColor(0);
let cumulative = 0;
const chartData = data.map((item) => {
cumulative += item.count;
return {
date: item.date,
timestamp: new Date(item.date).getTime(),
count: item.count,
cumulative,
};
});
const gradientId = 'memberGrowthGradient';
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
</WidgetHead>
<WidgetBody>
{data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No data yet
</p>
) : (
<div className="h-[200px] w-full">
<ResponsiveContainer>
<AreaChart data={chartData}>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
</defs>
<RechartTooltip
content={<Tooltip />}
cursor={{ stroke: color, strokeOpacity: 0.3 }}
/>
<Area
dataKey="cumulative"
dot={false}
fill={`url(#${gradientId})`}
isAnimationActive={false}
stroke={color}
strokeWidth={2}
type="monotone"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<CartesianGrid
className="stroke-border"
horizontal={true}
strokeDasharray="3 3"
strokeOpacity={0.5}
vertical={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,76 @@
import { useAppParams } from '@/hooks/use-app-params';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { Badge } from '@/components/ui/badge';
import { Link } from '@tanstack/react-router';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceGroup } from '@openpanel/db';
export type IServiceGroupWithStats = IServiceGroup & {
memberCount: number;
lastActiveAt: Date | null;
};
export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
const { organizationId, projectId } = useAppParams();
return [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
const group = row.original;
return (
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
);
},
},
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs">
{row.original.id}
</span>
),
},
{
accessorKey: 'type',
header: 'Type',
cell: ({ row }) => (
<Badge variant="outline">{row.original.type}</Badge>
),
},
{
accessorKey: 'memberCount',
header: 'Members',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.memberCount}</span>
),
},
{
accessorKey: 'lastActiveAt',
header: 'Last active',
size: ColumnCreatedAt.size,
cell: ({ row }) =>
row.original.lastActiveAt ? (
<ColumnCreatedAt>{row.original.lastActiveAt}</ColumnCreatedAt>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: 'createdAt',
header: 'Created',
size: ColumnCreatedAt.size,
cell: ({ row }) => (
<ColumnCreatedAt>{row.original.createdAt}</ColumnCreatedAt>
),
},
];
}

View File

@@ -0,0 +1,114 @@
import type { IServiceGroup } from '@openpanel/db';
import type { UseQueryResult } from '@tanstack/react-query';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
import { type IServiceGroupWithStats, useGroupColumns } from './columns';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
useDataTableColumnVisibility,
useDataTablePagination,
} from '@/components/ui/data-table/data-table-hooks';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import type { RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
const PAGE_SIZE = 50;
interface Props {
query: UseQueryResult<RouterOutputs['group']['list'], unknown>;
pageSize?: number;
toolbarLeft?: React.ReactNode;
}
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceGroupWithStats[];
export const GroupsTable = memo(
({ query, pageSize = PAGE_SIZE, toolbarLeft }: Props) => {
const { data, isLoading } = query;
const columns = useGroupColumns();
const { setPage, state: pagination } = useDataTablePagination(pageSize);
const {
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
} = useDataTableColumnVisibility(columns, 'groups');
const table = useReactTable({
data: isLoading ? LOADING_DATA : (data?.data ?? []),
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
columns,
rowCount: data?.meta.count,
pageCount: Math.ceil(
(data?.meta.count || 0) / (pagination.pageSize || 1)
),
filterFns: {
isWithinRange: () => true,
},
state: {
pagination,
columnVisibility,
columnOrder,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
const nextPagination =
typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue;
setPage(nextPagination.pageIndex + 1);
},
getRowId: (row, index) => row.id ?? `loading-${index}`,
});
return (
<>
<GroupsTableToolbar table={table} toolbarLeft={toolbarLeft} />
<DataTable
empty={{
title: 'No groups found',
description:
'Groups represent companies, teams, or other entities that events belong to.',
}}
loading={isLoading}
table={table}
/>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'pageSize', 'toolbarLeft'])
);
function GroupsTableToolbar({
table,
toolbarLeft,
}: {
table: Table<IServiceGroupWithStats>;
toolbarLeft?: React.ReactNode;
}) {
const { search, setSearch } = useSearchQueryState();
return (
<DataTableToolbarContainer>
<div className="flex flex-wrap items-center gap-2">
{toolbarLeft}
<AnimatedSearchInput
onChange={setSearch}
placeholder="Search groups..."
value={search}
/>
</div>
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>
);
}

View File

@@ -1,31 +1,13 @@
import type { import type { IServiceEvent } from '@openpanel/db';
IServiceClient,
IServiceEvent,
IServiceProject,
} from '@openpanel/db';
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react'; import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
import { useState } from 'react';
import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { timeAgo } from '@/utils/date'; import { timeAgo } from '@/utils/date';
interface Props { interface Props {
project: IServiceProject;
client: IServiceClient | null;
events: IServiceEvent[]; events: IServiceEvent[];
onVerified: (verified: boolean) => void;
} }
const VerifyListener = ({ client, events: _events, onVerified }: Props) => { const VerifyListener = ({ events }: Props) => {
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
useWS<IServiceEvent>(
`/live/events/${client?.projectId}?type=received`,
(data) => {
setEvents((prev) => [...prev, data]);
onVerified(true);
}
);
const isConnected = events.length > 0; const isConnected = events.length > 0;
const renderIcon = () => { const renderIcon = () => {
@@ -49,16 +31,18 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
<div <div
className={cn( className={cn(
'flex gap-6 rounded-xl p-4 md:p-6', 'flex gap-6 rounded-xl p-4 md:p-6',
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10' isConnected
? 'bg-emerald-100 dark:bg-emerald-700/10'
: 'bg-blue-500/10'
)} )}
> >
{renderIcon()} {renderIcon()}
<div className="flex-1"> <div className="flex-1">
<div className="font-semibold text-foreground/90 text-lg leading-normal"> <div className="font-semibold text-foreground/90 text-lg leading-normal">
{isConnected ? 'Success' : 'Waiting for events'} {isConnected ? 'Successfully connected' : 'Waiting for events'}
</div> </div>
{isConnected ? ( {isConnected ? (
<div className="flex flex-col-reverse"> <div className="mt-2 flex flex-col-reverse gap-1">
{events.length > 5 && ( {events.length > 5 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
@@ -69,7 +53,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
<div className="flex items-center gap-2" key={event.id}> <div className="flex items-center gap-2" key={event.id}>
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '} <span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800"> <span className="ml-auto text-foreground/50 text-sm">
{timeAgo(event.createdAt, 'round')} {timeAgo(event.createdAt, 'round')}
</span> </span>
</div> </div>

View File

@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
<XAxis {...xAxisProps} dataKey="date" /> <XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} /> <YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid <CartesianGrid
className="stroke-border"
horizontal={true} horizontal={true}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}

View File

@@ -1,4 +1,5 @@
import { Widget } from '@/components/widget'; import { ZapIcon } from 'lucide-react';
import { Widget, WidgetEmptyState } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = { type Props = {
@@ -6,28 +7,32 @@ type Props = {
}; };
export const MostEvents = ({ data }: Props) => { export const MostEvents = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count)); const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
return ( return (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead> <WidgetHead>
<WidgetTitle>Popular events</WidgetTitle> <WidgetTitle>Popular events</WidgetTitle>
</WidgetHead> </WidgetHead>
<div className="flex flex-col gap-1 p-1"> {data.length === 0 ? (
{data.slice(0, 5).map((item) => ( <WidgetEmptyState icon={ZapIcon} text="No events yet" />
<div key={item.name} className="relative px-3 py-2"> ) : (
<div <div className="flex flex-col gap-1 p-1">
className="absolute bottom-0 left-0 top-0 rounded bg-def-200" {data.slice(0, 5).map((item) => (
style={{ <div key={item.name} className="relative px-3 py-2">
width: `${(item.count / max) * 100}%`, <div
}} className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
/> style={{
<div className="relative flex justify-between "> width: `${(item.count / max) * 100}%`,
<div>{item.name}</div> }}
<div>{item.count}</div> />
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
</div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</Widget> </Widget>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { Widget } from '@/components/widget'; import { RouteIcon } from 'lucide-react';
import { Widget, WidgetEmptyState } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = { type Props = {
@@ -6,28 +7,32 @@ type Props = {
}; };
export const PopularRoutes = ({ data }: Props) => { export const PopularRoutes = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count)); const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
return ( return (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead> <WidgetHead>
<WidgetTitle>Most visted pages</WidgetTitle> <WidgetTitle>Most visted pages</WidgetTitle>
</WidgetHead> </WidgetHead>
<div className="flex flex-col gap-1 p-1"> {data.length === 0 ? (
{data.slice(0, 5).map((item) => ( <WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
<div key={item.path} className="relative px-3 py-2"> ) : (
<div <div className="flex flex-col gap-1 p-1">
className="absolute bottom-0 left-0 top-0 rounded bg-def-200" {data.slice(0, 5).map((item) => (
style={{ <div key={item.path} className="relative px-3 py-2">
width: `${(item.count / max) * 100}%`, <div
}} className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
/> style={{
<div className="relative flex justify-between "> width: `${(item.count / max) * 100}%`,
<div>{item.path}</div> }}
<div>{item.count}</div> />
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
</div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</Widget> </Widget>
); );
}; };

View File

@@ -0,0 +1,43 @@
import { ProjectLink } from '@/components/links';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { UsersIcon } from 'lucide-react';
interface Props {
profileId: string;
projectId: string;
groups: string[];
}
export const ProfileGroups = ({ projectId, groups }: Props) => {
const trpc = useTRPC();
const query = useQuery(
trpc.group.listByIds.queryOptions({
projectId,
ids: groups,
}),
);
if (groups.length === 0 || !query.data?.length) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground text-xs">
<UsersIcon className="size-3.5" />
Groups
</span>
{query.data.map((group) => (
<ProjectLink
key={group.id}
href={`/groups/${encodeURIComponent(group.id)}`}
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-2.5 py-1 text-xs transition-colors hover:bg-muted"
>
<span className="font-medium">{group.name}</span>
<span className="text-muted-foreground">{group.type}</span>
</ProjectLink>
))}
</div>
);
};

View File

@@ -1,15 +1,10 @@
import type { IServiceProfile } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { ProfileAvatar } from '../profile-avatar';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProjectLink } from '@/components/links'; import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tooltiper } from '@/components/ui/tooltip';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceProfile } from '@openpanel/db';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '../profile-avatar';
export function useColumns(type: 'profiles' | 'power-users') { export function useColumns(type: 'profiles' | 'power-users') {
const columns: ColumnDef<IServiceProfile>[] = [ const columns: ColumnDef<IServiceProfile>[] = [
@@ -20,8 +15,8 @@ export function useColumns(type: 'profiles' | 'power-users') {
const profile = row.original; const profile = row.original;
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="flex items-center gap-2 font-medium" className="flex items-center gap-2 font-medium"
href={`/profiles/${encodeURIComponent(profile.id)}`}
title={getProfileName(profile, false)} title={getProfileName(profile, false)}
> >
<ProfileAvatar size="sm" {...profile} /> <ProfileAvatar size="sm" {...profile} />
@@ -100,13 +95,40 @@ export function useColumns(type: 'profiles' | 'power-users') {
}, },
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Last seen', header: 'First seen',
size: ColumnCreatedAt.size, size: ColumnCreatedAt.size,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original; const item = row.original;
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>; return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
}, },
}, },
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
meta: {
hidden: true,
},
cell({ row }) {
const { groups } = row.original;
if (!groups?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<ProjectLink
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline"
href={`/groups/${encodeURIComponent(g)}`}
key={g}
>
{g}
</ProjectLink>
))}
</div>
);
},
},
]; ];
if (type === 'power-users') { if (type === 'power-users') {

View File

@@ -1,22 +1,24 @@
import type { IServiceProfile } from '@openpanel/db';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import type { RouterOutputs } from '@/trpc/client'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo, useCallback } from 'react';
import { useColumns } from './columns'; import { useColumns } from './columns';
import { DataTable } from '@/components/ui/data-table/data-table'; import { DataTable } from '@/components/ui/data-table/data-table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks'; import {
useDataTableColumnVisibility,
useDataTablePagination,
} from '@/components/ui/data-table/data-table-hooks';
import { import {
AnimatedSearchInput, AnimatedSearchInput,
DataTableToolbarContainer, DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar'; } from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useAppParams } from '@/hooks/use-app-params';
import { useSearchQueryState } from '@/hooks/use-search-query-state'; import { useSearchQueryState } from '@/hooks/use-search-query-state';
import type { RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal'; import { arePropsEqual } from '@/utils/are-props-equal';
import type { IServiceProfile } from '@openpanel/db';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -32,6 +34,22 @@ export const ProfilesTable = memo(
({ type, query, pageSize = PAGE_SIZE }: Props) => { ({ type, query, pageSize = PAGE_SIZE }: Props) => {
const { data, isLoading } = query; const { data, isLoading } = query;
const columns = useColumns(type); const columns = useColumns(type);
const navigate = useNavigate();
const { organizationId, projectId } = useAppParams();
const handleRowClick = useCallback(
(row: any) => {
navigate({
to: '/$organizationId/$projectId/profiles/$profileId',
params: {
organizationId,
projectId,
profileId: encodeURIComponent(row.original.id),
},
});
},
[navigate, organizationId, projectId]
);
const { setPage, state: pagination } = useDataTablePagination(pageSize); const { setPage, state: pagination } = useDataTablePagination(pageSize);
const { const {
@@ -50,7 +68,7 @@ export const ProfilesTable = memo(
columns, columns,
rowCount: data?.meta.count, rowCount: data?.meta.count,
pageCount: Math.ceil( pageCount: Math.ceil(
(data?.meta.count || 0) / (pagination.pageSize || 1), (data?.meta.count || 0) / (pagination.pageSize || 1)
), ),
filterFns: { filterFns: {
isWithinRange: () => true, isWithinRange: () => true,
@@ -76,17 +94,18 @@ export const ProfilesTable = memo(
<> <>
<ProfileTableToolbar table={table} /> <ProfileTableToolbar table={table} />
<DataTable <DataTable
table={table}
loading={isLoading}
empty={{ empty={{
title: 'No profiles', title: 'No profiles',
description: "Looks like you haven't identified any profiles yet.", description: "Looks like you haven't identified any profiles yet.",
}} }}
loading={isLoading}
onRowClick={handleRowClick}
table={table}
/> />
</> </>
); );
}, },
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']), arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize'])
); );
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) { function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
@@ -94,9 +113,9 @@ function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
return ( return (
<DataTableToolbarContainer> <DataTableToolbarContainer>
<AnimatedSearchInput <AnimatedSearchInput
onChange={setSearch}
placeholder="Search profiles" placeholder="Search profiles"
value={search} value={search}
onChange={setSearch}
/> />
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
</DataTableToolbarContainer> </DataTableToolbarContainer>

View File

@@ -1,11 +1,9 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react'; import { ProjectLink } from '../links';
import { SerieIcon } from '../report-chart/common/serie-icon';
import useWS from '@/hooks/use-ws'; import { useTRPC } from '@/integrations/trpc/react';
import type { IServiceEvent } from '@openpanel/db'; import { formatTimeAgoOrDateTime } from '@/utils/date';
import { EventItem } from '../events/table/item';
interface RealtimeActiveSessionsProps { interface RealtimeActiveSessionsProps {
projectId: string; projectId: string;
@@ -17,64 +15,52 @@ export function RealtimeActiveSessions({
limit = 10, limit = 10,
}: RealtimeActiveSessionsProps) { }: RealtimeActiveSessionsProps) {
const trpc = useTRPC(); const trpc = useTRPC();
const activeSessionsQuery = useQuery( const { data: sessions = [] } = useQuery(
trpc.realtime.activeSessions.queryOptions({ trpc.realtime.activeSessions.queryOptions(
projectId, { projectId },
}), { refetchInterval: 5000 }
)
); );
const [state, setState] = useState<IServiceEvent[]>([]);
// Update state when initial data loads
useEffect(() => {
if (activeSessionsQuery.data && state.length === 0) {
setState(activeSessionsQuery.data);
}
}, [activeSessionsQuery.data, state]);
// Set up WebSocket connection for real-time updates
useWS<IServiceEvent>(
`/live/events/${projectId}`,
(session) => {
setState((prev) => {
// Add new session and remove duplicates, keeping most recent
const filtered = prev.filter((s) => s.id !== session.id);
return [session, ...filtered].slice(0, limit);
});
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
const sessions = state.length > 0 ? state : (activeSessionsQuery.data ?? []);
return ( return (
<div className="col h-full max-md:hidden"> <div className="col card h-full max-md:hidden">
<div className="hide-scrollbar h-full overflow-y-auto pb-10"> <div className="hide-scrollbar h-full overflow-y-auto">
<AnimatePresence mode="popLayout" initial={false}> <AnimatePresence initial={false} mode="popLayout">
<div className="col gap-4"> <div className="col divide-y">
{sessions.map((session) => ( {sessions.slice(0, limit).map((session) => (
<motion.div <motion.div
key={session.id}
layout
// initial={{ opacity: 0, x: -200, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }} animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 200, scale: 0.8 }} exit={{ opacity: 0, x: 200, scale: 0.8 }}
key={session.id}
layout
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }} transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
> >
<EventItem <ProjectLink
event={session} className="relative block p-4 py-3 pr-14"
viewOptions={{ href={`/sessions/${session.sessionId}`}
properties: false, >
origin: false, <div className="col flex-1 gap-1">
queryString: false, {session.name === 'screen_view' && (
}} <span className="text-muted-foreground text-xs leading-normal/80">
className="w-full" {session.origin}
/> </span>
)}
<span className="font-medium text-sm leading-normal">
{session.name === 'screen_view'
? session.path
: session.name}
</span>
<span className="text-muted-foreground text-xs">
{formatTimeAgoOrDateTime(session.createdAt)}
</span>
</div>
<div className="row absolute top-1/2 right-4 origin-right -translate-y-1/2 scale-50 gap-2">
<SerieIcon name={session.referrerName} />
<SerieIcon name={session.os} />
<SerieIcon name={session.browser} />
<SerieIcon name={session.device} />
</div>
</ProjectLink>
</motion.div> </motion.div>
))} ))}
</div> </div>

View File

@@ -166,7 +166,8 @@ export function Tables({
metric: 'sum', metric: 'sum',
options: funnelOptions, options: funnelOptions,
}, },
stepIndex, // Pass the step index for funnel queries stepIndex,
breakdownValues: breakdowns,
}); });
}; };
return ( return (

View File

@@ -1,5 +1,8 @@
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import { import {
ActivityIcon, ActivityIcon,
Building2Icon,
ClockIcon, ClockIcon,
EqualApproximatelyIcon, EqualApproximatelyIcon,
type LucideIcon, type LucideIcon,
@@ -10,10 +13,7 @@ import {
UserCheckIcon, UserCheckIcon,
UsersIcon, UsersIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '../ui/button';
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -25,7 +25,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Button } from '../ui/button';
interface ReportChartTypeProps { interface ReportChartTypeProps {
className?: string; className?: string;
@@ -46,6 +45,7 @@ export function ReportSegment({
event: ActivityIcon, event: ActivityIcon,
user: UsersIcon, user: UsersIcon,
session: ClockIcon, session: ClockIcon,
group: Building2Icon,
user_average: UserCheck2Icon, user_average: UserCheck2Icon,
one_event_per_user: UserCheckIcon, one_event_per_user: UserCheckIcon,
property_sum: SigmaIcon, property_sum: SigmaIcon,
@@ -58,9 +58,9 @@ export function ReportSegment({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline"
icon={Icons[value]}
className={cn('justify-start text-sm', className)} className={cn('justify-start text-sm', className)}
icon={Icons[value]}
variant="outline"
> >
{items.find((item) => item.value === value)?.label} {items.find((item) => item.value === value)?.label}
</Button> </Button>
@@ -74,13 +74,13 @@ export function ReportSegment({
const Icon = Icons[item.value]; const Icon = Icons[item.value];
return ( return (
<DropdownMenuItem <DropdownMenuItem
className="group"
key={item.value} key={item.value}
onClick={() => onChange(item.value)} onClick={() => onChange(item.value)}
className="group"
> >
{item.label} {item.label}
<DropdownMenuShortcut> <DropdownMenuShortcut>
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" /> <Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
); );

View File

@@ -1,3 +1,14 @@
import type { IChartEvent } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import {
ArrowLeftIcon,
Building2Icon,
DatabaseIcon,
UserIcon,
} from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -10,11 +21,7 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties'; import { useEventProperties } from '@/hooks/use-event-properties';
import type { IChartEvent } from '@openpanel/validation'; import { useTRPC } from '@/integrations/trpc/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
interface PropertiesComboboxProps { interface PropertiesComboboxProps {
event?: IChartEvent; event?: IChartEvent;
@@ -40,15 +47,15 @@ function SearchHeader({
return ( return (
<div className="row items-center gap-1"> <div className="row items-center gap-1">
{!!onBack && ( {!!onBack && (
<Button variant="ghost" size="icon" onClick={onBack}> <Button onClick={onBack} size="icon" variant="ghost">
<ArrowLeftIcon className="size-4" /> <ArrowLeftIcon className="size-4" />
</Button> </Button>
)} )}
<Input <Input
autoFocus
onChange={(e) => onSearch(e.target.value)}
placeholder="Search" placeholder="Search"
value={value} value={value}
onChange={(e) => onSearch(e.target.value)}
autoFocus
/> />
</div> </div>
); );
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
exclude = [], exclude = [],
}: PropertiesComboboxProps) { }: PropertiesComboboxProps) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const trpc = useTRPC();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const properties = useEventProperties({ const properties = useEventProperties({
event: event?.name, event: event?.name,
projectId, projectId,
}); });
const [state, setState] = useState<'index' | 'event' | 'profile'>('index'); const groupPropertiesQuery = useQuery(
trpc.group.properties.queryOptions({ projectId })
);
const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>(
'index'
);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [direction, setDirection] = useState<'forward' | 'backward'>('forward'); const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile'); setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
} }
}, [open, mode]); }, [open, mode]);
@@ -86,11 +99,21 @@ export function PropertiesCombobox({
}); });
}; };
// Mock data for the lists // Fixed group properties: name, type, plus dynamic property keys
const groupActions = [
{ value: 'group.name', label: 'name', description: 'group' },
{ value: 'group.type', label: 'type', description: 'group' },
...(groupPropertiesQuery.data ?? []).map((key) => ({
value: `group.properties.${key}`,
label: key,
description: 'group.properties',
})),
].filter((a) => shouldShowProperty(a.value));
const profileActions = properties const profileActions = properties
.filter( .filter(
(property) => (property) =>
property.startsWith('profile') && shouldShowProperty(property), property.startsWith('profile') && shouldShowProperty(property)
) )
.map((property) => ({ .map((property) => ({
value: property, value: property,
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
const eventActions = properties const eventActions = properties
.filter( .filter(
(property) => (property) =>
!property.startsWith('profile') && shouldShowProperty(property), !property.startsWith('profile') && shouldShowProperty(property)
) )
.map((property) => ({ .map((property) => ({
value: property, value: property,
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
description: property.split('.').slice(0, -1).join('.'), description: property.split('.').slice(0, -1).join('.'),
})); }));
const handleStateChange = (newState: 'index' | 'event' | 'profile') => { const handleStateChange = (
newState: 'index' | 'event' | 'profile' | 'group'
) => {
setDirection(newState === 'index' ? 'backward' : 'forward'); setDirection(newState === 'index' ? 'backward' : 'forward');
setState(newState); setState(newState);
}; };
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
}} }}
> >
Event properties Event properties
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" /> <DatabaseIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="group justify-between gap-2" className="group justify-between gap-2"
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
}} }}
> >
Profile properties Profile properties
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" /> <UserIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem>
<DropdownMenuItem
className="group justify-between gap-2"
onClick={(e) => {
e.preventDefault();
handleStateChange('group');
}}
>
Group properties
<Building2Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
); );
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
const filteredActions = eventActions.filter( const filteredActions = eventActions.filter(
(action) => (action) =>
action.label.toLowerCase().includes(search.toLowerCase()) || action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()), action.description.toLowerCase().includes(search.toLowerCase())
); );
return ( return (
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<VirtualList <VirtualList
height={300}
data={filteredActions} data={filteredActions}
height={300}
itemHeight={40} itemHeight={40}
itemKey="id" itemKey="id"
> >
{(action) => ( {(action) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px" className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)} onClick={() => handleSelect(action)}
> >
<div className="font-medium">{action.label}</div> <div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{action.description} {action.description}
</div> </div>
</motion.div> </motion.div>
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
const filteredActions = profileActions.filter( const filteredActions = profileActions.filter(
(action) => (action) =>
action.label.toLowerCase().includes(search.toLowerCase()) || action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()), action.description.toLowerCase().includes(search.toLowerCase())
); );
return ( return (
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<VirtualList <VirtualList
height={300}
data={filteredActions} data={filteredActions}
height={300}
itemHeight={40} itemHeight={40}
itemKey="id" itemKey="id"
> >
{(action) => ( {(action) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px" className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)} onClick={() => handleSelect(action)}
> >
<div className="font-medium">{action.label}</div> <div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{action.description}
</div>
</motion.div>
)}
</VirtualList>
</div>
);
};
const renderGroup = () => {
const filteredActions = groupActions.filter(
(action) =>
action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col">
<SearchHeader
onBack={() => handleStateChange('index')}
onSearch={setSearch}
value={search}
/>
<DropdownMenuSeparator />
<VirtualList
data={filteredActions}
height={Math.min(300, filteredActions.length * 40 + 8)}
itemHeight={40}
itemKey="value"
>
{(action) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-muted-foreground text-sm">
{action.description} {action.description}
</div> </div>
</motion.div> </motion.div>
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
return ( return (
<DropdownMenu <DropdownMenu
open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
setOpen(open); setOpen(open);
}} }}
open={open}
> >
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-80" align="start"> <DropdownMenuContent align="start" className="max-w-80">
<AnimatePresence mode="wait" initial={false}> <AnimatePresence initial={false} mode="wait">
{state === 'index' && ( {state === 'index' && (
<motion.div <motion.div
key="index"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="index"
transition={{ duration: 0.05 }} transition={{ duration: 0.05 }}
> >
{renderIndex()} {renderIndex()}
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
)} )}
{state === 'event' && ( {state === 'event' && (
<motion.div <motion.div
key="event"
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }} exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="event"
transition={{ duration: 0.05 }} transition={{ duration: 0.05 }}
> >
{renderEvent()} {renderEvent()}
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
)} )}
{state === 'profile' && ( {state === 'profile' && (
<motion.div <motion.div
key="profile"
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }} exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="profile"
transition={{ duration: 0.05 }} transition={{ duration: 0.05 }}
> >
{renderProfile()} {renderProfile()}
</motion.div> </motion.div>
)} )}
{state === 'group' && (
<motion.div
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="group"
transition={{ duration: 0.05 }}
>
{renderGroup()}
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -74,7 +74,7 @@ export function useColumns() {
if (session.profile) { if (session.profile) {
return ( return (
<ProjectLink <ProjectLink
className="row items-center gap-2 font-medium" className="row items-center gap-2 font-medium hover:underline"
href={`/profiles/${encodeURIComponent(session.profile.id)}`} href={`/profiles/${encodeURIComponent(session.profile.id)}`}
> >
<ProfileAvatar size="sm" {...session.profile} /> <ProfileAvatar size="sm" {...session.profile} />
@@ -270,6 +270,27 @@ export function useColumns() {
header: 'Device ID', header: 'Device ID',
size: 120, size: 120,
}, },
{
accessorKey: 'groups',
header: 'Groups',
size: 200,
cell: ({ row }) => {
const { groups } = row.original;
if (!groups?.length) return null;
return (
<div className="flex flex-wrap gap-1">
{groups.map((g) => (
<span
key={g}
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"
>
{g}
</span>
))}
</div>
);
},
},
]; ];
return columns; return columns;

View File

@@ -1,9 +1,7 @@
import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { useLocalStorage } from 'usehooks-ts'; import { useLocalStorage } from 'usehooks-ts';
import { useColumns } from './columns'; import { useColumns } from './columns';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
// Custom hook for persistent column visibility // Custom hook for persistent column visibility
const usePersistentColumnVisibility = (columns: any[]) => { const usePersistentColumnVisibility = (columns: any[]) => {
@@ -21,7 +19,7 @@ const usePersistentColumnVisibility = (columns: any[]) => {
} }
return acc; return acc;
}, },
{} as Record<string, boolean>, {} as Record<string, boolean>
); );
}, [columns, savedVisibility]); }, [columns, savedVisibility]);
@@ -37,25 +35,33 @@ const usePersistentColumnVisibility = (columns: any[]) => {
}; };
}; };
import type { IServiceSession } from '@openpanel/db';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { Loader2Icon, SlidersHorizontalIcon } from 'lucide-react';
import { last } from 'ramda';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Skeleton } from '@/components/skeleton'; import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { import {
AnimatedSearchInput, AnimatedSearchInput,
DataTableToolbarContainer, DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar'; } from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import type { FilterDefinition } from '@/components/ui/filter-dropdown';
import { FilterDropdown } from '@/components/ui/filter-dropdown';
import { useAppParams } from '@/hooks/use-app-params';
import { useSearchQueryState } from '@/hooks/use-search-query-state'; import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { arePropsEqual } from '@/utils/are-props-equal'; import { useSessionFilters } from '@/hooks/use-session-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IServiceSession } from '@openpanel/db';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { Loader2Icon } from 'lucide-react';
import { last } from 'ramda';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
type Props = { type Props = {
query: UseInfiniteQueryResult< query: UseInfiniteQueryResult<
@@ -83,6 +89,7 @@ interface VirtualRowProps {
scrollMargin: number; scrollMargin: number;
isLoading: boolean; isLoading: boolean;
headerColumnsHash: string; headerColumnsHash: string;
onRowClick?: (row: any) => void;
} }
const VirtualRow = memo( const VirtualRow = memo(
@@ -92,12 +99,26 @@ const VirtualRow = memo(
headerColumns, headerColumns,
scrollMargin, scrollMargin,
isLoading, isLoading,
onRowClick,
}: VirtualRowProps) { }: VirtualRowProps) {
return ( return (
<div <div
className={cn(
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
onRowClick && 'cursor-pointer'
)}
data-index={virtualRow.index} data-index={virtualRow.index}
onClick={
onRowClick
? (e) => {
if ((e.target as HTMLElement).closest('a, button')) {
return;
}
onRowClick(row);
}
: undefined
}
ref={virtualRow.measureElement} ref={virtualRow.measureElement}
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
style={{ style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`, transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid', display: 'grid',
@@ -112,8 +133,8 @@ const VirtualRow = memo(
const width = `${cell.column.getSize()}px`; const width = `${cell.column.getSize()}px`;
return ( return (
<div <div
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
key={cell.id} key={cell.id}
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
style={{ style={{
width, width,
overflow: 'hidden', overflow: 'hidden',
@@ -143,16 +164,18 @@ const VirtualRow = memo(
prevProps.virtualRow.start === nextProps.virtualRow.start && prevProps.virtualRow.start === nextProps.virtualRow.start &&
prevProps.virtualRow.size === nextProps.virtualRow.size && prevProps.virtualRow.size === nextProps.virtualRow.size &&
prevProps.isLoading === nextProps.isLoading && prevProps.isLoading === nextProps.isLoading &&
prevProps.headerColumnsHash === nextProps.headerColumnsHash prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
prevProps.onRowClick === nextProps.onRowClick
); );
}, }
); );
const VirtualizedSessionsTable = ({ const VirtualizedSessionsTable = ({
table, table,
data, data,
isLoading, isLoading,
}: VirtualizedSessionsTableProps) => { onRowClick,
}: VirtualizedSessionsTableProps & { onRowClick?: (row: any) => void }) => {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const headerColumns = table.getAllLeafColumns().filter((col) => { const headerColumns = table.getAllLeafColumns().filter((col) => {
@@ -171,12 +194,12 @@ const VirtualizedSessionsTable = ({
return ( return (
<div <div
className="w-full overflow-x-auto rounded-md border bg-card"
ref={parentRef} ref={parentRef}
className="w-full overflow-x-auto border rounded-md bg-card"
> >
{/* Table Header */} {/* Table Header */}
<div <div
className="sticky top-0 z-10 bg-card border-b" className="sticky top-0 z-10 border-b bg-card"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: headerColumns gridTemplateColumns: headerColumns
@@ -190,8 +213,8 @@ const VirtualizedSessionsTable = ({
const width = `${column.getSize()}px`; const width = `${column.getSize()}px`;
return ( return (
<div <div
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
key={column.id} key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{ style={{
width, width,
}} }}
@@ -204,8 +227,8 @@ const VirtualizedSessionsTable = ({
{!isLoading && data.length === 0 && ( {!isLoading && data.length === 0 && (
<FullPageEmptyState <FullPageEmptyState
title="No sessions found"
description="Looks like you haven't inserted any events yet." description="Looks like you haven't inserted any events yet."
title="No sessions found"
/> />
)} )}
@@ -220,20 +243,23 @@ const VirtualizedSessionsTable = ({
> >
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index]; const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null; if (!row) {
return null;
}
return ( return (
<VirtualRow <VirtualRow
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
isLoading={isLoading}
key={row.id} key={row.id}
onRowClick={onRowClick}
row={row} row={row}
scrollMargin={rowVirtualizer.options.scrollMargin}
virtualRow={{ virtualRow={{
...virtualRow, ...virtualRow,
measureElement: rowVirtualizer.measureElement, measureElement: rowVirtualizer.measureElement,
}} }}
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
/> />
); );
})} })}
@@ -245,13 +271,25 @@ const VirtualizedSessionsTable = ({
export const SessionsTable = ({ query }: Props) => { export const SessionsTable = ({ query }: Props) => {
const { isLoading } = query; const { isLoading } = query;
const columns = useColumns(); const columns = useColumns();
const navigate = useNavigate();
const { organizationId, projectId } = useAppParams();
const handleRowClick = useCallback(
(row: any) => {
navigate({
to: '/$organizationId/$projectId/sessions/$sessionId',
params: { organizationId, projectId, sessionId: row.original.id },
});
},
[navigate, organizationId, projectId]
);
const data = useMemo(() => { const data = useMemo(() => {
if (isLoading) { if (isLoading) {
return LOADING_DATA; return LOADING_DATA;
} }
return query.data?.pages?.flatMap((p) => p.data) ?? []; return query.data?.pages?.flatMap((p) => p.items) ?? [];
}, [query.data]); }, [query.data]);
// const { setPage, state: pagination } = useDataTablePagination(); // const { setPage, state: pagination } = useDataTablePagination();
@@ -292,7 +330,6 @@ export const SessionsTable = ({ query }: Props) => {
enterCount > 0 && enterCount > 0 &&
query.isFetchingNextPage === false query.isFetchingNextPage === false
) { ) {
console.log('fetching next page');
query.fetchNextPage(); query.fetchNextPage();
} }
}, [inViewport, enterCount, hasNextPage]); }, [inViewport, enterCount, hasNextPage]);
@@ -301,15 +338,16 @@ export const SessionsTable = ({ query }: Props) => {
<> <>
<SessionTableToolbar table={table} /> <SessionTableToolbar table={table} />
<VirtualizedSessionsTable <VirtualizedSessionsTable
table={table}
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
onRowClick={handleRowClick}
table={table}
/> />
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}> <div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
<div <div
className={cn( className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity', 'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100', query.isFetchingNextPage && 'opacity-100'
)} )}
> >
<Loader2Icon className="size-4 animate-spin" /> <Loader2Icon className="size-4 animate-spin" />
@@ -319,15 +357,88 @@ export const SessionsTable = ({ query }: Props) => {
); );
}; };
const SESSION_FILTER_KEY_TO_FIELD: Record<string, string> = {
referrer: 'referrer_name',
country: 'country',
os: 'os',
browser: 'browser',
device: 'device',
};
const SESSION_FILTER_DEFINITIONS: FilterDefinition[] = [
{ key: 'referrer', label: 'Referrer', type: 'select' },
{ key: 'country', label: 'Country', type: 'select' },
{ key: 'os', label: 'OS', type: 'select' },
{ key: 'browser', label: 'Browser', type: 'select' },
{ key: 'device', label: 'Device', type: 'select' },
{ key: 'entryPage', label: 'Entry page', type: 'string' },
{ key: 'exitPage', label: 'Exit page', type: 'string' },
{ key: 'minPageViews', label: 'Min page views', type: 'number' },
{ key: 'maxPageViews', label: 'Max page views', type: 'number' },
{ key: 'minEvents', label: 'Min events', type: 'number' },
{ key: 'maxEvents', label: 'Max events', type: 'number' },
];
function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) { function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { search, setSearch } = useSearchQueryState(); const { search, setSearch } = useSearchQueryState();
const { values, setValue, activeCount } = useSessionFilters();
const loadOptions = useCallback(
(key: string) => {
const field = SESSION_FILTER_KEY_TO_FIELD[key];
if (!field) {
return Promise.resolve([]);
}
return queryClient.fetchQuery(
trpc.session.distinctValues.queryOptions({
projectId,
field: field as
| 'referrer_name'
| 'country'
| 'os'
| 'browser'
| 'device',
})
);
},
[trpc, queryClient, projectId]
);
return ( return (
<DataTableToolbarContainer> <DataTableToolbarContainer>
<AnimatedSearchInput <div className="flex flex-1 flex-wrap items-center gap-2">
placeholder="Search sessions by path, referrer..." <AnimatedSearchInput
value={search} onChange={setSearch}
onChange={setSearch} placeholder="Search sessions by path, referrer..."
/> value={search}
/>
<FilterDropdown
definitions={SESSION_FILTER_DEFINITIONS}
loadOptions={loadOptions}
onChange={setValue}
values={values}
>
<Button
className={cn(
'border-dashed',
activeCount > 0 && 'border-primary border-solid'
)}
size="sm"
variant="outline"
>
<SlidersHorizontalIcon className="mr-2 size-4" />
Filters
{activeCount > 0 && (
<Badge className="ml-2 rounded-full px-1.5 py-0 text-xs">
{activeCount}
</Badge>
)}
</Button>
</FilterDropdown>
</div>
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
</DataTableToolbarContainer> </DataTableToolbarContainer>
); );

View File

@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { import {
BellIcon, BellIcon,
BookOpenIcon, BookOpenIcon,
Building2Icon,
ChartLineIcon, ChartLineIcon,
ChevronDownIcon, ChevronDownIcon,
CogIcon, CogIcon,
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" /> <SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" /> <SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" /> <SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm"> <div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
Manage Manage
</div> </div>

View File

@@ -1,7 +1,6 @@
import type { Column, Table } from '@tanstack/react-table'; import type { Column, Table } from '@tanstack/react-table';
import { SearchIcon, X, XIcon } from 'lucide-react'; import { SearchIcon, X, XIcon } from 'lucide-react';
import * as React from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter'; import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter'; import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
@@ -23,12 +22,12 @@ export function DataTableToolbarContainer({
}: React.ComponentProps<'div'>) { }: React.ComponentProps<'div'>) {
return ( return (
<div <div
role="toolbar"
aria-orientation="horizontal" aria-orientation="horizontal"
className={cn( className={cn(
'flex flex-1 items-start justify-between gap-2 mb-2', 'mb-2 flex flex-1 items-start justify-between gap-2',
className, className
)} )}
role="toolbar"
{...props} {...props}
/> />
); );
@@ -47,12 +46,12 @@ export function DataTableToolbar<TData>({
}); });
const isFiltered = table.getState().columnFilters.length > 0; const isFiltered = table.getState().columnFilters.length > 0;
const columns = React.useMemo( const columns = useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()), () => table.getAllColumns().filter((column) => column.getCanFilter()),
[table], [table]
); );
const onReset = React.useCallback(() => { const onReset = useCallback(() => {
table.resetColumnFilters(); table.resetColumnFilters();
}, [table]); }, [table]);
@@ -61,23 +60,23 @@ export function DataTableToolbar<TData>({
<div className="flex flex-1 flex-wrap items-center gap-2"> <div className="flex flex-1 flex-wrap items-center gap-2">
{globalSearchKey && ( {globalSearchKey && (
<AnimatedSearchInput <AnimatedSearchInput
onChange={setSearch}
placeholder={globalSearchPlaceholder ?? 'Search'} placeholder={globalSearchPlaceholder ?? 'Search'}
value={search} value={search}
onChange={setSearch}
/> />
)} )}
{columns.map((column) => ( {columns.map((column) => (
<DataTableToolbarFilter key={column.id} column={column} /> <DataTableToolbarFilter column={column} key={column.id} />
))} ))}
{isFiltered && ( {isFiltered && (
<Button <Button
aria-label="Reset filters" aria-label="Reset filters"
variant="outline"
size="sm"
className="border-dashed" className="border-dashed"
onClick={onReset} onClick={onReset}
size="sm"
variant="outline"
> >
<XIcon className="size-4 mr-2" /> <XIcon className="mr-2 size-4" />
Reset Reset
</Button> </Button>
)} )}
@@ -99,20 +98,22 @@ function DataTableToolbarFilter<TData>({
{ {
const columnMeta = column.columnDef.meta; const columnMeta = column.columnDef.meta;
const getTitle = React.useCallback(() => { const getTitle = useCallback(() => {
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id; return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
}, [columnMeta, column]); }, [columnMeta, column]);
const onFilterRender = React.useCallback(() => { const onFilterRender = useCallback(() => {
if (!columnMeta?.variant) return null; if (!columnMeta?.variant) {
return null;
}
switch (columnMeta.variant) { switch (columnMeta.variant) {
case 'text': case 'text':
return ( return (
<AnimatedSearchInput <AnimatedSearchInput
onChange={(value) => column.setFilterValue(value)}
placeholder={columnMeta.placeholder ?? columnMeta.label} placeholder={columnMeta.placeholder ?? columnMeta.label}
value={(column.getFilterValue() as string) ?? ''} value={(column.getFilterValue() as string) ?? ''}
onChange={(value) => column.setFilterValue(value)}
/> />
); );
@@ -120,12 +121,12 @@ function DataTableToolbarFilter<TData>({
return ( return (
<div className="relative"> <div className="relative">
<Input <Input
type="number"
inputMode="numeric"
placeholder={getTitle()}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')} className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
inputMode="numeric"
onChange={(event) => column.setFilterValue(event.target.value)}
placeholder={getTitle()}
type="number"
value={(column.getFilterValue() as string) ?? ''}
/> />
{columnMeta.unit && ( {columnMeta.unit && (
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm"> <span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
@@ -143,8 +144,8 @@ function DataTableToolbarFilter<TData>({
return ( return (
<DataTableDateFilter <DataTableDateFilter
column={column} column={column}
title={getTitle()}
multiple={columnMeta.variant === 'dateRange'} multiple={columnMeta.variant === 'dateRange'}
title={getTitle()}
/> />
); );
@@ -153,9 +154,9 @@ function DataTableToolbarFilter<TData>({
return ( return (
<DataTableFacetedFilter <DataTableFacetedFilter
column={column} column={column}
title={getTitle()}
options={columnMeta.options ?? []}
multiple={columnMeta.variant === 'multiSelect'} multiple={columnMeta.variant === 'multiSelect'}
options={columnMeta.options ?? []}
title={getTitle()}
/> />
); );
@@ -179,11 +180,11 @@ export function AnimatedSearchInput({
value, value,
onChange, onChange,
}: AnimatedSearchInputProps) { }: AnimatedSearchInputProps) {
const [isFocused, setIsFocused] = React.useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const isExpanded = isFocused || (value?.length ?? 0) > 0; const isExpanded = isFocused || (value?.length ?? 0) > 0;
const handleClear = React.useCallback(() => { const handleClear = useCallback(() => {
onChange(''); onChange('');
// Re-focus after clearing // Re-focus after clearing
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
@@ -191,34 +192,35 @@ export function AnimatedSearchInput({
return ( return (
<div <div
aria-label={placeholder ?? 'Search'}
className={cn( className={cn(
'relative flex h-8 items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out', 'relative flex items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background', 'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
isExpanded ? 'w-56 lg:w-72' : 'w-32', 'h-8 min-h-8',
isExpanded ? 'w-56 lg:w-72' : 'w-32'
)} )}
role="search" role="search"
aria-label={placeholder ?? 'Search'}
> >
<SearchIcon className="size-4 ml-2 shrink-0" /> <SearchIcon className="ml-2 size-4 shrink-0" />
<Input <Input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn( className={cn(
'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none', 'absolute inset-0 h-full w-full rounded-md border-0 bg-transparent py-2 pr-7 pl-7 shadow-none',
'focus-visible:ring-0 focus-visible:ring-offset-0', 'focus-visible:ring-0 focus-visible:ring-offset-0',
'transition-opacity duration-200', 'transition-opacity duration-200',
'font-medium text-[14px] truncate align-baseline', 'truncate align-baseline font-medium text-[14px]'
)} )}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
placeholder={placeholder}
ref={inputRef}
size="sm"
value={value}
/> />
{isExpanded && value && ( {isExpanded && value && (
<button <button
type="button"
aria-label="Clear search" aria-label="Clear search"
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={(e) => { onClick={(e) => {
@@ -226,6 +228,7 @@ export function AnimatedSearchInput({
e.stopPropagation(); e.stopPropagation();
handleClear(); handleClear();
}} }}
type="button"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>

View File

@@ -0,0 +1,328 @@
import { AnimatePresence, motion } from 'framer-motion';
import {
ArrowLeftIcon,
CheckIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import { useEffect, useState } from 'react';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/utils/cn';
export type FilterType = 'select' | 'string' | 'number';
export interface FilterDefinition {
key: string;
label: string;
type: FilterType;
/** For 'select' type: show SerieIcon next to options (default true) */
showIcon?: boolean;
}
interface FilterDropdownProps {
definitions: FilterDefinition[];
values: Record<string, string | number | null | undefined>;
onChange: (key: string, value: string | number | null) => void;
loadOptions: (key: string) => Promise<string[]>;
children: React.ReactNode;
}
export function FilterDropdown({
definitions,
values,
onChange,
loadOptions,
children,
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
const [search, setSearch] = useState('');
const [options, setOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
if (!open) {
setActiveKey(null);
setSearch('');
setOptions([]);
setInputValue('');
}
}, [open]);
useEffect(() => {
if (!activeKey) {
return;
}
const def = definitions.find((d) => d.key === activeKey);
if (!def || def.type !== 'select') {
return;
}
setIsLoading(true);
loadOptions(activeKey)
.then((opts) => {
setOptions(opts);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}, [activeKey]);
const currentDef = activeKey
? definitions.find((d) => d.key === activeKey)
: null;
const goToFilter = (key: string) => {
setDirection('forward');
setSearch('');
setOptions([]);
const current = values[key];
setInputValue(current != null ? String(current) : '');
setActiveKey(key);
};
const goBack = () => {
setDirection('backward');
setActiveKey(null);
setSearch('');
};
const applyValue = (key: string, value: string | number | null) => {
onChange(key, value);
goBack();
};
const renderIndex = () => (
<div className="min-w-52">
{definitions.map((def) => {
const currentValue = values[def.key];
const isActive = currentValue != null && currentValue !== '';
return (
<button
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent"
key={def.key}
onClick={() => goToFilter(def.key)}
type="button"
>
<span className="font-medium text-sm">{def.label}</span>
<div className="flex shrink-0 items-center gap-1">
{isActive && (
<>
<span className="max-w-24 truncate text-muted-foreground text-xs">
{String(currentValue)}
</span>
<button
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onChange(def.key, null);
}}
type="button"
>
<XIcon className="size-3" />
</button>
</>
)}
<ChevronRightIcon className="size-4 text-muted-foreground" />
</div>
</button>
);
})}
</div>
);
const renderSelectFilter = () => {
const showIcon = currentDef?.showIcon !== false;
const filteredOptions = options.filter((opt) =>
opt.toLowerCase().includes(search.toLowerCase())
);
const currentValue = activeKey ? values[activeKey] : undefined;
return (
<div className="min-w-52">
<div className="flex items-center gap-1 p-1">
<Button
className="size-7 shrink-0"
onClick={goBack}
size="icon"
variant="ghost"
>
<ArrowLeftIcon className="size-4" />
</Button>
<Input
autoFocus
className="h-7 text-sm"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
value={search}
/>
</div>
<Separator />
{isLoading ? (
<div className="flex items-center justify-center p-6 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
</div>
) : filteredOptions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm">
No options found
</div>
) : (
<VirtualList
data={filteredOptions}
height={Math.min(filteredOptions.length * 36, 250)}
itemHeight={36}
itemKey={(item) => item}
>
{(option) => (
<button
className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 hover:bg-accent"
onClick={() => applyValue(activeKey!, option)}
type="button"
>
{showIcon && <SerieIcon name={option} />}
<span className="truncate text-sm">{option || 'Direct'}</span>
<CheckIcon
className={cn(
'ml-auto size-4 shrink-0',
currentValue === option ? 'opacity-100' : 'opacity-0'
)}
/>
</button>
)}
</VirtualList>
)}
</div>
);
};
const renderStringFilter = () => (
<div className="min-w-52">
<div className="flex items-center gap-1 p-1">
<Button
className="size-7 shrink-0"
onClick={goBack}
size="icon"
variant="ghost"
>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
</div>
<Separator />
<div className="flex flex-col gap-2 p-2">
<Input
autoFocus
className="h-8 text-sm"
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
applyValue(activeKey!, inputValue || null);
}
}}
placeholder={`Filter by ${currentDef?.label.toLowerCase()}...`}
value={inputValue}
/>
<Button
className="w-full"
onClick={() => applyValue(activeKey!, inputValue || null)}
size="sm"
>
Apply
</Button>
</div>
</div>
);
const renderNumberFilter = () => (
<div className="min-w-52">
<div className="flex items-center gap-1 p-1">
<Button
className="size-7 shrink-0"
onClick={goBack}
size="icon"
variant="ghost"
>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
</div>
<Separator />
<div className="flex flex-col gap-2 p-2">
<Input
autoFocus
className="h-8 text-sm"
inputMode="numeric"
min={0}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
applyValue(
activeKey!,
inputValue === '' ? null : Number(inputValue)
);
}
}}
placeholder="Enter value..."
type="number"
value={inputValue}
/>
<Button
className="w-full"
onClick={() =>
applyValue(
activeKey!,
inputValue === '' ? null : Number(inputValue)
)
}
size="sm"
>
Apply
</Button>
</div>
</div>
);
const renderContent = () => {
if (!(activeKey && currentDef)) {
return renderIndex();
}
switch (currentDef.type) {
case 'select':
return renderSelectFilter();
case 'string':
return renderStringFilter();
case 'number':
return renderNumberFilter();
}
};
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-1">
<AnimatePresence initial={false} mode="wait">
<motion.div
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key={activeKey ?? 'index'}
transition={{ duration: 0.1 }}
>
{renderContent()}
</motion.div>
</AnimatePresence>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,7 +1,6 @@
import * as SelectPrimitive from '@radix-ui/react-select'; import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import type * as React from 'react'; import type * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Select({ function Select({
@@ -32,12 +31,12 @@ function SelectTrigger({
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-8 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className
)} )}
data-size={size}
data-slot="select-trigger"
{...props} {...props}
> >
{children} {children}
@@ -57,13 +56,13 @@ function SelectContent({
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content"
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
className, className
)} )}
data-slot="select-content"
position={position} position={position}
{...props} {...props}
> >
@@ -72,7 +71,7 @@ function SelectContent({
className={cn( className={cn(
'p-1', 'p-1',
position === 'popper' && position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1', 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)} )}
> >
{children} {children}
@@ -89,8 +88,8 @@ function SelectLabel({
}: React.ComponentProps<typeof SelectPrimitive.Label>) { }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
className={cn('px-2 py-1.5 text-muted-foreground text-xs', className)}
data-slot="select-label" data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props} {...props}
/> />
); );
@@ -103,11 +102,11 @@ function SelectItem({
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className, className
)} )}
data-slot="select-item"
{...props} {...props}
> >
<span className="absolute right-2 flex size-3.5 items-center justify-center"> <span className="absolute right-2 flex size-3.5 items-center justify-center">
@@ -126,8 +125,8 @@ function SelectSeparator({
}: React.ComponentProps<typeof SelectPrimitive.Separator>) { }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
data-slot="select-separator" data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
); );
@@ -139,11 +138,11 @@ function SelectScrollUpButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn( className={cn(
'flex cursor-default items-center justify-center py-1', 'flex cursor-default items-center justify-center py-1',
className, className
)} )}
data-slot="select-scroll-up-button"
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
@@ -157,11 +156,11 @@ function SelectScrollDownButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn( className={cn(
'flex cursor-default items-center justify-center py-1', 'flex cursor-default items-center justify-center py-1',
className, className
)} )}
data-slot="select-scroll-down-button"
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />

View File

@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>; return <div className={cn('p-4', className)}>{children}</div>;
} }
export interface WidgetEmptyStateProps {
icon: LucideIcon;
text: string;
}
export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<Icon size={28} strokeWidth={1.5} />
<p className="text-sm">{text}</p>
</div>
);
}
export interface WidgetProps { export interface WidgetProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;

View File

@@ -0,0 +1,229 @@
import type { IChartEventFilter } from '@openpanel/validation';
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
import { useCallback, useMemo } from 'react';
const DEBOUNCE_MS = 500;
const debounceOpts = {
clearOnDefault: true,
limitUrlUpdates: { method: 'debounce' as const, timeMs: DEBOUNCE_MS },
};
export function useSessionFilters() {
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [country, setCountry] = useQueryState(
'country',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [os, setOs] = useQueryState(
'os',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [device, setDevice] = useQueryState(
'device',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [entryPage, setEntryPage] = useQueryState(
'entryPage',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [exitPage, setExitPage] = useQueryState(
'exitPage',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [minPageViews, setMinPageViews] = useQueryState(
'minPageViews',
parseAsInteger,
);
const [maxPageViews, setMaxPageViews] = useQueryState(
'maxPageViews',
parseAsInteger,
);
const [minEvents, setMinEvents] = useQueryState('minEvents', parseAsInteger);
const [maxEvents, setMaxEvents] = useQueryState('maxEvents', parseAsInteger);
const filters = useMemo<IChartEventFilter[]>(() => {
const result: IChartEventFilter[] = [];
if (referrer) {
result.push({ name: 'referrer_name', operator: 'is', value: [referrer] });
}
if (country) {
result.push({ name: 'country', operator: 'is', value: [country] });
}
if (os) {
result.push({ name: 'os', operator: 'is', value: [os] });
}
if (browser) {
result.push({ name: 'browser', operator: 'is', value: [browser] });
}
if (device) {
result.push({ name: 'device', operator: 'is', value: [device] });
}
if (entryPage) {
result.push({
name: 'entry_path',
operator: 'contains',
value: [entryPage],
});
}
if (exitPage) {
result.push({
name: 'exit_path',
operator: 'contains',
value: [exitPage],
});
}
return result;
}, [referrer, country, os, browser, device, entryPage, exitPage]);
const values = useMemo(
() => ({
referrer,
country,
os,
browser,
device,
entryPage,
exitPage,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
}),
[
referrer,
country,
os,
browser,
device,
entryPage,
exitPage,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
],
);
const setValue = useCallback(
(key: string, value: string | number | null) => {
switch (key) {
case 'referrer':
setReferrer(String(value ?? ''));
break;
case 'country':
setCountry(String(value ?? ''));
break;
case 'os':
setOs(String(value ?? ''));
break;
case 'browser':
setBrowser(String(value ?? ''));
break;
case 'device':
setDevice(String(value ?? ''));
break;
case 'entryPage':
setEntryPage(String(value ?? ''));
break;
case 'exitPage':
setExitPage(String(value ?? ''));
break;
case 'minPageViews':
setMinPageViews(value != null ? Number(value) : null);
break;
case 'maxPageViews':
setMaxPageViews(value != null ? Number(value) : null);
break;
case 'minEvents':
setMinEvents(value != null ? Number(value) : null);
break;
case 'maxEvents':
setMaxEvents(value != null ? Number(value) : null);
break;
}
},
[
setReferrer,
setCountry,
setOs,
setBrowser,
setDevice,
setEntryPage,
setExitPage,
setMinPageViews,
setMaxPageViews,
setMinEvents,
setMaxEvents,
],
);
const activeCount =
filters.length +
(minPageViews != null ? 1 : 0) +
(maxPageViews != null ? 1 : 0) +
(minEvents != null ? 1 : 0) +
(maxEvents != null ? 1 : 0);
const clearAll = useCallback(() => {
setReferrer('');
setCountry('');
setOs('');
setBrowser('');
setDevice('');
setEntryPage('');
setExitPage('');
setMinPageViews(null);
setMaxPageViews(null);
setMinEvents(null);
setMaxEvents(null);
}, [
setReferrer,
setCountry,
setOs,
setBrowser,
setDevice,
setEntryPage,
setExitPage,
setMinPageViews,
setMaxPageViews,
setMinEvents,
setMaxEvents,
]);
return {
referrer,
setReferrer,
country,
setCountry,
os,
setOs,
browser,
setBrowser,
device,
setDevice,
entryPage,
setEntryPage,
exitPage,
setExitPage,
minPageViews,
setMinPageViews,
maxPageViews,
setMaxPageViews,
minEvents,
setMinEvents,
maxEvents,
setMaxEvents,
filters,
values,
setValue,
activeCount,
clearAll,
};
}

View File

@@ -0,0 +1,139 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateGroup } from '@openpanel/validation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError, useTRPC } from '@/integrations/trpc/react';
const zForm = zCreateGroup.omit({ projectId: true, properties: true }).extend({
properties: z.array(z.object({ key: z.string(), value: z.string() })),
});
type IForm = z.infer<typeof zForm>;
export default function AddGroup() {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
resolver: zodResolver(zForm),
defaultValues: {
id: '',
type: '',
name: '',
properties: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'properties',
});
const mutation = useMutation(
trpc.group.create.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
queryClient.invalidateQueries(trpc.group.types.pathFilter());
toast('Success', { description: 'Group created.' });
popModal();
},
onError: handleError,
})
);
return (
<ModalContent>
<ModalHeader title="Add group" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ properties, ...values }) => {
const props = Object.fromEntries(
properties
.filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)])
);
mutation.mutate({ projectId, ...values, properties: props });
})}
>
<InputWithLabel
label="ID"
placeholder="acme-corp"
{...register('id')}
autoFocus
error={formState.errors.id?.message}
/>
<InputWithLabel
label="Name"
placeholder="Acme Corp"
{...register('name')}
error={formState.errors.name?.message}
/>
<InputWithLabel
label="Type"
placeholder="company"
{...register('type')}
error={formState.errors.type?.message}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Properties</span>
<Button
onClick={() => append({ key: '', value: '' })}
size="sm"
type="button"
variant="outline"
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div className="flex gap-2" key={field.id}>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key"
{...register(`properties.${index}.key`)}
/>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="value"
{...register(`properties.${index}.value`)}
/>
<Button
className="shrink-0"
onClick={() => remove(index)}
size="icon"
type="button"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
<ButtonContainer>
<Button onClick={() => popModal()} type="button" variant="outline">
Cancel
</Button>
<Button
disabled={!formState.isDirty || mutation.isPending}
type="submit"
>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,147 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceGroup } from '@openpanel/db';
import { zUpdateGroup } from '@openpanel/validation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { handleError, useTRPC } from '@/integrations/trpc/react';
const zForm = zUpdateGroup
.omit({ id: true, projectId: true, properties: true })
.extend({
properties: z.array(z.object({ key: z.string(), value: z.string() })),
});
type IForm = z.infer<typeof zForm>;
type EditGroupProps = Pick<
IServiceGroup,
'id' | 'projectId' | 'name' | 'type' | 'properties'
>;
export default function EditGroup({
id,
projectId,
name,
type,
properties,
}: EditGroupProps) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
resolver: zodResolver(zForm),
defaultValues: {
type,
name,
properties: Object.entries(properties as Record<string, string>).map(
([key, value]) => ({
key,
value: String(value),
})
),
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'properties',
});
const mutation = useMutation(
trpc.group.update.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
queryClient.invalidateQueries(trpc.group.byId.pathFilter());
queryClient.invalidateQueries(trpc.group.types.pathFilter());
toast('Success', { description: 'Group updated.' });
popModal();
},
onError: handleError,
})
);
return (
<ModalContent>
<ModalHeader title="Edit group" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ properties: formProps, ...values }) => {
const props = Object.fromEntries(
formProps
.filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)])
);
mutation.mutate({ id, projectId, ...values, properties: props });
})}
>
<InputWithLabel
label="Name"
{...register('name')}
error={formState.errors.name?.message}
/>
<InputWithLabel
label="Type"
{...register('type')}
error={formState.errors.type?.message}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Properties</span>
<Button
onClick={() => append({ key: '', value: '' })}
size="sm"
type="button"
variant="outline"
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div className="flex gap-2" key={field.id}>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key"
{...register(`properties.${index}.key`)}
/>
<input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="value"
{...register(`properties.${index}.value`)}
/>
<Button
className="shrink-0"
onClick={() => remove(index)}
size="icon"
type="button"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
<ButtonContainer>
<Button onClick={() => popModal()} type="button" variant="outline">
Cancel
</Button>
<Button
disabled={!formState.isDirty || mutation.isPending}
type="submit"
>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -1,7 +1,7 @@
import PageDetails from './page-details';
import { createPushModal } from 'pushmodal'; import { createPushModal } from 'pushmodal';
import AddClient from './add-client'; import AddClient from './add-client';
import AddDashboard from './add-dashboard'; import AddDashboard from './add-dashboard';
import AddGroup from './add-group';
import AddImport from './add-import'; import AddImport from './add-import';
import AddIntegration from './add-integration'; import AddIntegration from './add-integration';
import AddNotificationRule from './add-notification-rule'; import AddNotificationRule from './add-notification-rule';
@@ -16,6 +16,7 @@ import DateTimePicker from './date-time-picker';
import EditClient from './edit-client'; import EditClient from './edit-client';
import EditDashboard from './edit-dashboard'; import EditDashboard from './edit-dashboard';
import EditEvent from './edit-event'; import EditEvent from './edit-event';
import EditGroup from './edit-group';
import EditMember from './edit-member'; import EditMember from './edit-member';
import EditReference from './edit-reference'; import EditReference from './edit-reference';
import EditReport from './edit-report'; import EditReport from './edit-report';
@@ -23,6 +24,7 @@ import EventDetails from './event-details';
import Instructions from './Instructions'; import Instructions from './Instructions';
import OverviewChartDetails from './overview-chart-details'; import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters'; import OverviewFilters from './overview-filters';
import PageDetails from './page-details';
import RequestPasswordReset from './request-reset-password'; import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report'; import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan'; import SelectBillingPlan from './select-billing-plan';
@@ -36,6 +38,8 @@ import { op } from '@/utils/op';
const modals = { const modals = {
PageDetails, PageDetails,
AddGroup,
EditGroup,
OverviewTopPagesModal, OverviewTopPagesModal,
OverviewTopGenericModal, OverviewTopGenericModal,
RequestPasswordReset, RequestPasswordReset,

View File

@@ -281,9 +281,10 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
interface FunnelUsersViewProps { interface FunnelUsersViewProps {
report: IReportInput; report: IReportInput;
stepIndex: number; stepIndex: number;
breakdownValues?: string[];
} }
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) { function FunnelUsersView({ report, stepIndex, breakdownValues }: FunnelUsersViewProps) {
const trpc = useTRPC(); const trpc = useTRPC();
const [showDropoffs, setShowDropoffs] = useState(false); const [showDropoffs, setShowDropoffs] = useState(false);
@@ -306,6 +307,7 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
? report.options.funnelGroup ? report.options.funnelGroup
: undefined, : undefined,
breakdowns: report.breakdowns, breakdowns: report.breakdowns,
breakdownValues: breakdownValues,
}, },
{ {
enabled: stepIndex !== undefined, enabled: stepIndex !== undefined,
@@ -384,13 +386,14 @@ type ViewChartUsersProps =
type: 'funnel'; type: 'funnel';
report: IReportInput; report: IReportInput;
stepIndex: number; stepIndex: number;
breakdownValues?: string[];
}; };
// Main component that routes to the appropriate view // Main component that routes to the appropriate view
export default function ViewChartUsers(props: ViewChartUsersProps) { export default function ViewChartUsers(props: ViewChartUsersProps) {
if (props.type === 'funnel') { if (props.type === 'funnel') {
return ( return (
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} /> <FunnelUsersView report={props.report} stepIndex={props.stepIndex} breakdownValues={props.breakdownValues} />
); );
} }

View File

@@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime' import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages' import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights' import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups'
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards' import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat' import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index' import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
@@ -82,12 +83,16 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules' import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications' import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs'
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats' import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events' import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions' import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute( const AppOrganizationIdProfileRouteImport = createFileRoute(
'/_app/$organizationId/profile', '/_app/$organizationId/profile',
@@ -113,6 +118,9 @@ const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute(
const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute( const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId', '/_app/$organizationId/$projectId/profiles/$profileId',
)() )()
const AppOrganizationIdProjectIdGroupsGroupIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId',
)()
const UnsubscribeRoute = UnsubscribeRouteImport.update({ const UnsubscribeRoute = UnsubscribeRouteImport.update({
id: '/unsubscribe', id: '/unsubscribe',
@@ -350,6 +358,12 @@ const AppOrganizationIdProjectIdInsightsRoute =
path: '/insights', path: '/insights',
getParentRoute: () => AppOrganizationIdProjectIdRoute, getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsRoute =
AppOrganizationIdProjectIdGroupsRouteImport.update({
id: '/groups',
path: '/groups',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdDashboardsRoute = const AppOrganizationIdProjectIdDashboardsRoute =
AppOrganizationIdProjectIdDashboardsRouteImport.update({ AppOrganizationIdProjectIdDashboardsRouteImport.update({
id: '/dashboards', id: '/dashboards',
@@ -368,6 +382,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
path: '/$profileId', path: '/$profileId',
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute, getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
id: '/groups_/$groupId',
path: '/groups/$groupId',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProfileTabsIndexRoute = const AppOrganizationIdProfileTabsIndexRoute =
AppOrganizationIdProfileTabsIndexRouteImport.update({ AppOrganizationIdProfileTabsIndexRouteImport.update({
id: '/', id: '/',
@@ -555,6 +575,11 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute =
path: '/notifications', path: '/notifications',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute, getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdRoute,
} as any)
const AppOrganizationIdProjectIdEventsTabsStatsRoute = const AppOrganizationIdProjectIdEventsTabsStatsRoute =
AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({ AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({
id: '/stats', id: '/stats',
@@ -579,6 +604,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
path: '/', path: '/',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute, getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute = const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({ AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
id: '/sessions', id: '/sessions',
@@ -591,6 +622,18 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
path: '/events', path: '/events',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute, getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport.update({
id: '/members',
path: '/members',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport.update({
id: '/events',
path: '/events',
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -615,6 +658,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -646,6 +690,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute '/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute '/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren '/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
@@ -663,8 +708,11 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute '/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute '/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/$organizationId/$projectId/groups/$groupId/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute '/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -688,6 +736,7 @@ export interface FileRoutesByTo {
'/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -716,6 +765,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute '/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute '/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute '/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
@@ -729,6 +779,8 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute '/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute '/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
} }
@@ -760,6 +812,7 @@ export interface FileRoutesById {
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -798,6 +851,8 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute '/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute '/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute '/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren '/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
@@ -816,8 +871,11 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute '/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -845,6 +903,7 @@ export interface FileRouteTypes {
| '/$organizationId/' | '/$organizationId/'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/groups'
| '/$organizationId/$projectId/insights' | '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
@@ -876,6 +935,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats' | '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications/notifications' | '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules' | '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId' | '/$organizationId/$projectId/profiles/$profileId'
@@ -893,8 +953,11 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/notifications/' | '/$organizationId/$projectId/notifications/'
| '/$organizationId/$projectId/profiles/' | '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/' | '/$organizationId/$projectId/settings/'
| '/$organizationId/$projectId/groups/$groupId/events'
| '/$organizationId/$projectId/groups/$groupId/members'
| '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions' | '/$organizationId/$projectId/profiles/$profileId/sessions'
| '/$organizationId/$projectId/groups/$groupId/'
| '/$organizationId/$projectId/profiles/$profileId/' | '/$organizationId/$projectId/profiles/$profileId/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@@ -918,6 +981,7 @@ export interface FileRouteTypes {
| '/$organizationId' | '/$organizationId'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/groups'
| '/$organizationId/$projectId/insights' | '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
@@ -946,6 +1010,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats' | '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications/notifications' | '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules' | '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId' | '/$organizationId/$projectId/profiles/$profileId'
@@ -959,6 +1024,8 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking' | '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets' | '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/groups/$groupId/events'
| '/$organizationId/$projectId/groups/$groupId/members'
| '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions' | '/$organizationId/$projectId/profiles/$profileId/sessions'
id: id:
@@ -989,6 +1056,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/' | '/_app/$organizationId/'
| '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/chat'
| '/_app/$organizationId/$projectId/dashboards' | '/_app/$organizationId/$projectId/dashboards'
| '/_app/$organizationId/$projectId/groups'
| '/_app/$organizationId/$projectId/insights' | '/_app/$organizationId/$projectId/insights'
| '/_app/$organizationId/$projectId/pages' | '/_app/$organizationId/$projectId/pages'
| '/_app/$organizationId/$projectId/realtime' | '/_app/$organizationId/$projectId/realtime'
@@ -1027,6 +1095,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/events/_tabs/conversions' | '/_app/$organizationId/$projectId/events/_tabs/conversions'
| '/_app/$organizationId/$projectId/events/_tabs/events' | '/_app/$organizationId/$projectId/events/_tabs/events'
| '/_app/$organizationId/$projectId/events/_tabs/stats' | '/_app/$organizationId/$projectId/events/_tabs/stats'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications' | '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs/rules' | '/_app/$organizationId/$projectId/notifications/_tabs/rules'
| '/_app/$organizationId/$projectId/profiles/$profileId' | '/_app/$organizationId/$projectId/profiles/$profileId'
@@ -1045,8 +1115,11 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/notifications/_tabs/' | '/_app/$organizationId/$projectId/notifications/_tabs/'
| '/_app/$organizationId/$projectId/profiles/_tabs/' | '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/' | '/_app/$organizationId/$projectId/settings/_tabs/'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -1378,6 +1451,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute parentRoute: typeof AppOrganizationIdProjectIdRoute
} }
'/_app/$organizationId/$projectId/groups': {
id: '/_app/$organizationId/$projectId/groups'
path: '/groups'
fullPath: '/$organizationId/$projectId/groups'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/dashboards': { '/_app/$organizationId/$projectId/dashboards': {
id: '/_app/$organizationId/$projectId/dashboards' id: '/_app/$organizationId/$projectId/dashboards'
path: '/dashboards' path: '/dashboards'
@@ -1399,6 +1479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
} }
'/_app/$organizationId/$projectId/groups_/$groupId': {
id: '/_app/$organizationId/$projectId/groups_/$groupId'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/profile/_tabs/': { '/_app/$organizationId/profile/_tabs/': {
id: '/_app/$organizationId/profile/_tabs/' id: '/_app/$organizationId/profile/_tabs/'
path: '/' path: '/'
@@ -1623,6 +1710,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
} }
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
path: '/groups/$groupId'
fullPath: '/$organizationId/$projectId/groups/$groupId'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
}
'/_app/$organizationId/$projectId/events/_tabs/stats': { '/_app/$organizationId/$projectId/events/_tabs/stats': {
id: '/_app/$organizationId/$projectId/events/_tabs/stats' id: '/_app/$organizationId/$projectId/events/_tabs/stats'
path: '/stats' path: '/stats'
@@ -1651,6 +1745,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
} }
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
path: '/'
fullPath: '/$organizationId/$projectId/groups/$groupId/'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': { '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions' id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
path: '/sessions' path: '/sessions'
@@ -1665,6 +1766,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
} }
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
path: '/members'
fullPath: '/$organizationId/$projectId/groups/$groupId/members'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
path: '/events'
fullPath: '/$organizationId/$projectId/groups/$groupId/events'
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
}
} }
} }
@@ -1872,9 +1987,46 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
AppOrganizationIdProjectIdSettingsRouteChildren, AppOrganizationIdProjectIdSettingsRouteChildren,
) )
interface AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren {
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
}
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren =
{
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute,
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute,
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute,
}
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren =
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute._addFileChildren(
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren,
)
interface AppOrganizationIdProjectIdGroupsGroupIdRouteChildren {
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
}
const AppOrganizationIdProjectIdGroupsGroupIdRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdRouteChildren =
{
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute:
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren,
}
const AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren =
AppOrganizationIdProjectIdGroupsGroupIdRoute._addFileChildren(
AppOrganizationIdProjectIdGroupsGroupIdRouteChildren,
)
interface AppOrganizationIdProjectIdRouteChildren { interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -1890,6 +2042,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
} }
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren = const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
@@ -1897,6 +2050,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute, AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
AppOrganizationIdProjectIdDashboardsRoute: AppOrganizationIdProjectIdDashboardsRoute:
AppOrganizationIdProjectIdDashboardsRoute, AppOrganizationIdProjectIdDashboardsRoute,
AppOrganizationIdProjectIdGroupsRoute:
AppOrganizationIdProjectIdGroupsRoute,
AppOrganizationIdProjectIdInsightsRoute: AppOrganizationIdProjectIdInsightsRoute:
AppOrganizationIdProjectIdInsightsRoute, AppOrganizationIdProjectIdInsightsRoute,
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute, AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
@@ -1924,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdSessionsSessionIdRoute, AppOrganizationIdProjectIdSessionsSessionIdRoute,
AppOrganizationIdProjectIdSettingsRoute: AppOrganizationIdProjectIdSettingsRoute:
AppOrganizationIdProjectIdSettingsRouteWithChildren, AppOrganizationIdProjectIdSettingsRouteWithChildren,
AppOrganizationIdProjectIdGroupsGroupIdRoute:
AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren,
} }
const AppOrganizationIdProjectIdRouteWithChildren = const AppOrganizationIdProjectIdRouteWithChildren =

View File

@@ -42,5 +42,5 @@ function Component() {
), ),
); );
return <EventsTable query={query} />; return <EventsTable query={query} showEventListener />;
} }

View File

@@ -0,0 +1,100 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { PlusIcon } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { GroupsTable } from '@/components/groups/table';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { createProjectTitle } from '@/utils/title';
const PAGE_SIZE = 50;
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
{
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Groups') }],
}),
}
);
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const [typeFilter, setTypeFilter] = useQueryState(
'type',
parseAsString.withDefault('')
);
const { page } = useDataTablePagination(PAGE_SIZE);
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
const groupsQuery = useQuery(
trpc.group.list.queryOptions(
{
projectId,
search: debouncedSearch || undefined,
type: typeFilter || undefined,
take: PAGE_SIZE,
cursor: (page - 1) * PAGE_SIZE,
},
{ placeholderData: keepPreviousData }
)
);
const types = typesQuery.data ?? [];
return (
<PageContainer>
<PageHeader
actions={
<Button onClick={() => pushModal('AddGroup')}>
<PlusIcon className="mr-2 size-4" />
Add group
</Button>
}
className="mb-8"
description="Groups represent companies, teams, or other entities that events belong to."
title="Groups"
/>
<GroupsTable
pageSize={PAGE_SIZE}
query={groupsQuery}
toolbarLeft={
types.length > 0 ? (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
) : null
}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,46 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group events') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
groupId,
filters: [], // Always scope to group only; date + event names from toolbar still apply
startDate: startDate || undefined,
endDate: endDate || undefined,
events: eventNames,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
}
)
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,233 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { GroupMemberGrowth } from '@/components/groups/group-member-growth';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody, WidgetEmptyState } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
const MEMBERS_PREVIEW_LIMIT = 13;
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const mostEvents = useSuspenseQuery(
trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
);
const popularRoutes = useSuspenseQuery(
trpc.group.popularRoutes.queryOptions({ id: groupId, projectId })
);
const memberGrowth = useSuspenseQuery(
trpc.group.memberGrowth.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data;
if (!g) {
return null;
}
const properties = g.properties as Record<string, unknown>;
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Member growth */}
<div className="col-span-1">
<GroupMemberGrowth data={memberGrowth.data} />
</div>
{/* Top events */}
<div className="col-span-1">
<MostEvents data={mostEvents.data} />
</div>
{/* Popular routes */}
<div className="col-span-1">
<PopularRoutes data={popularRoutes.data} />
</div>
{/* Members preview */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{members.data.length === 0 ? (
<WidgetEmptyState icon={UsersIcon} text="No members yet" />
) : (
<WidgetTable
columnClassName="px-2"
columns={[
{
key: 'profile',
name: 'Profile',
width: 'w-full',
render: (member) => (
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
),
},
{
key: 'events',
name: 'Events',
width: '60px',
className: 'text-muted-foreground',
render: (member) => member.eventCount,
},
{
key: 'lastSeen',
name: 'Last Seen',
width: '150px',
className: 'text-muted-foreground',
render: (member) =>
formatTimeAgoOrDateTime(new Date(member.lastSeen)),
},
]}
data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)}
keyExtractor={(member) => member.profileId}
/>
)}
{members.data.length > MEMBERS_PREVIEW_LIMIT && (
<p className="border-t py-2 text-center text-muted-foreground text-xs">
{`${members.data.length} members found. View all in Members tab`}
</p>
)}
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group members') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const { page } = useDataTablePagination(50);
const query = useQuery({
...trpc.group.listProfiles.queryOptions({
projectId,
groupId,
cursor: (page - 1) * 50,
take: 50,
search: debouncedSearch || undefined,
}),
placeholderData: keepPreviousData,
});
return (
<ProfilesTable
pageSize={50}
query={query as Parameters<typeof ProfilesTable>[0]['query']}
type="profiles"
/>
);
}

View File

@@ -0,0 +1,161 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query';
import {
createFileRoute,
Outlet,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const router = useRouter();
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const navigate = useNavigate();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const deleteMutation = useMutation(
trpc.group.delete.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
navigate({
to: '/$organizationId/$projectId/groups',
params: { organizationId, projectId },
});
},
onError: handleError,
})
);
const { activeTab, tabs } = usePageTabs([
{ id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' },
{ id: 'members', label: 'Members' },
{ id: 'events', label: 'Events' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
const g = group.data;
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
return (
<PageContainer className="col">
<PageHeader
actions={
<div className="row gap-2">
<Button
onClick={() =>
pushModal('EditGroup', {
id: g.id,
projectId: g.projectId,
name: g.name,
type: g.type,
properties: g.properties,
})
}
size="sm"
variant="outline"
>
<PencilIcon className="mr-2 size-4" />
Edit
</Button>
<Button
onClick={() =>
showConfirm({
title: 'Delete group',
text: `Are you sure you want to delete "${g.name}"? This action cannot be undone.`,
onConfirm: () =>
deleteMutation.mutate({ id: g.id, projectId }),
})
}
size="sm"
variant="outline"
>
<Trash2Icon className="mr-2 size-4" />
Delete
</Button>
</div>
}
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
</div>
}
/>
<Tabs
className="mt-2 mb-8"
onValueChange={handleTabChange}
value={activeTab}
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes'; import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity'; import { ProfileActivity } from '@/components/profiles/profile-activity';
import { ProfileCharts } from '@/components/profiles/profile-charts'; import { ProfileCharts } from '@/components/profiles/profile-charts';
import { ProfileGroups } from '@/components/profiles/profile-groups';
import { ProfileMetrics } from '@/components/profiles/profile-metrics'; import { ProfileMetrics } from '@/components/profiles/profile-metrics';
import { ProfileProperties } from '@/components/profiles/profile-properties'; import { ProfileProperties } from '@/components/profiles/profile-properties';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
@@ -103,8 +104,15 @@ function Component() {
<ProfileMetrics data={metrics.data} /> <ProfileMetrics data={metrics.data} />
</div> </div>
{/* Profile properties - full width */} {/* Profile properties - full width */}
<div className="col-span-1 md:col-span-2"> <div className="col-span-1 flex flex-col gap-3 md:col-span-2">
<ProfileProperties profile={profile.data!} /> <ProfileProperties profile={profile.data!} />
{profile.data?.groups?.length ? (
<ProfileGroups
profileId={profileId}
projectId={projectId}
groups={profile.data.groups}
/>
) : null}
</div> </div>
{/* Heatmap / Activity */} {/* Heatmap / Activity */}

View File

@@ -1,3 +1,5 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle'; import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
import RealtimeMap from '@/components/realtime/map'; import RealtimeMap from '@/components/realtime/map';
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions'; import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
@@ -7,12 +9,10 @@ import { RealtimePaths } from '@/components/realtime/realtime-paths';
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals'; import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
import RealtimeReloader from '@/components/realtime/realtime-reloader'; import RealtimeReloader from '@/components/realtime/realtime-reloader';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/realtime', '/_app/$organizationId/$projectId/realtime'
)({ )({
component: Component, component: Component,
head: () => { head: () => {
@@ -36,8 +36,8 @@ function Component() {
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}, }
), )
); );
return ( return (
@@ -47,7 +47,7 @@ function Component() {
<RealtimeReloader projectId={projectId} /> <RealtimeReloader projectId={projectId} />
<div className="row relative"> <div className="row relative">
<div className="overflow-hidden aspect-[4/2] w-full"> <div className="aspect-[4/2] w-full overflow-hidden">
<RealtimeMap <RealtimeMap
markers={coordinatesQuery.data ?? []} markers={coordinatesQuery.data ?? []}
sidebarConfig={{ sidebarConfig={{
@@ -56,18 +56,17 @@ function Component() {
}} }}
/> />
</div> </div>
<div className="absolute top-8 left-8 bottom-0 col gap-4"> <div className="col absolute top-8 bottom-4 left-8 gap-4">
<div className="card p-4 w-72 bg-background/90"> <div className="card w-72 bg-background/90 p-4">
<RealtimeLiveHistogram projectId={projectId} /> <RealtimeLiveHistogram projectId={projectId} />
</div> </div>
<div className="w-72 flex-1 min-h-0 relative"> <div className="relative min-h-0 w-72 flex-1">
<RealtimeActiveSessions projectId={projectId} /> <RealtimeActiveSessions projectId={projectId} />
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-def-100 to-transparent" />
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4 pt-4 md:p-8 md:pt-0"> <div className="grid grid-cols-1 gap-4 p-4 pt-4 md:grid-cols-2 md:p-8 md:pt-0 xl:grid-cols-3">
<div> <div>
<RealtimeGeo projectId={projectId} /> <RealtimeGeo projectId={projectId} />
</div> </div>

View File

@@ -1,19 +1,15 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { SessionsTable } from '@/components/sessions/table'; import { SessionsTable } from '@/components/sessions/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state'; import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useSessionFilters } from '@/hooks/use-session-filters';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/sessions', '/_app/$organizationId/$projectId/sessions'
)({ )({
component: Component, component: Component,
head: () => { head: () => {
@@ -31,6 +27,8 @@ function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState(); const { debouncedSearch } = useSearchQueryState();
const { filters, minPageViews, maxPageViews, minEvents, maxEvents } =
useSessionFilters();
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.session.list.infiniteQueryOptions( trpc.session.list.infiniteQueryOptions(
@@ -38,19 +36,24 @@ function Component() {
projectId, projectId,
take: 50, take: 50,
search: debouncedSearch, search: debouncedSearch,
filters,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
}, },
{ {
getNextPageParam: (lastPage) => lastPage.meta.next, getNextPageParam: (lastPage) => lastPage.meta.next,
}, }
), )
); );
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Sessions"
description="Access all your sessions here"
className="mb-8" className="mb-8"
description="Access all your sessions here"
title="Sessions"
/> />
<SessionsTable query={query} /> <SessionsTable query={query} />
</PageContainer> </PageContainer>

View File

@@ -1,5 +1,5 @@
import type { IServiceEvent, IServiceSession } from '@openpanel/db'; import type { IServiceEvent, IServiceSession } from '@openpanel/db';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery, useQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router'; import { createFileRoute, Link } from '@tanstack/react-router';
import { EventIcon } from '@/components/events/event-icon'; import { EventIcon } from '@/components/events/event-icon';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
@@ -165,6 +165,14 @@ function Component() {
}) })
); );
const { data: sessionGroups } = useQuery({
...trpc.group.listByIds.queryOptions({
projectId,
ids: session.groups ?? [],
}),
enabled: (session.groups?.length ?? 0) > 0,
});
const fakeEvent = sessionToFakeEvent(session); const fakeEvent = sessionToFakeEvent(session);
return ( return (
@@ -324,6 +332,35 @@ function Component() {
</Widget> </Widget>
)} )}
{/* Group cards */}
{sessionGroups && sessionGroups.length > 0 && (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Groups</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{sessionGroups.map((group) => (
<Link
key={group.id}
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
<div className="col min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">{group.name}</span>
<span className="truncate text-muted-foreground text-sm font-mono">
{group.id}
</span>
</div>
<span className="shrink-0 rounded border px-1.5 py-0.5 text-muted-foreground text-xs">
{group.type}
</span>
</Link>
))}
</WidgetBody>
</Widget>
)}
{/* Visited pages */} {/* Visited pages */}
<VisitedRoutes <VisitedRoutes
paths={events paths={events

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { createFileRoute, Link, redirect } from '@tanstack/react-router'; import { createFileRoute, Link, redirect } from '@tanstack/react-router';
import { BoxSelectIcon } from 'lucide-react'; import { BoxSelectIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { ButtonContainer } from '@/components/button-container'; import { ButtonContainer } from '@/components/button-container';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
@@ -33,22 +32,21 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
}); });
function Component() { function Component() {
const [isVerified, setIsVerified] = useState(false);
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const { data: events, refetch } = useQuery( const { data: events } = useQuery(
trpc.event.events.queryOptions({ projectId }) trpc.event.events.queryOptions(
{ projectId },
{
refetchInterval: 2500,
}
)
); );
const isVerified = events?.data && events.data.length > 0;
const { data: project } = useQuery( const { data: project } = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId }) trpc.project.getProjectWithClients.queryOptions({ projectId })
); );
useEffect(() => {
if (events && events.data.length > 0) {
setIsVerified(true);
}
}, [events]);
if (!project) { if (!project) {
return ( return (
<FullPageEmptyState icon={BoxSelectIcon} title="Project not found" /> <FullPageEmptyState icon={BoxSelectIcon} title="Project not found" />
@@ -64,15 +62,7 @@ function Component() {
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="scrollbar-thin flex-1 overflow-y-auto"> <div className="scrollbar-thin flex-1 overflow-y-auto">
<div className="col gap-8 p-4"> <div className="col gap-8 p-4">
<VerifyListener <VerifyListener events={events?.data ?? []} />
client={client}
events={events?.data ?? []}
onVerified={() => {
refetch();
setIsVerified(true);
}}
project={project}
/>
<VerifyFaq project={project} /> <VerifyFaq project={project} />
</div> </div>

View File

@@ -10,7 +10,7 @@ const BASE_TITLE = 'OpenPanel.dev';
export function createTitle( export function createTitle(
pageTitle: string, pageTitle: string,
section?: string, section?: string,
baseTitle = BASE_TITLE, baseTitle = BASE_TITLE
): string { ): string {
const parts = [pageTitle]; const parts = [pageTitle];
if (section) { if (section) {
@@ -25,7 +25,7 @@ export function createTitle(
*/ */
export function createOrganizationTitle( export function createOrganizationTitle(
pageTitle: string, pageTitle: string,
organizationName?: string, organizationName?: string
): string { ): string {
if (organizationName) { if (organizationName) {
return createTitle(pageTitle, organizationName); return createTitle(pageTitle, organizationName);
@@ -39,7 +39,7 @@ export function createOrganizationTitle(
export function createProjectTitle( export function createProjectTitle(
pageTitle: string, pageTitle: string,
projectName?: string, projectName?: string,
organizationName?: string, organizationName?: string
): string { ): string {
const parts = [pageTitle]; const parts = [pageTitle];
if (projectName) { if (projectName) {
@@ -59,7 +59,7 @@ export function createEntityTitle(
entityName: string, entityName: string,
entityType: string, entityType: string,
projectName?: string, projectName?: string,
organizationName?: string, organizationName?: string
): string { ): string {
const parts = [entityName, entityType]; const parts = [entityName, entityType];
if (projectName) { if (projectName) {
@@ -95,6 +95,9 @@ export const PAGE_TITLES = {
PROFILES: 'Profiles', PROFILES: 'Profiles',
PROFILE_EVENTS: 'Profile events', PROFILE_EVENTS: 'Profile events',
PROFILE_DETAILS: 'Profile details', PROFILE_DETAILS: 'Profile details',
// Groups
GROUPS: 'Groups',
GROUP_DETAILS: 'Group details',
// Sub-sections // Sub-sections
CONVERSIONS: 'Conversions', CONVERSIONS: 'Conversions',

4
apps/testbed/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
public/op1.js
.env

12
apps/testbed/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testbed | OpenPanel SDK</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
apps/testbed/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@openpanel/testbed",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3100",
"build": "tsc && vite build",
"postinstall": "node scripts/copy-op1.mjs"
},
"dependencies": {
"@openpanel/web": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "catalog:",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,16 @@
import { copyFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const src = join(__dirname, '../../public/public/op1.js');
const dest = join(__dirname, '../public/op1.js');
mkdirSync(join(__dirname, '../public'), { recursive: true });
try {
copyFileSync(src, dest);
console.log('✓ Copied op1.js to public/');
} catch (e) {
console.warn('⚠ Could not copy op1.js:', e.message);
}

217
apps/testbed/src/App.tsx Normal file
View File

@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react';
import { Link, Route, Routes, useNavigate } from 'react-router-dom';
import { op } from './analytics';
import { CartPage } from './pages/Cart';
import { CheckoutPage } from './pages/Checkout';
import { LoginPage, PRESET_GROUPS } from './pages/Login';
import { ProductPage } from './pages/Product';
import { ShopPage } from './pages/Shop';
import type { CartItem, Product, User } from './types';
const PRODUCTS: Product[] = [
{ id: 'p1', name: 'Classic T-Shirt', price: 25, category: 'clothing' },
{ id: 'p2', name: 'Coffee Mug', price: 15, category: 'accessories' },
{ id: 'p3', name: 'Hoodie', price: 60, category: 'clothing' },
{ id: 'p4', name: 'Sticker Pack', price: 10, category: 'accessories' },
{ id: 'p5', name: 'Cap', price: 35, category: 'clothing' },
];
export default function App() {
const navigate = useNavigate();
const [cart, setCart] = useState<CartItem[]>([]);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const stored = localStorage.getItem('op_testbed_user');
if (stored) {
const u = JSON.parse(stored) as User;
setUser(u);
op.identify({
profileId: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
});
applyGroups(u);
}
op.ready();
}, []);
function applyGroups(u: User) {
op.setGroups(u.groupIds);
for (const id of u.groupIds) {
const meta = PRESET_GROUPS.find((g) => g.id === id);
if (meta) {
op.upsertGroup({ id, ...meta });
}
}
}
function login(u: User) {
localStorage.setItem('op_testbed_user', JSON.stringify(u));
setUser(u);
op.identify({
profileId: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
});
applyGroups(u);
op.track('user_login', { method: 'form', group_count: u.groupIds.length });
navigate('/');
}
function logout() {
localStorage.removeItem('op_testbed_user');
op.clear();
setUser(null);
}
function addToCart(product: Product) {
setCart((prev) => {
const existing = prev.find((i) => i.id === product.id);
if (existing) {
return prev.map((i) =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...prev, { ...product, qty: 1 }];
});
op.track('add_to_cart', {
product_id: product.id,
product_name: product.name,
price: product.price,
category: product.category,
});
}
function removeFromCart(id: string) {
const item = cart.find((i) => i.id === id);
if (item) {
op.track('remove_from_cart', {
product_id: item.id,
product_name: item.name,
});
}
setCart((prev) => prev.filter((i) => i.id !== id));
}
function startCheckout() {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
op.track('checkout_started', {
total,
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
items: cart.map((i) => i.id),
});
navigate('/checkout');
}
function pay(succeed: boolean) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
op.track('payment_attempted', { total, success: succeed });
if (succeed) {
op.revenue(total, {
items: cart.map((i) => i.id),
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
});
op.track('purchase_completed', { total });
setCart([]);
navigate('/success');
} else {
op.track('purchase_failed', { total, reason: 'declined' });
navigate('/error');
}
}
const cartCount = cart.reduce((sum, i) => sum + i.qty, 0);
return (
<div className="app">
<nav className="nav">
<Link className="nav-brand" to="/">
TESTSTORE
</Link>
<div className="nav-links">
<Link to="/">Shop</Link>
<Link to="/cart">Cart ({cartCount})</Link>
{user ? (
<>
<span className="nav-user">{user.firstName}</span>
<button onClick={logout} type="button">
Logout
</button>
</>
) : (
<Link to="/login">Login</Link>
)}
</div>
</nav>
<main className="main">
<Routes>
<Route
element={<ShopPage onAddToCart={addToCart} products={PRODUCTS} />}
path="/"
/>
<Route
element={
<ProductPage onAddToCart={addToCart} products={PRODUCTS} />
}
path="/product/:id"
/>
<Route element={<LoginPage onLogin={login} />} path="/login" />
<Route
element={
<CartPage
cart={cart}
onCheckout={startCheckout}
onRemove={removeFromCart}
/>
}
path="/cart"
/>
<Route
element={<CheckoutPage cart={cart} onPay={pay} />}
path="/checkout"
/>
<Route
element={
<div className="result-page">
<div className="result-icon">[OK]</div>
<div className="result-title">Payment successful</div>
<p>Your order has been placed. Thanks for testing!</p>
<div className="result-actions">
<Link to="/">
<button className="primary" type="button">
Continue shopping
</button>
</Link>
</div>
</div>
}
path="/success"
/>
<Route
element={
<div className="result-page">
<div className="result-icon">[ERR]</div>
<div className="result-title">Payment failed</div>
<p>Card declined. Try again or go back to cart.</p>
<div className="result-actions">
<Link to="/checkout">
<button type="button">Retry</button>
</Link>
<Link to="/cart">
<button type="button">Back to cart</button>
</Link>
</div>
</div>
}
path="/error"
/>
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID ?? 'testbed-client',
apiUrl: import.meta.env.VITE_OPENPANEL_API_URL ?? 'http://localhost:3333',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
disabled: true,
});

10
apps/testbed/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
);

View File

@@ -0,0 +1,63 @@
import { Link } from 'react-router-dom';
import type { CartItem } from '../types';
type Props = {
cart: CartItem[];
onRemove: (id: string) => void;
onCheckout: () => void;
};
export function CartPage({ cart, onRemove, onCheckout }: Props) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
if (cart.length === 0) {
return (
<div>
<div className="page-title">Cart</div>
<div className="cart-empty">Your cart is empty.</div>
<Link to="/"><button type="button"> Back to shop</button></Link>
</div>
);
}
return (
<div>
<div className="page-title">Cart</div>
<table className="cart-table">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Qty</th>
<th>Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
{cart.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>${item.price}</td>
<td>{item.qty}</td>
<td>${item.price * item.qty}</td>
<td>
<button type="button" className="danger" onClick={() => onRemove(item.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="cart-summary">
<div className="cart-total">Total: ${total}</div>
<div className="cart-actions">
<Link to="/"><button type="button"> Shop</button></Link>
<button type="button" className="primary" onClick={onCheckout}>
Checkout
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Link } from 'react-router-dom';
import type { CartItem } from '../types';
type Props = {
cart: CartItem[];
onPay: (succeed: boolean) => void;
};
export function CheckoutPage({ cart, onPay }: Props) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<div>
<div className="page-title">Checkout</div>
<div className="checkout-form">
<div className="form-group">
<label className="form-label" htmlFor="card">Card number</label>
<input id="card" defaultValue="4242 4242 4242 4242" readOnly />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div className="form-group">
<label className="form-label" htmlFor="expiry">Expiry</label>
<input id="expiry" defaultValue="12/28" readOnly />
</div>
<div className="form-group">
<label className="form-label" htmlFor="cvc">CVC</label>
<input id="cvc" defaultValue="123" readOnly />
</div>
</div>
<div className="checkout-total">Total: ${total}</div>
<div className="checkout-pay-buttons">
<Link to="/cart"><button type="button"> Back</button></Link>
<button type="button" className="primary" onClick={() => onPay(true)}>
Pay ${total} (success)
</button>
<button type="button" className="danger" onClick={() => onPay(false)}>
Pay ${total} (fail)
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useState } from 'react';
import type { Group, User } from '../types';
export const PRESET_GROUPS: Group[] = [
{
type: 'company',
id: 'grp_acme',
name: 'Acme Corp',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_globex',
name: 'Globex',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_initech',
name: 'Initech',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_umbrella',
name: 'Umbrella Ltd',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_stark',
name: 'Stark Industries',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_wayne',
name: 'Wayne Enterprises',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_dunder',
name: 'Dunder Mifflin',
properties: { plan: 'free' },
},
{
type: 'company',
id: 'grp_pied',
name: 'Pied Piper',
properties: { plan: 'free' },
},
{
type: 'company',
id: 'grp_hooli',
name: 'Hooli',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_vandelay',
name: 'Vandelay Industries',
properties: { plan: 'free' },
},
];
const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack'];
const LAST_NAMES = ['Smith', 'Jones', 'Brown', 'Taylor', 'Wilson', 'Davis', 'Clark', 'Hall', 'Lewis', 'Young'];
function randomMock(): User {
const first = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
const last = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
const id = Math.random().toString(36).slice(2, 8);
return {
id: `usr_${id}`,
firstName: first,
lastName: last,
email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
groupIds: [],
};
}
type Props = {
onLogin: (user: User) => void;
};
export function LoginPage({ onLogin }: Props) {
const [form, setForm] = useState<User>(randomMock);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onLogin(form);
}
function set(field: keyof User, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
function toggleGroup(id: string) {
setForm((prev) => ({
...prev,
groupIds: prev.groupIds.includes(id)
? prev.groupIds.filter((g) => g !== id)
: [...prev.groupIds, id],
}));
}
return (
<div>
<div className="page-title">Login</div>
<form className="login-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="id">
User ID
</label>
<input
id="id"
onChange={(e) => set('id', e.target.value)}
required
value={form.id}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="firstName">
First name
</label>
<input
id="firstName"
onChange={(e) => set('firstName', e.target.value)}
required
value={form.firstName}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="lastName">
Last name
</label>
<input
id="lastName"
onChange={(e) => set('lastName', e.target.value)}
required
value={form.lastName}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email
</label>
<input
id="email"
onChange={(e) => set('email', e.target.value)}
required
type="email"
value={form.email}
/>
</div>
<div className="form-group">
<div className="form-label" style={{ marginBottom: 8 }}>
Groups (optional)
</div>
<div className="group-picker">
{PRESET_GROUPS.map((group) => {
const selected = form.groupIds.includes(group.id);
return (
<button
className={selected ? 'primary' : ''}
key={group.id}
onClick={() => toggleGroup(group.id)}
type="button"
>
{group.name}
<span className="group-plan">{group.plan}</span>
</button>
);
})}
</div>
</div>
<button className="primary" style={{ width: '100%' }} type="submit">
Login
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { op } from '../analytics';
import type { Product } from '../types';
type Props = {
products: Product[];
onAddToCart: (product: Product) => void;
};
export function ProductPage({ products, onAddToCart }: Props) {
const { id } = useParams<{ id: string }>();
const product = products.find((p) => p.id === id);
useEffect(() => {
if (product) {
op.track('product_viewed', {
product_id: product.id,
product_name: product.name,
price: product.price,
category: product.category,
});
}
}, [product]);
if (!product) {
return (
<div>
<div className="page-title">Product not found</div>
<Link to="/"><button type="button"> Back to shop</button></Link>
</div>
);
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Link to="/"> Back to shop</Link>
</div>
<div className="product-detail">
<div className="product-detail-img">[img]</div>
<div className="product-detail-info">
<div className="product-card-category">{product.category}</div>
<div className="product-detail-name">{product.name}</div>
<div className="product-detail-price">${product.price}</div>
<p className="product-detail-desc">
A high quality {product.name.toLowerCase()} for testing purposes.
Lorem ipsum dolor sit amet consectetur adipiscing elit.
</p>
<button
type="button"
className="primary"
onClick={() => onAddToCart(product)}
>
Add to cart
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import type { Product } from '../types';
type Props = {
products: Product[];
onAddToCart: (product: Product) => void;
};
export function ShopPage({ products, onAddToCart }: Props) {
return (
<div>
<div className="page-title">Products</div>
<div className="product-grid">
{products.map((product) => (
<div key={product.id} className="product-card">
<div className="product-card-category">{product.category}</div>
<Link to={`/product/${product.id}`} className="product-card-name">
{product.name}
</Link>
<div className="product-card-price">${product.price}</div>
<div className="product-card-actions">
<button
type="button"
className="primary"
style={{ width: '100%' }}
onClick={() => onAddToCart(product)}
>
Add to cart
</button>
</div>
</div>
))}
</div>
</div>
);
}

358
apps/testbed/src/styles.css Normal file
View File

@@ -0,0 +1,358 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--border: 1px solid #999;
--bg: #f5f5f5;
--surface: #fff;
--text: #111;
--muted: #666;
--accent: #1a1a1a;
--gap: 16px;
}
body {
font-family: monospace;
font-size: 14px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
button, input, select {
font-family: monospace;
font-size: 14px;
}
button {
cursor: pointer;
border: var(--border);
background: var(--surface);
padding: 6px 14px;
}
button:hover {
background: var(--accent);
color: #fff;
}
button.primary {
background: var(--accent);
color: #fff;
}
button.primary:hover {
opacity: 0.85;
}
button.danger {
border-color: #c00;
color: #c00;
}
button.danger:hover {
background: #c00;
color: #fff;
}
input {
border: var(--border);
background: var(--surface);
padding: 6px 10px;
width: 100%;
}
input:focus {
outline: 2px solid var(--accent);
}
/* Layout */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.nav {
border-bottom: var(--border);
padding: 12px var(--gap);
background: var(--surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
}
.nav-brand {
font-weight: bold;
font-size: 16px;
cursor: pointer;
letter-spacing: 1px;
text-decoration: none;
color: inherit;
}
.nav-links a {
color: inherit;
text-decoration: underline;
}
.nav-links a:hover {
color: var(--muted);
}
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.nav-links span {
cursor: default;
}
.nav-user {
text-decoration: none !important;
cursor: default !important;
color: var(--muted);
}
.main {
flex: 1;
padding: var(--gap);
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* Page common */
.page-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: var(--border);
}
/* Shop */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--gap);
}
.product-card {
border: var(--border);
background: var(--surface);
padding: var(--gap);
display: flex;
flex-direction: column;
gap: 8px;
}
.product-card-name {
font-weight: bold;
}
.product-card-category {
color: var(--muted);
font-size: 12px;
}
.product-card-price {
font-size: 16px;
}
.product-card-actions {
margin-top: auto;
}
/* Cart */
.cart-empty {
color: var(--muted);
padding: 40px 0;
text-align: center;
}
.cart-table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--gap);
}
.cart-table th,
.cart-table td {
border: var(--border);
padding: 8px 12px;
text-align: left;
}
.cart-table th {
background: var(--bg);
}
.cart-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-top: var(--border);
margin-top: 8px;
}
.cart-total {
font-size: 16px;
font-weight: bold;
}
.cart-actions {
display: flex;
gap: 8px;
}
/* Checkout */
.checkout-form {
border: var(--border);
background: var(--surface);
padding: var(--gap);
max-width: 400px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.form-label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
.checkout-total {
margin: 16px 0;
padding: 12px;
border: var(--border);
background: var(--bg);
font-weight: bold;
}
.checkout-pay-buttons {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* Login */
.login-form {
border: var(--border);
background: var(--surface);
padding: var(--gap);
max-width: 360px;
}
/* Product detail */
.product-detail {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
max-width: 700px;
}
.product-detail-img {
border: var(--border);
background: var(--surface);
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: var(--muted);
}
.product-detail-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-detail-name {
font-size: 22px;
font-weight: bold;
}
.product-detail-price {
font-size: 20px;
}
.product-detail-desc {
color: var(--muted);
line-height: 1.6;
}
.product-card-name {
font-weight: bold;
color: inherit;
}
/* Group picker */
.group-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.group-picker button {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 13px;
}
.group-plan {
font-size: 11px;
opacity: 0.6;
border-left: 1px solid currentColor;
padding-left: 6px;
}
/* Result pages */
.result-page {
text-align: center;
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.result-icon {
font-size: 48px;
line-height: 1;
}
.result-title {
font-size: 22px;
font-weight: bold;
}
.result-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}

23
apps/testbed/src/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export type Product = {
id: string;
name: string;
price: number;
category: string;
};
export type CartItem = Product & { qty: number };
export type User = {
id: string;
firstName: string;
lastName: string;
email: string;
groupIds: string[];
};
export type Group = {
id: string;
name: string;
type: string;
properties: Record<string, string>;
};

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
define: {
'process.env': {},
},
});

View File

@@ -16,11 +16,11 @@
"@openpanel/common": "workspace:*", "@openpanel/common": "workspace:*",
"@openpanel/db": "workspace:*", "@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*", "@openpanel/email": "workspace:*",
"@openpanel/importer": "workspace:*",
"@openpanel/integrations": "workspace:^", "@openpanel/integrations": "workspace:^",
"@openpanel/js-runtime": "workspace:*", "@openpanel/js-runtime": "workspace:*",
"@openpanel/json": "workspace:*", "@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*", "@openpanel/logger": "workspace:*",
"@openpanel/importer": "workspace:*",
"@openpanel/payments": "workspace:*", "@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*", "@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*", "@openpanel/redis": "workspace:*",

View File

@@ -68,6 +68,11 @@ export async function bootCron() {
type: 'flushReplay', type: 'flushReplay',
pattern: 1000 * 10, pattern: 1000 * 10,
}, },
{
name: 'flush',
type: 'flushGroups',
pattern: 1000 * 10,
},
{ {
name: 'insightsDaily', name: 'insightsDaily',
type: 'insightsDaily', type: 'insightsDaily',

View File

@@ -1,10 +1,9 @@
import type { Queue, WorkerOptions } from 'bullmq'; import { performance } from 'node:perf_hooks';
import { Worker } from 'bullmq'; import { setTimeout as sleep } from 'node:timers/promises';
import { import {
cronQueue,
EVENTS_GROUP_QUEUES_SHARDS, EVENTS_GROUP_QUEUES_SHARDS,
type EventsQueuePayloadIncomingEvent, type EventsQueuePayloadIncomingEvent,
cronQueue,
eventsGroupQueues, eventsGroupQueues,
gscQueue, gscQueue,
importQueue, importQueue,
@@ -15,14 +14,12 @@ import {
sessionsQueue, sessionsQueue,
} from '@openpanel/queue'; } from '@openpanel/queue';
import { getRedisQueue } from '@openpanel/redis'; import { getRedisQueue } from '@openpanel/redis';
import type { Queue, WorkerOptions } from 'bullmq';
import { performance } from 'node:perf_hooks'; import { Worker } from 'bullmq';
import { setTimeout as sleep } from 'node:timers/promises';
import { Worker as GroupWorker } from 'groupmq'; import { Worker as GroupWorker } from 'groupmq';
import { cronJob } from './jobs/cron'; import { cronJob } from './jobs/cron';
import { gscJob } from './jobs/gsc';
import { incomingEvent } from './jobs/events.incoming-event'; import { incomingEvent } from './jobs/events.incoming-event';
import { gscJob } from './jobs/gsc';
import { importJob } from './jobs/import'; import { importJob } from './jobs/import';
import { insightsProjectJob } from './jobs/insights'; import { insightsProjectJob } from './jobs/insights';
import { miscJob } from './jobs/misc'; import { miscJob } from './jobs/misc';
@@ -95,7 +92,7 @@ function getConcurrencyFor(queueName: string, defaultValue = 1): number {
return defaultValue; return defaultValue;
} }
export async function bootWorkers() { export function bootWorkers() {
const enabledQueues = getEnabledQueues(); const enabledQueues = getEnabledQueues();
const workers: (Worker | GroupWorker<any>)[] = []; const workers: (Worker | GroupWorker<any>)[] = [];
@@ -119,12 +116,14 @@ export async function bootWorkers() {
for (const index of eventQueuesToStart) { for (const index of eventQueuesToStart) {
const queue = eventsGroupQueues[index]; const queue = eventsGroupQueues[index];
if (!queue) continue; if (!queue) {
continue;
}
const queueName = `events_${index}`; const queueName = `events_${index}`;
const concurrency = getConcurrencyFor( const concurrency = getConcurrencyFor(
queueName, queueName,
Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10), Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10)
); );
const worker = new GroupWorker<EventsQueuePayloadIncomingEvent['payload']>({ const worker = new GroupWorker<EventsQueuePayloadIncomingEvent['payload']>({
@@ -132,7 +131,7 @@ export async function bootWorkers() {
concurrency, concurrency,
logger: process.env.NODE_ENV === 'production' ? queueLogger : undefined, logger: process.env.NODE_ENV === 'production' ? queueLogger : undefined,
blockingTimeoutSec: Number.parseFloat( blockingTimeoutSec: Number.parseFloat(
process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1', process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1'
), ),
handler: async (job) => { handler: async (job) => {
return await incomingEvent(job.data); return await incomingEvent(job.data);
@@ -172,7 +171,7 @@ export async function bootWorkers() {
const notificationWorker = new Worker( const notificationWorker = new Worker(
notificationQueue.name, notificationQueue.name,
notificationJob, notificationJob,
{ ...workerOptions, concurrency }, { ...workerOptions, concurrency }
); );
workers.push(notificationWorker); workers.push(notificationWorker);
logger.info('Started worker for notification', { concurrency }); logger.info('Started worker for notification', { concurrency });
@@ -224,7 +223,7 @@ export async function bootWorkers() {
if (workers.length === 0) { if (workers.length === 0) {
logger.warn( logger.warn(
'No workers started. Check ENABLED_QUEUES environment variable.', 'No workers started. Check ENABLED_QUEUES environment variable.'
); );
} }
@@ -254,7 +253,7 @@ export async function bootWorkers() {
const elapsed = job.finishedOn - job.processedOn; const elapsed = job.finishedOn - job.processedOn;
eventsGroupJobDuration.observe( eventsGroupJobDuration.observe(
{ name: worker.name, status: 'failed' }, { name: worker.name, status: 'failed' },
elapsed, elapsed
); );
} }
logger.error('job failed', { logger.error('job failed', {
@@ -267,23 +266,6 @@ export async function bootWorkers() {
} }
}); });
(worker as Worker).on('completed', (job) => {
if (job) {
if (job.processedOn && job.finishedOn) {
const elapsed = job.finishedOn - job.processedOn;
logger.info('job completed', {
jobId: job.id,
worker: worker.name,
elapsed,
});
eventsGroupJobDuration.observe(
{ name: worker.name, status: 'success' },
elapsed,
);
}
}
});
(worker as Worker).on('ioredis:close', () => { (worker as Worker).on('ioredis:close', () => {
logger.error('worker closed due to ioredis:close', { logger.error('worker closed due to ioredis:close', {
worker: worker.name, worker: worker.name,
@@ -293,7 +275,7 @@ export async function bootWorkers() {
async function exitHandler( async function exitHandler(
eventName: string, eventName: string,
evtOrExitCodeOrError: number | string | Error, evtOrExitCodeOrError: number | string | Error
) { ) {
// Log the actual error details for unhandled rejections/exceptions // Log the actual error details for unhandled rejections/exceptions
if (evtOrExitCodeOrError instanceof Error) { if (evtOrExitCodeOrError instanceof Error) {
@@ -339,7 +321,7 @@ export async function bootWorkers() {
process.on(evt, (code) => { process.on(evt, (code) => {
exitHandler(evt, code); exitHandler(evt, code);
}); });
}, }
); );
return workers; return workers;

View File

@@ -33,7 +33,7 @@ async function generateNewSalt() {
return created; return created;
}); });
getSalts.clear(); await getSalts.clear();
return newSalt; return newSalt;
} }

View File

@@ -1,6 +1,6 @@
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db'; import { eventBuffer, groupBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue'; import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects'; import { jobdeleteProjects } from './cron.delete-projects';
@@ -30,6 +30,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'flushReplay': { case 'flushReplay': {
return await replayBuffer.tryFlush(); return await replayBuffer.tryFlush();
} }
case 'flushGroups': {
return await groupBuffer.tryFlush();
}
case 'ping': { case 'ping': {
return await ping(); return await ping();
} }

View File

@@ -1,9 +1,5 @@
import { getTime, isSameDomain, parsePath } from '@openpanel/common'; import { getTime, isSameDomain, parsePath } from '@openpanel/common';
import { import { getReferrerWithQuery, parseReferrer } from '@openpanel/common/server';
getReferrerWithQuery,
parseReferrer,
parseUserAgent,
} from '@openpanel/common/server';
import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db'; import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import { import {
checkNotificationRulesForEvent, checkNotificationRulesForEvent,
@@ -14,10 +10,12 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import type { ILogger } from '@openpanel/logger'; import type { ILogger } from '@openpanel/logger';
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue'; import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
import { getLock } from '@openpanel/redis';
import { anyPass, isEmpty, isNil, mergeDeepRight, omit, reject } from 'ramda'; import { anyPass, isEmpty, isNil, mergeDeepRight, omit, reject } from 'ramda';
import { logger as baseLogger } from '@/utils/logger'; import { logger as baseLogger } from '@/utils/logger';
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler'; import {
createSessionEndJob,
extendSessionEndJob,
} from '@/utils/session-handler';
const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp', '__revenue']; const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp', '__revenue'];
@@ -93,7 +91,8 @@ export async function incomingEvent(
projectId, projectId,
deviceId, deviceId,
sessionId, sessionId,
uaInfo: _uaInfo, uaInfo,
session,
} = jobPayload; } = jobPayload;
const properties = body.properties ?? {}; const properties = body.properties ?? {};
const reqId = headers['request-id'] ?? 'unknown'; const reqId = headers['request-id'] ?? 'unknown';
@@ -121,21 +120,21 @@ export async function incomingEvent(
? null ? null
: parseReferrer(getProperty('__referrer')); : parseReferrer(getProperty('__referrer'));
const utmReferrer = getReferrerWithQuery(query); const utmReferrer = getReferrerWithQuery(query);
const userAgent = headers['user-agent'];
const sdkName = headers['openpanel-sdk-name']; const sdkName = headers['openpanel-sdk-name'];
const sdkVersion = headers['openpanel-sdk-version']; const sdkVersion = headers['openpanel-sdk-version'];
// TODO: Remove both user-agent and parseUserAgent
const uaInfo = _uaInfo ?? parseUserAgent(userAgent, properties);
const baseEvent = { const baseEvent: IServiceCreateEventPayload = {
name: body.name, name: body.name,
profileId, profileId,
projectId, projectId,
deviceId,
sessionId,
properties: omit(GLOBAL_PROPERTIES, { properties: omit(GLOBAL_PROPERTIES, {
...properties, ...properties,
__hash: hash, __hash: hash,
__query: query, __query: query,
}), }),
groups: body.groups ?? [],
createdAt, createdAt,
duration: 0, duration: 0,
sdkName, sdkName,
@@ -149,7 +148,7 @@ export async function incomingEvent(
origin, origin,
referrer: referrer?.url || '', referrer: referrer?.url || '',
referrerName: utmReferrer?.name || referrer?.name || referrer?.url, referrerName: utmReferrer?.name || referrer?.name || referrer?.url,
referrerType: referrer?.type || utmReferrer?.type || '', referrerType: utmReferrer?.type || referrer?.type || '',
os: uaInfo.os, os: uaInfo.os,
osVersion: uaInfo.osVersion, osVersion: uaInfo.osVersion,
browser: uaInfo.browser, browser: uaInfo.browser,
@@ -161,16 +160,17 @@ export async function incomingEvent(
body.name === 'revenue' && '__revenue' in properties body.name === 'revenue' && '__revenue' in properties
? parseRevenue(properties.__revenue) ? parseRevenue(properties.__revenue)
: undefined, : undefined,
} as const; };
// if timestamp is from the past we dont want to create a new session // if timestamp is from the past we dont want to create a new session
if (uaInfo.isServer || isTimestampFromThePast) { if (uaInfo.isServer || isTimestampFromThePast) {
const session = profileId const session =
? await sessionBuffer.getExistingSession({ profileId && !isTimestampFromThePast
profileId, ? await sessionBuffer.getExistingSession({
projectId, profileId,
}) projectId,
: null; })
: null;
const payload = { const payload = {
...baseEvent, ...baseEvent,
@@ -198,82 +198,48 @@ export async function incomingEvent(
return createEventAndNotify(payload as IServiceEvent, logger, projectId); return createEventAndNotify(payload as IServiceEvent, logger, projectId);
} }
const sessionEnd = await getSessionEnd({
projectId,
deviceId,
profileId,
});
const activeSession = sessionEnd
? await sessionBuffer.getExistingSession({
sessionId: sessionEnd.sessionId,
})
: null;
const payload: IServiceCreateEventPayload = merge(baseEvent, { const payload: IServiceCreateEventPayload = merge(baseEvent, {
deviceId: sessionEnd?.deviceId ?? deviceId, referrer: session?.referrer ?? baseEvent.referrer,
sessionId: sessionEnd?.sessionId ?? sessionId, referrerName: session?.referrerName ?? baseEvent.referrerName,
referrer: sessionEnd?.referrer ?? baseEvent.referrer, referrerType: session?.referrerType ?? baseEvent.referrerType,
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
// if the path is not set, use the last screen view path
path: baseEvent.path || activeSession?.exit_path || '',
origin: baseEvent.origin || activeSession?.exit_origin || '',
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload; } as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
// If the triggering event is filtered, do not create session_start or the event (issue #2)
const isExcluded = await isEventExcludedByProjectFilter(payload, projectId); const isExcluded = await isEventExcludedByProjectFilter(payload, projectId);
if (isExcluded) { if (isExcluded) {
logger.info( logger.info(
'Skipping session_start and event (excluded by project filter)', 'Skipping session_start and event (excluded by project filter)',
{ event: payload.name, projectId }
);
return null;
}
if (session) {
await extendSessionEndJob({
projectId,
deviceId,
}).catch((error) => {
logger.error('Error finding and extending session end job', { error });
throw error;
});
} else {
await createEventAndNotify(
{ {
event: payload.name, ...payload,
projectId, name: 'session_start',
} createdAt: new Date(getTime(payload.createdAt) - 100),
); },
return null; logger,
} projectId
).catch((error) => {
logger.error('Error creating session start event', { event: payload });
throw error;
});
if (!sessionEnd) {
const locked = await getLock(
`session_start:${projectId}:${sessionId}`,
'1',
1000
);
if (locked) {
logger.info('Creating session start event', { event: payload });
await createEventAndNotify(
{
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
},
logger,
projectId
).catch((error) => {
logger.error('Error creating session start event', { event: payload });
throw error;
});
} else {
logger.info('Session start already claimed by another worker', {
event: payload,
});
}
}
const event = await createEventAndNotify(payload, logger, projectId);
if (!event) {
// Skip creating session end when event was excluded
return null;
}
if (!sessionEnd) {
logger.info('Creating session end job', { event: payload });
await createSessionEndJob({ payload }).catch((error) => { await createSessionEndJob({ payload }).catch((error) => {
logger.error('Error creating session end job', { event: payload }); logger.error('Error creating session end job', { event: payload });
throw error; throw error;
}); });
} }
return event; return createEventAndNotify(payload, logger, projectId);
} }

View File

@@ -129,6 +129,7 @@ describe('incomingEvent', () => {
referrerType: '', referrerType: '',
sdkName: jobData.headers['openpanel-sdk-name'], sdkName: jobData.headers['openpanel-sdk-name'],
sdkVersion: jobData.headers['openpanel-sdk-version'], sdkVersion: jobData.headers['openpanel-sdk-version'],
groups: [],
}; };
(createEvent as Mock).mockReturnValue(event); (createEvent as Mock).mockReturnValue(event);
@@ -186,6 +187,11 @@ describe('incomingEvent', () => {
projectId, projectId,
deviceId, deviceId,
sessionId: 'session-123', sessionId: 'session-123',
session: {
referrer: '',
referrerName: '',
referrerType: '',
},
}; };
const changeDelay = vi.fn(); const changeDelay = vi.fn();
@@ -237,6 +243,7 @@ describe('incomingEvent', () => {
referrerType: '', referrerType: '',
sdkName: jobData.headers['openpanel-sdk-name'], sdkName: jobData.headers['openpanel-sdk-name'],
sdkVersion: jobData.headers['openpanel-sdk-version'], sdkVersion: jobData.headers['openpanel-sdk-version'],
groups: [],
}; };
expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0); expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0);
@@ -307,6 +314,7 @@ describe('incomingEvent', () => {
screen_views: [], screen_views: [],
sign: 1, sign: 1,
version: 1, version: 1,
groups: [],
} satisfies IClickhouseSession); } satisfies IClickhouseSession);
await incomingEvent(jobData); await incomingEvent(jobData);
@@ -344,6 +352,7 @@ describe('incomingEvent', () => {
sdkName: 'server', sdkName: 'server',
sdkVersion: '1.0.0', sdkVersion: '1.0.0',
revenue: undefined, revenue: undefined,
groups: [],
}); });
expect(sessionsQueue.add).not.toHaveBeenCalled(); expect(sessionsQueue.add).not.toHaveBeenCalled();
@@ -407,6 +416,7 @@ describe('incomingEvent', () => {
referrerType: undefined, referrerType: undefined,
sdkName: 'server', sdkName: 'server',
sdkVersion: '1.0.0', sdkVersion: '1.0.0',
groups: [],
}); });
expect(sessionsQueue.add).not.toHaveBeenCalled(); expect(sessionsQueue.add).not.toHaveBeenCalled();

View File

@@ -1,5 +1,3 @@
import client from 'prom-client';
import { import {
botBuffer, botBuffer,
eventBuffer, eventBuffer,
@@ -8,6 +6,7 @@ import {
sessionBuffer, sessionBuffer,
} from '@openpanel/db'; } from '@openpanel/db';
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue'; import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
import client from 'prom-client';
const Registry = client.Registry; const Registry = client.Registry;
@@ -20,7 +19,7 @@ export const eventsGroupJobDuration = new client.Histogram({
name: 'job_duration_ms', name: 'job_duration_ms',
help: 'Duration of job processing (in ms)', help: 'Duration of job processing (in ms)',
labelNames: ['name', 'status'], labelNames: ['name', 'status'],
buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10000, 30000], // 10ms to 30s buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10_000, 30_000], // 10ms to 30s
}); });
register.registerMetric(eventsGroupJobDuration); register.registerMetric(eventsGroupJobDuration);
@@ -28,57 +27,61 @@ register.registerMetric(eventsGroupJobDuration);
queues.forEach((queue) => { queues.forEach((queue) => {
register.registerMetric( register.registerMetric(
new client.Gauge({ new client.Gauge({
name: `${queue.name.replace(/[\{\}]/g, '')}_active_count`, name: `${queue.name.replace(/[{}]/g, '')}_active_count`,
help: 'Active count', help: 'Active count',
async collect() { async collect() {
const metric = await queue.getActiveCount(); const metric = await queue.getActiveCount();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
new client.Gauge({ new client.Gauge({
name: `${queue.name.replace(/[\{\}]/g, '')}_delayed_count`, name: `${queue.name.replace(/[{}]/g, '')}_delayed_count`,
help: 'Delayed count', help: 'Delayed count',
async collect() { async collect() {
const metric = await queue.getDelayedCount(); if ('getDelayedCount' in queue) {
this.set(metric); const metric = await queue.getDelayedCount();
this.set(metric);
} else {
this.set(0);
}
}, },
}), })
); );
register.registerMetric( register.registerMetric(
new client.Gauge({ new client.Gauge({
name: `${queue.name.replace(/[\{\}]/g, '')}_failed_count`, name: `${queue.name.replace(/[{}]/g, '')}_failed_count`,
help: 'Failed count', help: 'Failed count',
async collect() { async collect() {
const metric = await queue.getFailedCount(); const metric = await queue.getFailedCount();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
new client.Gauge({ new client.Gauge({
name: `${queue.name.replace(/[\{\}]/g, '')}_completed_count`, name: `${queue.name.replace(/[{}]/g, '')}_completed_count`,
help: 'Completed count', help: 'Completed count',
async collect() { async collect() {
const metric = await queue.getCompletedCount(); const metric = await queue.getCompletedCount();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
new client.Gauge({ new client.Gauge({
name: `${queue.name.replace(/[\{\}]/g, '')}_waiting_count`, name: `${queue.name.replace(/[{}]/g, '')}_waiting_count`,
help: 'Waiting count', help: 'Waiting count',
async collect() { async collect() {
const metric = await queue.getWaitingCount(); const metric = await queue.getWaitingCount();
this.set(metric); this.set(metric);
}, },
}), })
); );
}); });
@@ -90,7 +93,7 @@ register.registerMetric(
const metric = await eventBuffer.getBufferSize(); const metric = await eventBuffer.getBufferSize();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
@@ -101,7 +104,7 @@ register.registerMetric(
const metric = await profileBuffer.getBufferSize(); const metric = await profileBuffer.getBufferSize();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
@@ -112,7 +115,7 @@ register.registerMetric(
const metric = await botBuffer.getBufferSize(); const metric = await botBuffer.getBufferSize();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
@@ -123,7 +126,7 @@ register.registerMetric(
const metric = await sessionBuffer.getBufferSize(); const metric = await sessionBuffer.getBufferSize();
this.set(metric); this.set(metric);
}, },
}), })
); );
register.registerMetric( register.registerMetric(
@@ -134,5 +137,5 @@ register.registerMetric(
const metric = await replayBuffer.getBufferSize(); const metric = await replayBuffer.getBufferSize();
this.set(metric); this.set(metric);
}, },
}), })
); );

View File

@@ -1,13 +1,39 @@
import type { IServiceCreateEventPayload } from '@openpanel/db'; import type { IServiceCreateEventPayload } from '@openpanel/db';
import { import { sessionsQueue } from '@openpanel/queue';
type EventsQueuePayloadCreateSessionEnd,
sessionsQueue,
} from '@openpanel/queue';
import type { Job } from 'bullmq';
import { logger } from './logger';
export const SESSION_TIMEOUT = 1000 * 60 * 30; export const SESSION_TIMEOUT = 1000 * 60 * 30;
const CHANGE_DELAY_THROTTLE_MS = process.env.CHANGE_DELAY_THROTTLE_MS
? Number.parseInt(process.env.CHANGE_DELAY_THROTTLE_MS, 10)
: 60_000; // 1 minute
const CHANGE_DELAY_THROTTLE_MAP = new Map<string, number>();
export async function extendSessionEndJob({
projectId,
deviceId,
}: {
projectId: string;
deviceId: string;
}) {
const last = CHANGE_DELAY_THROTTLE_MAP.get(`${projectId}:${deviceId}`) ?? 0;
const isThrottled = Date.now() - last < CHANGE_DELAY_THROTTLE_MS;
if (isThrottled) {
return;
}
const jobId = getSessionEndJobId(projectId, deviceId);
const job = await sessionsQueue.getJob(jobId);
if (!job) {
return;
}
await job.changeDelay(SESSION_TIMEOUT);
CHANGE_DELAY_THROTTLE_MAP.set(`${projectId}:${deviceId}`, Date.now());
}
const getSessionEndJobId = (projectId: string, deviceId: string) => const getSessionEndJobId = (projectId: string, deviceId: string) =>
`sessionEnd:${projectId}:${deviceId}`; `sessionEnd:${projectId}:${deviceId}`;
@@ -33,106 +59,3 @@ export function createSessionEndJob({
} }
); );
} }
export async function getSessionEnd({
projectId,
deviceId,
profileId,
}: {
projectId: string;
deviceId: string;
profileId: string;
}) {
const sessionEnd = await getSessionEndJob({
projectId,
deviceId,
});
if (sessionEnd) {
const existingSessionIsAnonymous =
sessionEnd.job.data.payload.profileId ===
sessionEnd.job.data.payload.deviceId;
const eventIsIdentified =
profileId && sessionEnd.job.data.payload.profileId !== profileId;
if (existingSessionIsAnonymous && eventIsIdentified) {
await sessionEnd.job.updateData({
...sessionEnd.job.data,
payload: {
...sessionEnd.job.data.payload,
profileId,
},
});
}
await sessionEnd.job.changeDelay(SESSION_TIMEOUT);
return sessionEnd.job.data.payload;
}
return null;
}
export async function getSessionEndJob(args: {
projectId: string;
deviceId: string;
retryCount?: number;
}): Promise<{
deviceId: string;
job: Job<EventsQueuePayloadCreateSessionEnd>;
} | null> {
const { retryCount = 0 } = args;
if (retryCount >= 6) {
throw new Error('Failed to get session end');
}
async function handleJobStates(
job: Job<EventsQueuePayloadCreateSessionEnd>,
deviceId: string
): Promise<{
deviceId: string;
job: Job<EventsQueuePayloadCreateSessionEnd>;
} | null> {
const state = await job.getState();
if (state !== 'delayed') {
logger.debug(`[session-handler] Session end job is in "${state}" state`, {
state,
retryCount,
jobTimestamp: new Date(job.timestamp).toISOString(),
jobDelta: Date.now() - job.timestamp,
jobId: job.id,
payload: job.data.payload,
});
}
if (state === 'delayed' || state === 'waiting') {
return { deviceId, job };
}
if (state === 'active') {
await new Promise((resolve) => setTimeout(resolve, 100));
return getSessionEndJob({
...args,
retryCount: retryCount + 1,
});
}
if (state === 'completed') {
await job.remove();
}
return null;
}
// Check current device job
const currentJob = await sessionsQueue.getJob(
getSessionEndJobId(args.projectId, args.deviceId)
);
if (currentJob) {
return await handleJobStates(currentJob, args.deviceId);
}
// Create session
return null;
}

View File

@@ -42,6 +42,7 @@
"useSemanticElements": "off" "useSemanticElements": "off"
}, },
"style": { "style": {
"noNestedTernary": "off",
"noNonNullAssertion": "off", "noNonNullAssertion": "off",
"noParameterAssign": "error", "noParameterAssign": "error",
"useAsConstAssertion": "error", "useAsConstAssertion": "error",
@@ -52,7 +53,9 @@
"noUnusedTemplateLiteral": "error", "noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error", "useNumberNamespace": "error",
"noInferrableTypes": "error", "noInferrableTypes": "error",
"noUselessElse": "error" "noUselessElse": "error",
"noNestedTernary": "off",
"useDefaultSwitchClause": "off"
}, },
"correctness": { "correctness": {
"useExhaustiveDependencies": "off", "useExhaustiveDependencies": "off",
@@ -60,7 +63,8 @@
}, },
"performance": { "performance": {
"noDelete": "off", "noDelete": "off",
"noAccumulatingSpread": "off" "noAccumulatingSpread": "off",
"noBarrelFile": "off"
}, },
"suspicious": { "suspicious": {
"noExplicitAny": "off", "noExplicitAny": "off",
@@ -70,7 +74,8 @@
"noDangerouslySetInnerHtml": "off" "noDangerouslySetInnerHtml": "off"
}, },
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "off",
"noExcessiveCognitiveComplexity": "off"
} }
} }
}, },

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