14 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
7a76b968ba try simplified event buffer with duration calculation on the fly instead 2026-03-16 09:15:34 +01:00
Carl-Gerhard Lindesvärd
9c3c1458bb wip: try groupmq 2 2026-03-13 06:43:09 +01:00
Carl-Gerhard Lindesvärd
a672b73947 wip 2026-03-11 23:38:22 +01:00
Carl-Gerhard Lindesvärd
bc08566cd4 fix 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
bf39804767 remove unused file 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
664f1abe0a perf: optimize event buffer 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
8afcf55154 fix: how we fetch profiles in the buffer 2026-03-11 21:33:08 +01:00
Carl-Gerhard Lindesvärd
4736f8509d fix: healthz readiness should only fail if redis fails 2026-03-11 13:53:11 +01:00
Carl-Gerhard Lindesvärd
05cf6bb39f fix: add search for Opportunities and Cannibalization 2026-03-11 11:30:19 +01:00
Carl-Gerhard Lindesvärd
6e1daf2c76 fix: ensure we have envs for gsc sync 2026-03-11 09:50:12 +01:00
Carl-Gerhard Lindesvärd
f2aa0273e6 debug gsc sync 2026-03-11 08:20:04 +01:00
Carl-Gerhard Lindesvärd
1b898660ad fix: improve landing page 2026-03-10 22:30:31 +01:00
Carl-Gerhard Lindesvärd
9836f75e17 fix: add gsc worker to bullboard 2026-03-09 21:42:20 +01:00
Carl-Gerhard Lindesvärd
271d189ed0 feat: added google search console 2026-03-09 20:47:02 +01:00
114 changed files with 7188 additions and 2585 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

@@ -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

@@ -0,0 +1,167 @@
import { googleGsc } from '@openpanel/auth';
import { db, encrypt } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { LogError } from '@/utils/errors';
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
function sanitizeOAuthQuery(
query: Record<string, unknown> | null | undefined
): Record<string, string> {
if (!query || typeof query !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(query).map(([k, v]) => [
k,
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
])
);
}
export async function gscGoogleCallback(
req: FastifyRequest,
reply: FastifyReply
) {
try {
const schema = z.object({
code: z.string(),
state: z.string(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
throw new LogError(
'Invalid GSC callback query params',
sanitizeOAuthQuery(req.query as Record<string, unknown>)
);
}
const { code, state } = query.data;
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
const rawProjectId = req.cookies.gsc_project_id ?? null;
const storedStateResult =
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
const codeVerifierResult =
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
const projectIdResult =
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
if (
!(
storedStateResult?.value &&
codeVerifierResult?.value &&
projectIdResult?.value
)
) {
throw new LogError('Missing GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
if (
!(
storedStateResult?.valid &&
codeVerifierResult?.valid &&
projectIdResult?.valid
)
) {
throw new LogError('Invalid GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
const stateStr = storedStateResult?.value;
const codeVerifierStr = codeVerifierResult?.value;
const projectIdStr = projectIdResult?.value;
if (state !== stateStr) {
throw new LogError('GSC OAuth state mismatch', {
hasState: true,
hasStoredState: true,
stateMismatch: true,
});
}
const tokens = await googleGsc.validateAuthorizationCode(
code,
codeVerifierStr
);
const accessToken = tokens.accessToken();
const refreshToken = tokens.hasRefreshToken()
? tokens.refreshToken()
: null;
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
if (!refreshToken) {
throw new LogError('No refresh token returned from Google GSC OAuth');
}
const project = await db.project.findUnique({
where: { id: projectIdStr },
select: { id: true, organizationId: true },
});
if (!project) {
throw new LogError('Project not found for GSC connection', {
projectId: projectIdStr,
});
}
await db.gscConnection.upsert({
where: { projectId: projectIdStr },
create: {
projectId: projectIdStr,
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
siteUrl: '',
},
update: {
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
lastSyncStatus: null,
lastSyncError: null,
},
});
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
const dashboardUrl =
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
return reply.redirect(redirectUrl);
} catch (error) {
req.log.error(error);
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
return redirectWithError(reply, error);
}
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);
url.pathname = '/login';
if (error instanceof LogError) {
url.searchParams.set('error', error.message);
} else {
url.searchParams.set('error', 'Failed to connect Google Search Console');
}
url.searchParams.set('correlationId', reply.request.id);
return reply.redirect(url.toString());
}

View File

@@ -1,12 +1,12 @@
import { isShuttingDown } from '@/utils/graceful-shutdown';
import { chQuery, db } from '@openpanel/db'; import { chQuery, db } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isShuttingDown } from '@/utils/graceful-shutdown';
// For docker compose healthcheck // For docker compose healthcheck
export async function healthcheck( export async function healthcheck(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
try { try {
const redisRes = await getRedisCache().ping(); const redisRes = await getRedisCache().ping();
@@ -21,6 +21,7 @@ export async function healthcheck(
ch: chRes && chRes.length > 0, ch: chRes && chRes.length > 0,
}); });
} catch (error) { } catch (error) {
request.log.warn('healthcheck failed', { error });
return reply.status(503).send({ return reply.status(503).send({
ready: false, ready: false,
reason: 'dependencies not ready', reason: 'dependencies not ready',
@@ -41,18 +42,22 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
// Perform lightweight dependency checks for readiness // Perform lightweight dependency checks for readiness
const redisRes = await getRedisCache().ping(); const redisRes = await getRedisCache().ping();
const dbRes = await db.project.findFirst(); const dbRes = await db.$executeRaw`SELECT 1`;
const chRes = await chQuery('SELECT 1'); const chRes = await chQuery('SELECT 1');
const isReady = redisRes && dbRes && chRes; const isReady = redisRes;
if (!isReady) { if (!isReady) {
return reply.status(503).send({ const res = {
ready: false,
reason: 'dependencies not ready',
redis: redisRes === 'PONG', redis: redisRes === 'PONG',
db: !!dbRes, db: !!dbRes,
ch: chRes && chRes.length > 0, ch: chRes && chRes.length > 0,
};
request.log.warn('dependencies not ready', res);
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
...res,
}); });
} }

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

@@ -7,7 +7,10 @@ import {
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 IDecrementPayload, type IDecrementPayload,
@@ -112,6 +115,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 +145,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 +172,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 +183,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,7 +211,7 @@ async function handleTrack(
} }
promises.push( promises.push(
getEventsGroupQueueShard(groupId).add({ getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value, orderMs: timestamp.value,
data: { data: {
projectId, projectId,
@@ -217,6 +225,7 @@ async function handleTrack(
geo, geo,
deviceId, deviceId,
sessionId, sessionId,
session,
}, },
groupId, groupId,
jobId, jobId,

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

@@ -36,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router'; import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router'; import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router'; import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router'; import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router'; import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router'; import liveRouter from './routes/live.router';
@@ -194,6 +195,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' }); instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' }); instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' }); instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' }); instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' }); instance.register(aiRouter, { prefix: '/ai' });
}); });

View File

@@ -0,0 +1,12 @@
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'GET',
url: '/callback',
handler: gscGoogleCallback,
});
};
export default router;

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

@@ -59,7 +59,7 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
## Insights ## Insights
If you have configured [Insights](/features/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. 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.
--- ---

View File

@@ -1,56 +1,38 @@
import { import { ChevronRightIcon } from 'lucide-react';
BarChart3Icon,
ChevronRightIcon,
DollarSignIcon,
GlobeIcon,
PlayCircleIcon,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card'; import { FeatureCard } from '@/components/feature-card';
import { NotificationsIllustration } from '@/components/illustrations/notifications';
import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics'; import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics';
import { RetentionIllustration } from '@/components/illustrations/retention';
import { SessionReplayIllustration } from '@/components/illustrations/session-replay';
import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics'; import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics';
import { Section, SectionHeader } from '@/components/section'; import { Section, SectionHeader } from '@/components/section';
const features = [ function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const mediumFeatures = [
{ {
title: 'Revenue tracking', title: 'Retention',
description: description:
'Track revenue from your payments and get insights into your revenue sources.', 'Know how many users come back after day 1, day 7, day 30. Identify which behaviors predict long-term retention.',
icon: DollarSignIcon, illustration: wrap(<RetentionIllustration />),
link: { link: { href: '/features/retention', children: 'View retention' },
href: '/features/revenue-tracking',
children: 'More about revenue',
},
},
{
title: 'Profiles & Sessions',
description:
'Track individual users and their complete journey across your platform.',
icon: GlobeIcon,
link: {
href: '/features/identify-users',
children: 'Identify your users',
},
},
{
title: 'Event Tracking',
description:
'Capture every important interaction with flexible event tracking.',
icon: BarChart3Icon,
link: {
href: '/features/event-tracking',
children: 'All about tracking',
},
}, },
{ {
title: 'Session Replay', title: 'Session Replay',
description: description:
'Watch real user sessions to see exactly what happened. Privacy controls built in, loads async.', 'Watch real user sessions to see exactly what happened — clicks, scrolls, rage clicks. Privacy controls built in.',
icon: PlayCircleIcon, illustration: wrap(<SessionReplayIllustration />),
link: { link: { href: '/features/session-replay', children: 'See session replay' },
href: '/features/session-replay',
children: 'See session replay',
}, },
{
title: 'Notifications',
description:
'Get notified when a funnel is completed. Stay on top of key moments in your product without watching dashboards all day.',
illustration: wrap(<NotificationsIllustration />),
link: { href: '/features/notifications', children: 'Set up notifications' },
}, },
]; ];
@@ -59,37 +41,39 @@ export function AnalyticsInsights() {
<Section className="container"> <Section className="container">
<SectionHeader <SectionHeader
className="mb-16" className="mb-16"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking." description="From first page view to long-term retention — every touchpoint in one platform. No sampling, no data limits, no guesswork."
label="ANALYTICS & INSIGHTS" label="ANALYTICS & INSIGHTS"
title="See the full picture of your users and product performance" title="Everything you need to understand your users"
/> />
<div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
className="px-0 **:data-content:px-6" className="px-0 **:data-content:px-6"
description="Understand your website performance with privacy-first analytics and clear, actionable insights." description="Understand your website performance with privacy-first analytics. Track visitors, referrers, and page views without touching user cookies."
illustration={<WebAnalyticsIllustration />} illustration={<WebAnalyticsIllustration />}
title="Web Analytics" title="Web Analytics"
variant="large"
/> />
<FeatureCard <FeatureCard
className="px-0 **:data-content:px-6" className="px-0 **:data-content:px-6"
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends." description="Go beyond page views. Track custom events, understand user flows, and explore exactly how people use your product."
illustration={<ProductAnalyticsIllustration />} illustration={<ProductAnalyticsIllustration />}
title="Product Analytics" title="Product Analytics"
variant="large"
/> />
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{features.map((feature) => ( <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{mediumFeatures.map((feature) => (
<FeatureCard <FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description} description={feature.description}
icon={feature.icon} illustration={feature.illustration}
key={feature.title} key={feature.title}
link={feature.link} link={feature.link}
title={feature.title} title={feature.title}
/> />
))} ))}
</div> </div>
<p className="mt-8 text-center"> <p className="mt-8 text-center">
<Link <Link
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground" className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"

View File

@@ -15,23 +15,23 @@ import { CollaborationChart } from './collaboration-chart';
const features = [ const features = [
{ {
title: 'Visualize your data', title: 'Flexible data visualization',
description: description:
'See your data in a visual way. You can create advanced reports and more to understand', 'Build line charts, bar charts, sankey flows, and custom dashboards. Combine metrics from any event into a single view.',
icon: ChartBarIcon, icon: ChartBarIcon,
slug: 'data-visualization', slug: 'data-visualization',
}, },
{ {
title: 'Share & Collaborate', title: 'Share & Collaborate',
description: description:
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.', 'Invite unlimited team members with org-wide or project-level access. Share dashboards publicly or lock them behind a password.',
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
slug: 'share-and-collaborate', slug: 'share-and-collaborate',
}, },
{ {
title: 'Integrations', title: 'Integrations & Webhooks',
description: description:
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.', 'Forward events to your own systems or third-party tools. Connect OpenPanel to Slack, your data warehouse, or any webhook endpoint.',
icon: WorkflowIcon, icon: WorkflowIcon,
slug: 'integrations', slug: 'integrations',
}, },

View File

@@ -43,9 +43,9 @@ export function DataPrivacy() {
/> />
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard <FeatureCard
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust." description="GDPR compliant and privacy-friendly analytics without cookies or invasive tracking. Data is EU hosted, and a Data Processing Agreement (DPA) is available to sign."
illustration={<PrivacyIllustration />} illustration={<PrivacyIllustration />}
title="Privacy-first" title="GDPR compliant"
variant="large" variant="large"
/> />
<FeatureCard <FeatureCard

View File

@@ -0,0 +1,68 @@
import { FeatureCard } from '@/components/feature-card';
import { ConversionsIllustration } from '@/components/illustrations/conversions';
import { GoogleSearchConsoleIllustration } from '@/components/illustrations/google-search-console';
import { RevenueIllustration } from '@/components/illustrations/revenue';
import { Section, SectionHeader } from '@/components/section';
function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const features = [
{
title: 'Revenue Tracking',
description:
'Connect payment events to track MRR and see which referrers drive the most revenue.',
illustration: wrap(<RevenueIllustration />),
link: {
href: '/features/revenue-tracking',
children: 'Track revenue',
},
},
{
title: 'Conversion Tracking',
description:
'Monitor conversion rates over time and break down by A/B variant, country, or device. Catch regressions before they cost you.',
illustration: wrap(<ConversionsIllustration />),
link: {
href: '/features/conversion',
children: 'Track conversions',
},
},
{
title: 'Google Search Console',
description:
'See which search queries bring organic traffic and how visitors convert after landing. Your SEO and product data, in one place.',
illustration: wrap(<GoogleSearchConsoleIllustration />),
link: {
href: '/features/integrations',
children: 'View integrations',
},
},
];
export function FeatureSpotlight() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
description="OpenPanel goes beyond page views. Track revenue, monitor conversions, and connect your SEO data — all without switching tools."
label="GROWTH TOOLS"
title="Built for teams who ship and measure"
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => (
<FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description}
illustration={feature.illustration}
key={feature.title}
link={feature.link}
title={feature.title}
/>
))}
</div>
</Section>
);
}

View File

@@ -5,14 +5,14 @@ import {
CalendarIcon, CalendarIcon,
CookieIcon, CookieIcon,
CreditCardIcon, CreditCardIcon,
DatabaseIcon,
GithubIcon, GithubIcon,
ServerIcon, ShieldCheckIcon,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { Competition } from '@/components/competition'; import { Competition } from '@/components/competition';
import { EuFlag } from '@/components/eu-flag';
import { GetStartedButton } from '@/components/get-started-button'; import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks'; import { Perks } from '@/components/perks';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -21,10 +21,10 @@ import { cn } from '@/lib/utils';
const perks = [ const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon }, { text: 'Free trial 30 days', icon: CalendarIcon },
{ text: 'No credit card required', icon: CreditCardIcon }, { text: 'No credit card required', icon: CreditCardIcon },
{ text: 'GDPR compliant', icon: ShieldCheckIcon },
{ text: 'EU hosted', icon: EuFlag },
{ text: 'Cookie-less tracking', icon: CookieIcon }, { text: 'Cookie-less tracking', icon: CookieIcon },
{ text: 'Open-source', icon: GithubIcon }, { text: 'Open-source', icon: GithubIcon },
{ text: 'Your data, your rules', icon: DatabaseIcon },
{ text: 'Self-hostable', icon: ServerIcon },
]; ];
const aspectRatio = 2946 / 1329; const aspectRatio = 2946 / 1329;
@@ -90,7 +90,7 @@ export function Hero() {
TRUSTED BY 1,000+ PROJECTS TRUSTED BY 1,000+ PROJECTS
</div> </div>
<h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl"> <h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl">
OpenPanel - The open-source alternative to <Competition /> The open-source alternative to <Competition />
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
An open-source web and product analytics platform that combines the An open-source web and product analytics platform that combines the

View File

@@ -55,6 +55,9 @@ export function Pricing() {
<div className="col mt-8 w-full items-baseline md:mt-auto"> <div className="col mt-8 w-full items-baseline md:mt-auto">
{selected ? ( {selected ? (
<> <>
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
<div className="row items-end gap-3"> <div className="row items-end gap-3">
<NumberFlow <NumberFlow
className="font-bold text-5xl" className="font-bold text-5xl"
@@ -67,9 +70,6 @@ export function Pricing() {
locales={'en-US'} locales={'en-US'}
value={selected.price} value={selected.price}
/> />
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
</div> </div>
<div className="row w-full justify-between"> <div className="row w-full justify-between">
<span className="-mt-2 text-muted-foreground/80 text-sm"> <span className="-mt-2 text-muted-foreground/80 text-sm">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { QuoteIcon } from 'lucide-react'; import { QuoteIcon, StarIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { FeatureCardBackground } from '@/components/feature-card'; import { FeatureCardBackground } from '@/components/feature-card';
@@ -94,13 +94,22 @@ export function WhyOpenPanel() {
))} ))}
</div> </div>
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2"> <div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
{quotes.map((quote) => ( {quotes.slice(0, 2).map((quote) => (
<figure <figure
className="group px-4 py-4 md:odd:border-r" className="group px-4 py-4 md:odd:border-r"
key={quote.author} key={quote.author}
> >
<div className="row items-center justify-between">
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" /> <QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
<blockquote className="prose text-xl"> <div className="row gap-1">
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
</div>
</div>
<blockquote className="prose text-justify text-xl">
<Markdown>{quote.quote}</Markdown> <Markdown>{quote.quote}</Markdown>
</blockquote> </blockquote>
<figcaption className="row mt-4 justify-between text-muted-foreground text-sm"> <figcaption className="row mt-4 justify-between text-muted-foreground text-sm">

View File

@@ -1,5 +1,6 @@
import { AnalyticsInsights } from './_sections/analytics-insights'; import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration'; import { Collaboration } from './_sections/collaboration';
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';
@@ -57,6 +58,7 @@ export default function HomePage() {
<Hero /> <Hero />
<WhyOpenPanel /> <WhyOpenPanel />
<AnalyticsInsights /> <AnalyticsInsights />
<FeatureSpotlight />
<Collaboration /> <Collaboration />
<Testimonials /> <Testimonials />
<Pricing /> <Pricing />

View File

@@ -0,0 +1,37 @@
function star(cx: number, cy: number, outerR: number, innerR: number) {
const pts: string[] = [];
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (i * Math.PI) / 5 - Math.PI / 2;
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
return pts.join(' ');
}
const STARS = Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 - 90) * (Math.PI / 180);
return {
x: 12 + 5 * Math.cos(angle),
y: 8 + 5 * Math.sin(angle),
};
});
export function EuFlag({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 16"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="#003399" height="16" rx="1.5" width="24" />
{STARS.map((s, i) => (
<polygon
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
fill="#FFCC00"
points={star(s.x, s.y, 1.1, 0.45)}
/>
))}
</svg>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const variantA = [28, 31, 29, 34, 32, 36, 35, 38, 37, 40, 39, 42];
const variantB = [28, 30, 32, 35, 38, 37, 40, 42, 44, 43, 47, 50];
export function ConversionsIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* A/B variant cards */}
<div className="row gap-3">
<div className="col flex-1 gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Variant A
</span>
</div>
<span className="font-bold font-mono text-xl">28.4%</span>
<SimpleChart
height={24}
points={variantA}
strokeColor="var(--foreground)"
width={200}
/>
</div>
<div className="col flex-1 gap-1 rounded-xl border border-emerald-500/30 bg-card p-3 transition-all delay-75 duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[9px] text-emerald-600 dark:text-emerald-400">
Variant B
</span>
</div>
<span className="font-bold font-mono text-xl text-emerald-500">
41.2%
</span>
<SimpleChart
height={24}
points={variantB}
strokeColor="rgb(34, 197, 94)"
width={200}
/>
</div>
</div>
{/* Breakdown label */}
<div className="col gap-1 rounded-xl border bg-card/60 px-3 py-2.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
Breakdown by experiment variant
</span>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-foreground/50"
style={{ width: '57%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">A: 57%</span>
</div>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-emerald-500"
style={{ width: '82%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">B: 82%</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
const queries = [
{
query: 'openpanel analytics',
clicks: 312,
impressions: '4.1k',
pos: 1.2,
},
{
query: 'open source mixpanel alternative',
clicks: 187,
impressions: '3.8k',
pos: 2.4,
},
{
query: 'web analytics without cookies',
clicks: 98,
impressions: '2.2k',
pos: 4.7,
},
];
export function GoogleSearchConsoleIllustration() {
return (
<div className="col h-full gap-2 px-4 pt-5 pb-3">
{/* Top stats */}
<div className="row mb-1 gap-2">
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Clicks
</span>
<span className="font-bold font-mono text-sm">740</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Impr.
</span>
<span className="font-bold font-mono text-sm">13k</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. CTR
</span>
<span className="font-bold font-mono text-sm">5.7%</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. Pos
</span>
<span className="font-bold font-mono text-sm">2.8</span>
</div>
</div>
{/* Query table */}
<div className="flex-1 overflow-hidden rounded-xl border border-border bg-card">
<div className="row border-border border-b px-3 py-1.5">
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
Query
</span>
<span className="w-10 text-right text-[8px] text-muted-foreground uppercase tracking-wider">
Pos
</span>
</div>
{queries.map((q, i) => (
<div
className="row items-center border-border/50 border-b px-3 py-1.5 last:border-0"
key={q.query}
style={{ opacity: 1 - i * 0.18 }}
>
<span className="flex-1 truncate text-[9px]">{q.query}</span>
<span className="w-10 text-right font-mono text-[9px] text-muted-foreground">
{q.pos}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CheckCircleIcon } from 'lucide-react';
export function NotificationsIllustration() {
return (
<div className="col h-full justify-center gap-3 px-6 py-4">
{/* Funnel completion notification */}
<div className="col gap-2 rounded-xl border border-border bg-card p-4 shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-2">
<CheckCircleIcon className="size-4 shrink-0 text-emerald-500" />
<span className="font-semibold text-xs">Funnel completed</span>
<span className="ml-auto text-[9px] text-muted-foreground">
just now
</span>
</div>
<p className="font-medium text-sm">Signup Flow 142 today</p>
<div className="row items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-emerald-500"
style={{ width: '71%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">
71% conversion
</span>
</div>
</div>
{/* Notification rule */}
<div className="col gap-1.5 px-3 opacity-80">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
Notification rule
</span>
<div className="row flex-wrap items-center gap-1.5">
<span className="text-[9px] text-muted-foreground">When</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Signup Flow
</span>
<span className="text-[9px] text-muted-foreground">completes </span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
#growth
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
const cohorts = [
{ label: 'Week 1', values: [100, 68, 45, 38, 31] },
{ label: 'Week 2', values: [100, 72, 51, 42, 35] },
{ label: 'Week 3', values: [100, 65, 48, 39, null] },
{ label: 'Week 4', values: [100, 70, null, null, null] },
];
const headers = ['Day 0', 'Day 1', 'Day 7', 'Day 14', 'Day 30'];
function cellStyle(v: number | null) {
if (v === null) {
return {
backgroundColor: 'transparent',
borderColor: 'var(--border)',
color: 'var(--muted-foreground)',
};
}
const opacity = 0.12 + (v / 100) * 0.7;
return {
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
borderColor: `rgba(34, 197, 94, 0.3)`,
color: v > 55 ? 'rgba(0,0,0,0.75)' : 'var(--foreground)',
};
}
export function RetentionIllustration() {
return (
<div className="h-full px-4 pb-3 pt-5">
<div className="col h-full gap-1.5">
<div className="row gap-1">
<div className="w-12 shrink-0" />
{headers.map((h) => (
<div
key={h}
className="flex-1 text-center text-[9px] text-muted-foreground"
>
{h}
</div>
))}
</div>
{cohorts.map(({ label, values }) => (
<div key={label} className="row flex-1 gap-1">
<div className="flex w-12 shrink-0 items-center text-[9px] text-muted-foreground">
{label}
</div>
{values.map((v, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
className="flex flex-1 items-center justify-center rounded border text-[9px] font-medium transition-all duration-300 group-hover:scale-[1.03]"
style={cellStyle(v)}
>
{v !== null ? `${v}%` : '—'}
</div>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const revenuePoints = [28, 34, 31, 40, 37, 44, 41, 50, 47, 56, 59, 65];
const referrers = [
{ name: 'google.com', amount: '$3,840', pct: 46 },
{ name: 'twitter.com', amount: '$1,920', pct: 23 },
{ name: 'github.com', amount: '$1,260', pct: 15 },
{ name: 'direct', amount: '$1,400', pct: 16 },
];
export function RevenueIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* MRR stat + chart */}
<div className="row gap-3">
<div className="col gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
MRR
</span>
<span className="font-bold font-mono text-xl text-emerald-500">
$8,420
</span>
<span className="text-[9px] text-emerald-500"> 12% this month</span>
</div>
<div className="col flex-1 gap-1 rounded-xl border bg-card px-3 py-2">
<span className="text-[9px] text-muted-foreground">MRR over time</span>
<SimpleChart
className="mt-1 flex-1"
height={36}
points={revenuePoints}
strokeColor="rgb(34, 197, 94)"
width={400}
/>
</div>
</div>
{/* Revenue by referrer */}
<div className="flex-1 overflow-hidden rounded-xl border bg-card">
<div className="row border-b border-border px-3 py-1.5">
<span className="flex-1 text-[8px] uppercase tracking-wider text-muted-foreground">
Referrer
</span>
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
Revenue
</span>
</div>
{referrers.map((r) => (
<div
className="row items-center gap-2 border-b border-border/50 px-3 py-1.5 last:border-0"
key={r.name}
>
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
{r.name}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-1 rounded-full bg-emerald-500/70"
style={{ width: `${r.pct}%` }}
/>
</div>
<span className="font-mono text-[9px] text-emerald-500 flex-none">
{r.amount}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { PlayIcon } from 'lucide-react';
export function SessionReplayIllustration() {
return (
<div className="h-full px-6 pb-3 pt-4">
<div className="col h-full overflow-hidden rounded-xl border border-border bg-background shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
{/* Browser chrome */}
<div className="row shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2">
<div className="h-2 w-2 rounded-full bg-red-400" />
<div className="h-2 w-2 rounded-full bg-yellow-400" />
<div className="h-2 w-2 rounded-full bg-green-400" />
<div className="mx-2 flex-1 rounded bg-background/80 px-2 py-0.5 text-[8px] text-muted-foreground">
app.example.com/pricing
</div>
</div>
{/* Page content */}
<div className="relative flex-1 overflow-hidden p-3">
<div className="mb-2 h-2 w-20 rounded-full bg-muted/60" />
<div className="mb-4 h-2 w-32 rounded-full bg-muted/40" />
<div className="row mb-3 gap-2">
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
</div>
<div className="mb-2 h-2 w-28 rounded-full bg-muted/30" />
<div className="h-2 w-24 rounded-full bg-muted/20" />
{/* Click heatspot */}
<div
className="absolute"
style={{ left: '62%', top: '48%' }}
>
<div className="h-4 w-4 animate-pulse rounded-full border-2 border-blue-500/70 bg-blue-500/20" />
</div>
<div
className="absolute"
style={{ left: '25%', top: '32%' }}
>
<div className="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
</div>
{/* Cursor trail */}
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ overflow: 'visible' }}
>
<path
d="M 18% 22% Q 42% 28% 62% 48%"
fill="none"
stroke="rgb(59 130 246 / 0.35)"
strokeDasharray="3 2"
strokeWidth="1"
/>
</svg>
{/* Cursor */}
<div
className="absolute"
style={{
left: 'calc(62% + 8px)',
top: 'calc(48% + 6px)',
}}
>
<svg fill="none" height="12" viewBox="0 0 10 12" width="10">
<path
d="M0 0L0 10L3 7L5 11L6.5 10.5L4.5 6.5L8 6L0 0Z"
fill="var(--foreground)"
/>
</svg>
</div>
</div>
{/* Playback bar */}
<div className="row shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2">
<PlayIcon className="size-3 shrink-0 text-muted-foreground" />
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-muted">
<div
className="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
style={{ width: '42%' }}
/>
</div>
<span className="font-mono text-[8px] text-muted-foreground">
0:52 / 2:05
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,188 +1,165 @@
'use client'; 'use client';
import { SimpleChart } from '@/components/simple-chart';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react'; import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [ const VISITOR_DATA = [1840, 2100, 1950, 2400, 2250, 2650, 2980];
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const STATS = [
{ label: 'Visitors', value: 4128, formatted: null, change: 12, up: true },
{ label: 'Page views', value: 12438, formatted: '12.4k', change: 8, up: true },
{ label: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
];
const SOURCES = [
{ {
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com', icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
name: 'Google', name: 'google.com',
percentage: 49, pct: 49,
value: 2039,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
name: 'Instagram',
percentage: 23,
value: 920,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
name: 'Facebook',
percentage: 18,
value: 750,
}, },
{ {
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com', icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter', name: 'twitter.com',
percentage: 10, pct: 21,
value: 412, },
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
name: 'github.com',
pct: 14,
}, },
]; ];
const COUNTRIES = [ function AreaChart({ data }: { data: number[] }) {
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 }, const max = Math.max(...data);
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 }, const w = 400;
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 }, const h = 64;
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 }, const xStep = w / (data.length - 1);
]; const pts = data.map((v, i) => ({ x: i * xStep, y: h - (v / max) * h }));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
const area = `${line} L ${w},${h} L 0,${h} Z`;
const last = pts[pts.length - 1];
return (
<svg className="w-full" viewBox={`0 0 ${w} ${h + 4}`}>
<defs>
<linearGradient id="wa-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.25" />
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#wa-fill)" />
<path
d={line}
fill="none"
stroke="rgb(59 130 246)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<circle cx={last.x} cy={last.y} fill="rgb(59 130 246)" r="3" />
<circle
cx={last.x}
cy={last.y}
fill="none"
r="6"
stroke="rgb(59 130 246)"
strokeOpacity="0.3"
strokeWidth="1.5"
/>
</svg>
);
}
export function WebAnalyticsIllustration() { export function WebAnalyticsIllustration() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0); const [liveVisitors, setLiveVisitors] = useState(47);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const values = [47, 51, 44, 53, 49, 56];
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length); let i = 0;
}, 3000); const id = setInterval(() => {
i = (i + 1) % values.length;
return () => clearInterval(interval); setLiveVisitors(values[i]);
}, 2500);
return () => clearInterval(id);
}, []); }, []);
return ( return (
<div className="px-12 group aspect-video"> <div className="aspect-video col gap-2.5 p-5">
<div className="relative h-full col"> {/* Header */}
<MetricCard <div className="row items-center justify-between">
title="Session duration" <div className="row items-center gap-1.5">
value="3m 23s" <span className="relative flex h-1.5 w-1.5">
change="3%" <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]} <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
color="var(--foreground)" </span>
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300" <span className="text-[10px] font-medium text-muted-foreground">
/> <NumberFlow value={liveVisitors} /> online now
<MetricCard
title="Bounce rate"
value="46%"
change="3%"
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
/>
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
<BarCell
{...TRAFFIC_SOURCES[currentSourceIndex]}
className="group-hover:scale-105 transition-all duration-300"
/>
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
className="group-hover:scale-105 transition-all duration-300"
/>
</div>
</div>
</div>
);
}
function MetricCard({
title,
value,
change,
chartPoints,
color,
className,
}: {
title: string;
value: string;
change: string;
chartPoints: number[];
color: string;
className?: string;
}) {
return (
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
<div className="row items-end justify-between">
<div>
<div className="text-muted-foreground text-sm">{title}</div>
<div className="text-2xl font-semibold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium">
<ArrowUpIcon className="size-3" strokeWidth={3} />
<div>{change}</div>
</div>
</div>
<SimpleChart
width={400}
height={30}
points={chartPoints}
strokeColor={color}
className="mt-4"
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
className,
}: {
icon: string;
name: string;
percentage: number;
value: number;
className?: string;
}) {
return (
<div
className={cn(
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
className,
)}
>
<div
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
style={{
width: `${percentage}%`,
}}
/>
<div className="relative row justify-between ">
<div className="row gap-2 items-center font-medium text-sm">
{icon.startsWith('http') ? (
<Image
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
width={16}
height={16}
/>
) : (
<div className="text-2xl">{icon}</div>
)}
<AnimatePresence mode="popLayout">
<motion.div
key={name}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
{name}
</motion.div>
</AnimatePresence>
</div>
<div className="row gap-3 font-mono text-sm">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span> </span>
<NumberFlow value={value} locales={'en-US'} />
</div> </div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] text-muted-foreground">
Last 7 days
</span>
</div>
{/* KPI tiles */}
<div className="grid grid-cols-4 gap-1.5">
{STATS.map((stat) => (
<div
className="col gap-0.5 rounded-lg border bg-card px-2 py-1.5"
key={stat.label}
>
<span className="text-[8px] text-muted-foreground">{stat.label}</span>
<span className="font-mono font-semibold text-xs leading-tight">
{stat.formatted ??
(stat.value !== null ? (
<NumberFlow locales="en-US" value={stat.value} />
) : null)}
</span>
<span
className={`text-[8px] ${stat.up ? 'text-emerald-500' : 'text-red-400'}`}
>
{stat.up ? '↑' : '↓'} {stat.change}%
</span>
</div>
))}
</div>
{/* Area chart */}
<div className="flex-1 col gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
<span className="text-[8px] text-muted-foreground">Unique visitors</span>
<AreaChart data={VISITOR_DATA} />
<div className="row justify-between px-0.5">
{DAYS.map((d) => (
<span className="text-[7px] text-muted-foreground" key={d}>
{d}
</span>
))}
</div>
</div>
{/* Traffic sources */}
<div className="row gap-1.5">
{SOURCES.map((src) => (
<div
className="row flex-1 items-center gap-1.5 overflow-hidden rounded-lg border bg-card px-2 py-1.5"
key={src.name}
>
<Image
alt={src.name}
className="rounded-[2px] object-contain"
height={10}
src={src.icon}
width={10}
/>
<span className="flex-1 truncate text-[9px]">{src.name}</span>
<span className="font-mono text-[9px] text-muted-foreground">
{src.pct}%
</span>
</div>
))}
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,13 @@
import type React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
export function Perks({ export function Perks({
perks, perks,
className, className,
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) { }: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
return ( return (
<ul className={cn('grid grid-cols-2 gap-2', className)}> <ul className={cn('grid grid-cols-2 gap-2', className)}>
{perks.map((perk) => ( {perks.map((perk) => (

View File

@@ -1,24 +1,27 @@
import { pushModal } from '@/modals';
import type { import type {
IReport,
IChartRange, IChartRange,
IChartType, IChartType,
IInterval, IInterval,
IReport,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { SaveIcon } from 'lucide-react'; import { SaveIcon } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType'; import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval'; import { ReportInterval } from '../report/ReportInterval';
import { ReportChart } from '../report-chart';
import { TimeWindowPicker } from '../time-window-picker'; import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { pushModal } from '@/modals';
export function ChatReport({ export function ChatReport({
lazy, lazy,
...props ...props
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) { }: {
report: IReport & { startDate: string; endDate: string };
lazy: boolean;
}) {
const [chartType, setChartType] = useState<IChartType>( const [chartType, setChartType] = useState<IChartType>(
props.report.chartType, props.report.chartType
); );
const [startDate, setStartDate] = useState<string>(props.report.startDate); const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate); const [endDate, setEndDate] = useState<string>(props.report.endDate);
@@ -35,47 +38,48 @@ export function ChatReport({
}; };
return ( return (
<div className="card"> <div className="card">
<div className="text-center text-sm font-mono font-medium pt-4"> <div className="pt-4 text-center font-medium font-mono text-sm">
{props.report.name} {props.report.name}
</div> </div>
<div className="p-4"> <div className="p-4">
<ReportChart lazy={lazy} report={report} /> <ReportChart lazy={lazy} report={report} />
</div> </div>
<div className="row justify-between gap-1 border-t border-border p-2"> <div className="row justify-between gap-1 border-border border-t p-2">
<div className="col md:row gap-1"> <div className="col md:row gap-1">
<TimeWindowPicker <TimeWindowPicker
className="min-w-0" className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate} endDate={report.endDate}
onChange={setRange}
onEndDateChange={setEndDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={report.startDate} startDate={report.startDate}
value={report.range}
/> />
<ReportInterval <ReportInterval
chartType={chartType}
className="min-w-0" className="min-w-0"
interval={interval} interval={interval}
range={range}
chartType={chartType}
onChange={setInterval} onChange={setInterval}
range={range}
/> />
<ReportChartType <ReportChartType
value={chartType}
onChange={(type) => { onChange={(type) => {
setChartType(type); setChartType(type);
}} }}
value={chartType}
/> />
</div> </div>
<Button <Button
icon={SaveIcon} icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => { onClick={() => {
pushModal('SaveReport', { pushModal('SaveReport', {
report, report,
disableRedirect: true, disableRedirect: true,
}); });
}} }}
size="sm"
variant="outline"
> >
Save report Save report
</Button> </Button>

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,24 +27,15 @@ 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
type="button" className={cn(
'card flex w-full items-center justify-between rounded-lg p-4 transition-colors hover:bg-light-background',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`
)}
onClick={() => { onClick={() => {
if (!isMinimal) { if (!isMinimal) {
pushModal('EventDetails', { pushModal('EventDetails', {
@@ -59,20 +45,12 @@ export function EventListItem(props: EventListItemProps) {
}); });
} }
}} }}
className={cn( type="button"
'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`,
)}
> >
<div> <div>
<div className="flex items-center gap-4 text-left "> <div className="flex items-center gap-4 text-left">
<EventIcon size="sm" name={name} meta={meta} /> <EventIcon meta={meta} name={name} size="sm" />
<span>
<span className="font-medium">{renderName()}</span> <span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div> </div>
<div className="pl-10"> <div className="pl-10">
<div className="flex origin-left scale-75 gap-1"> <div className="flex origin-left scale-75 gap-1">
@@ -86,16 +64,16 @@ export function EventListItem(props: EventListItemProps) {
{profile && ( {profile && (
<Tooltiper asChild content={getProfileName(profile)}> <Tooltiper asChild content={getProfileName(profile)}>
<Link <Link
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
to={'/$organizationId/$projectId/profiles/$profileId'}
params={{ params={{
organizationId, organizationId,
projectId, projectId,
profileId: profile.id, 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)} {getProfileName(profile)}
</Link> </Link>
@@ -103,12 +81,11 @@ export function EventListItem(props: EventListItemProps) {
)} )}
<Tooltiper asChild content={createdAt.toLocaleString()}> <Tooltiper asChild content={createdAt.toLocaleString()}>
<div className=" text-muted-foreground"> <div className="text-muted-foreground">
{createdAt.toLocaleTimeString()} {createdAt.toLocaleTimeString()}
</div> </div>
</Tooltiper> </Tooltiper>
</div> </div>
</button> </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,27 @@ export function useColumns() {
return name.replace(/_/g, ' '); return name.replace(/_/g, ' ');
}; };
const renderDuration = () => {
if (name === 'screen_view') {
return ( return (
<span className="text-muted-foreground"> <div className="flex min-w-0 items-center gap-2">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<div className="flex 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"
title={fullTitle}
onClick={() => { onClick={() => {
pushModal('EventDetails', { pushModal('EventDetails', {
id: row.original.id, id: row.original.id,
@@ -89,11 +84,10 @@ export function useColumns() {
projectId: row.original.projectId, projectId: row.original.projectId,
}); });
}} }}
className="font-medium hover:underline" 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,7 +197,7 @@ 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>
@@ -221,14 +215,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

@@ -35,6 +35,7 @@ type Props = {
>, >,
unknown unknown
>; >;
showEventListener?: boolean;
}; };
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[]; const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
@@ -215,7 +216,7 @@ 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();
@@ -272,7 +273,7 @@ export const EventsTable = ({ query }: Props) => {
return ( return (
<> <>
<EventsTableToolbar query={query} table={table} /> <EventsTableToolbar query={query} table={table} showEventListener={showEventListener} />
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} /> <VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}> <div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div <div
@@ -291,9 +292,11 @@ 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(
@@ -305,7 +308,7 @@ function EventsTableToolbar({
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" variant="outline"
size="sm" size="sm"

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

@@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker'; import { TimeWindowPicker } from '@/components/time-window-picker';
export function OverviewRange() { export function OverviewRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } = const {
useOverviewOptions(); range,
setRange,
setStartDate,
setEndDate,
endDate,
startDate,
setInterval,
} = useOverviewOptions();
return ( return (
<TimeWindowPicker <TimeWindowPicker
onChange={setRange}
value={range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={endDate} endDate={endDate}
onChange={setRange}
onEndDateChange={setEndDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={startDate} startDate={startDate}
value={range}
/> />
); );
} }

View File

@@ -0,0 +1,143 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import { Skeleton } from '@/components/skeleton';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
interface GscBreakdownTableProps {
projectId: string;
value: string;
type: 'page' | 'query';
}
export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) {
const { range, startDate, endDate } = useOverviewOptions();
const trpc = useTRPC();
const dateInput = {
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
};
const pageQuery = useQuery(
trpc.gsc.getPageDetails.queryOptions(
{ projectId, page: value, ...dateInput },
{ enabled: type === 'page' },
),
);
const queryQuery = useQuery(
trpc.gsc.getQueryDetails.queryOptions(
{ projectId, query: value, ...dateInput },
{ enabled: type === 'query' },
),
);
const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const breakdownRows: Record<string, string | number>[] =
type === 'page'
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
const breakdownKey = type === 'page' ? 'query' : 'page';
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
const pluralLabel = type === 'page' ? 'queries' : 'pages';
const maxClicks = Math.max(
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
1,
);
return (
<div className="card overflow-hidden">
<div className="border-b p-4">
<h3 className="font-medium text-sm">Top {pluralLabel}</h3>
</div>
{isLoading ? (
<OverviewWidgetTable
data={[1, 2, 3, 4, 5]}
keyExtractor={(i) => String(i)}
getColumnPercentage={() => 0}
columns={[
{ name: breakdownLabel, width: 'w-full', render: () => <Skeleton className="h-4 w-2/3" /> },
{ name: 'Clicks', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
{ name: 'Impr.', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
{ name: 'CTR', width: '60px', render: () => <Skeleton className="h-4 w-8" /> },
{ name: 'Pos.', width: '55px', render: () => <Skeleton className="h-4 w-8" /> },
]}
/>
) : (
<OverviewWidgetTable
data={breakdownRows}
keyExtractor={(item) => String(item[breakdownKey])}
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
columns={[
{
name: breakdownLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<span className="block truncate font-mono text-xs">
{String(item[breakdownKey])}
</span>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.clicks as number).toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.impressions as number).toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{((item.ctr as number) * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.position as number).toFixed(1)}
</span>
);
},
},
]}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,255 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { AlertCircleIcon, ChevronsUpDownIcon, SearchIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
interface GscCannibalizationProps {
projectId: string;
range: IChartRange;
interval: IInterval;
startDate?: string;
endDate?: string;
}
export function GscCannibalization({
projectId,
range,
interval,
startDate,
endDate,
}: GscCannibalizationProps) {
const trpc = useTRPC();
const { apiUrl } = useAppContext();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const pageSize = 15;
const query = useQuery(
trpc.gsc.getCannibalization.queryOptions(
{
projectId,
range,
interval,
startDate,
endDate,
},
{ placeholderData: keepPreviousData }
)
);
const toggle = (q: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(q)) {
next.delete(q);
} else {
next.add(q);
}
return next;
});
};
const allItems = query.data ?? [];
const items = useMemo(() => {
if (!search.trim()) {
return allItems;
}
const q = search.toLowerCase();
return allItems.filter(
(item) =>
item.query.toLowerCase().includes(q) ||
item.pages.some((p) => p.page.toLowerCase().includes(q))
);
}, [allItems, search]);
const pageCount = Math.ceil(items.length / pageSize) || 1;
useEffect(() => {
setPage((p) => Math.max(0, Math.min(p, pageCount - 1)));
}, [items, pageSize, pageCount]);
const paginatedItems = useMemo(
() => items.slice(page * pageSize, (page + 1) * pageSize),
[items, page, pageSize]
);
const rangeStart = items.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
if (!(query.isLoading || allItems.length)) {
return null;
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
{items.length > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{items.length}
</span>
)}
</div>
{items.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{items.length === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${items.length}`}
</span>
<Pagination
canNextPage={page < pageCount - 1}
canPreviousPage={page > 0}
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
pageIndex={page}
previousPage={() => setPage((p) => Math.max(0, p - 1))}
/>
</div>
)}
</div>
<div className="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search keywords"
type="search"
value={search}
/>
</div>
</div>
<div className="divide-y">
{query.isLoading &&
[1, 2, 3].map((i) => (
<div className="space-y-2 p-4" key={i}>
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
))}
{paginatedItems.map((item) => {
const isOpen = expanded.has(item.query);
const avgCtr =
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
return (
<div key={item.query}>
<button
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/40"
onClick={() => toggle(item.query)}
type="button"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
'row shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs',
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
)}
>
<AlertCircleIcon className="size-3" />
{item.pages.length} pages
</div>
<span className="truncate font-medium text-sm">
{item.query}
</span>
</div>
<div className="flex shrink-0 items-center gap-4">
<span className="whitespace-nowrap font-mono text-muted-foreground text-xs">
{item.totalImpressions.toLocaleString()} impr ·{' '}
{(avgCtr * 100).toFixed(1)}% avg CTR
</span>
<ChevronsUpDownIcon
className={cn(
'size-3.5 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</button>
{isOpen && (
<div className="border-t bg-muted/20 px-4 py-3">
<p className="mb-3 text-muted-foreground text-xs leading-normal">
These pages all rank for{' '}
<span className="font-medium text-foreground">
"{item.query}"
</span>
. Consider consolidating weaker pages into the top-ranking
one to concentrate link equity and avoid splitting clicks.
</p>
<div className="space-y-1.5">
{item.pages.map((page, idx) => {
// Strip hash fragments — GSC sometimes returns heading
// anchor URLs (e.g. /page#section) as separate entries
let cleanUrl = page.page;
let origin = '';
let path = page.page;
try {
const u = new URL(page.page);
u.hash = '';
cleanUrl = u.toString();
origin = u.origin;
path = u.pathname + u.search;
} catch {
cleanUrl = page.page.split('#')[0] ?? page.page;
}
const isWinner = idx === 0;
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
key={page.page}
onClick={() =>
pushModal('PageDetails', {
type: 'page',
projectId,
value: cleanUrl,
})
}
type="button"
>
<img
alt=""
className="size-3.5 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
'none';
}}
src={`${apiUrl}/misc/favicon?url=${origin}`}
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{path || page.page}
</span>
{isWinner && (
<span className="shrink-0 rounded bg-emerald-100 px-1 py-0.5 font-medium text-emerald-700 text-xs dark:bg-emerald-900/30 dark:text-emerald-400">
#1
</span>
)}
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
pos {page.position.toFixed(1)} ·{' '}
{(page.ctr * 100).toFixed(1)}% CTR ·{' '}
{page.impressions.toLocaleString()} impr
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface ChartData {
date: string;
clicks: number;
impressions: number;
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
{ formatDate: (date: Date | string) => string }
>(({ data, context }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{context.formatDate(item.date)}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{item.clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{item.impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
interface GscClicksChartProps {
projectId: string;
value: string;
type: 'page' | 'query';
}
export function GscClicksChart({
projectId,
value,
type,
}: GscClicksChartProps) {
const { range, startDate, endDate, interval } = useOverviewOptions();
const trpc = useTRPC();
const yAxisProps = useYAxisProps();
const formatDateShort = useFormatDateInterval({ interval, short: true });
const formatDateLong = useFormatDateInterval({ interval, short: false });
const dateInput = {
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
};
const pageQuery = useQuery(
trpc.gsc.getPageDetails.queryOptions(
{ projectId, page: value, ...dateInput },
{ enabled: type === 'page' }
)
);
const queryQuery = useQuery(
trpc.gsc.getQueryDetails.queryOptions(
{ projectId, query: value, ...dateInput },
{ enabled: type === 'query' }
)
);
const isLoading =
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const timeseries =
(type === 'page'
? pageQuery.data?.timeseries
: queryQuery.data?.timeseries) ?? [];
const data: ChartData[] = timeseries.map((r) => ({
date: r.date,
clicks: r.clicks,
impressions: r.impressions,
}));
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">Clicks & Impressions</h3>
<div className="flex items-center gap-4 text-muted-foreground text-xs">
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(0) }}
/>
Clicks
</span>
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(1) }}
/>
Impressions
</span>
</div>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider formatDate={formatDateLong}>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-clicks-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => formatDateShort(v)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<Tooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-clicks-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-clicks-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,228 @@
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { getChartColor } from '@/utils/theme';
// Industry average CTR by position (Google organic)
const BENCHMARK: Record<number, number> = {
1: 28.5,
2: 15.7,
3: 11.0,
4: 8.0,
5: 6.3,
6: 5.0,
7: 4.0,
8: 3.3,
9: 2.8,
10: 2.5,
11: 2.2,
12: 2.0,
13: 1.8,
14: 1.5,
15: 1.2,
16: 1.1,
17: 1.0,
18: 0.9,
19: 0.8,
20: 0.7,
};
interface PageEntry {
path: string;
ctr: number;
impressions: number;
}
interface ChartData {
position: number;
yourCtr: number | null;
benchmark: number;
pages: PageEntry[];
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>Position #{item.position}</div>
</ChartTooltipHeader>
{item.yourCtr != null && (
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Your avg CTR</span>
<span>{item.yourCtr.toFixed(1)}%</span>
</div>
</ChartTooltipItem>
)}
<ChartTooltipItem color={getChartColor(3)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Benchmark</span>
<span>{item.benchmark.toFixed(1)}%</span>
</div>
</ChartTooltipItem>
{item.pages.length > 0 && (
<div className="mt-1.5 border-t pt-1.5">
{item.pages.map((p) => (
<div
className="flex items-center justify-between gap-4 py-0.5"
key={p.path}
>
<span className="max-w-40 truncate font-mono text-muted-foreground text-xs">
{p.path}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{(p.ctr * 100).toFixed(1)}%
</span>
</div>
))}
</div>
)}
</>
);
});
interface GscCtrBenchmarkProps {
data: Array<{
page: string;
position: number;
ctr: number;
impressions: number;
}>;
isLoading: boolean;
}
export function GscCtrBenchmark({ data, isLoading }: GscCtrBenchmarkProps) {
const yAxisProps = useYAxisProps();
const grouped = new Map<number, { ctrSum: number; pages: PageEntry[] }>();
for (const d of data) {
const pos = Math.round(d.position);
if (pos < 1 || pos > 20 || d.impressions < 10) {
continue;
}
let path = d.page;
try {
path = new URL(d.page).pathname;
} catch {
// keep as-is
}
const entry = grouped.get(pos) ?? { ctrSum: 0, pages: [] };
entry.ctrSum += d.ctr * 100;
entry.pages.push({ path, ctr: d.ctr, impressions: d.impressions });
grouped.set(pos, entry);
}
const chartData: ChartData[] = Array.from({ length: 20 }, (_, i) => {
const pos = i + 1;
const entry = grouped.get(pos);
const pages = entry
? [...entry.pages].sort((a, b) => b.ctr - a.ctr).slice(0, 5)
: [];
return {
position: pos,
yourCtr: entry ? entry.ctrSum / entry.pages.length : null,
benchmark: BENCHMARK[pos] ?? 0,
pages,
};
});
const hasAnyData = chartData.some((d) => d.yourCtr != null);
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">CTR vs Position</h3>
<div className="flex items-center gap-4 text-muted-foreground text-xs">
{hasAnyData && (
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(0) }}
/>
Your CTR
</span>
)}
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full opacity-60"
style={{ backgroundColor: getChartColor(3) }}
/>
Benchmark
</span>
</div>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={chartData}>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="position"
domain={[1, 20]}
tickFormatter={(v: number) => `#${v}`}
ticks={[1, 5, 10, 15, 20]}
type="number"
/>
<YAxis
{...yAxisProps}
domain={[0, 'auto']}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip />
<Line
activeDot={{ r: 4 }}
connectNulls={false}
dataKey="yourCtr"
dot={{ r: 3, fill: getChartColor(0) }}
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
/>
<Line
dataKey="benchmark"
dot={false}
isAnimationActive={false}
stroke={getChartColor(3)}
strokeDasharray="4 3"
strokeOpacity={0.6}
strokeWidth={1.5}
type="monotone"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { getChartColor } from '@/utils/theme';
interface ChartData {
date: string;
position: number;
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) return null;
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(2)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Avg Position</span>
<span>{item.position.toFixed(1)}</span>
</div>
</ChartTooltipItem>
</>
);
});
interface GscPositionChartProps {
data: Array<{ date: string; position: number }>;
isLoading: boolean;
}
export function GscPositionChart({ data, isLoading }: GscPositionChartProps) {
const yAxisProps = useYAxisProps();
const chartData: ChartData[] = data.map((r) => ({
date: r.date,
position: r.position,
}));
const positions = chartData.map((d) => d.position).filter((p) => p > 0);
const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1;
const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20;
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">Avg Position</h3>
<span className="text-muted-foreground text-xs">Lower is better</span>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={chartData}>
<defs>
<filter
height="140%"
id="gsc-pos-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis
{...yAxisProps}
domain={[minPos, maxPos]}
reversed
tickFormatter={(v: number) => `#${v}`}
/>
<Tooltip />
<Line
dataKey="position"
dot={false}
filter="url(#gsc-pos-glow)"
isAnimationActive={false}
stroke={getChartColor(2)}
strokeWidth={2}
type="monotone"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface ChartData {
date: string;
pageviews: number;
sessions: number;
}
const { TooltipProvider, Tooltip } = createChartTooltip<
ChartData,
{ formatDate: (date: Date | string) => string }
>(({ data, context }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{context.formatDate(item.date)}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Views</span>
<span>{item.pageviews.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Sessions</span>
<span>{item.sessions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
interface PageViewsChartProps {
projectId: string;
origin: string;
path: string;
}
export function PageViewsChart({
projectId,
origin,
path,
}: PageViewsChartProps) {
const { range, interval } = useOverviewOptions();
const trpc = useTRPC();
const yAxisProps = useYAxisProps();
const formatDateShort = useFormatDateInterval({ interval, short: true });
const formatDateLong = useFormatDateInterval({ interval, short: false });
const query = useQuery(
trpc.event.pageTimeseries.queryOptions({
projectId,
range,
interval,
origin,
path,
})
);
const data: ChartData[] = (query.data ?? []).map((r) => ({
date: r.date,
pageviews: r.pageviews,
sessions: r.sessions,
}));
return (
<div className="card p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-sm">Views & Sessions</h3>
<div className="flex items-center gap-4 text-muted-foreground text-xs">
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(0) }}
/>
Views
</span>
<span className="flex items-center gap-1.5">
<span
className="h-0.5 w-3 rounded-full"
style={{ backgroundColor: getChartColor(1) }}
/>
Sessions
</span>
</div>
</div>
{query.isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<TooltipProvider formatDate={formatDateLong}>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="page-views-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => formatDateShort(v)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<Tooltip />
<Line
dataKey="pageviews"
dot={false}
filter="url(#page-views-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="sessions"
dot={false}
filter="url(#page-views-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,360 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import {
AlertTriangleIcon,
EyeIcon,
MousePointerClickIcon,
SearchIcon,
TrendingUpIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
type InsightType =
| 'low_ctr'
| 'near_page_one'
| 'invisible_clicks'
| 'high_bounce';
interface PageInsight {
page: string;
origin: string;
path: string;
type: InsightType;
impact: number;
headline: string;
suggestion: string;
metrics: string;
}
const INSIGHT_CONFIG: Record<
InsightType,
{ label: string; icon: React.ElementType; color: string; bg: string }
> = {
low_ctr: {
label: 'Low CTR',
icon: MousePointerClickIcon,
color: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-100 dark:bg-amber-900/30',
},
near_page_one: {
label: 'Near page 1',
icon: TrendingUpIcon,
color: 'text-blue-600 dark:text-blue-400',
bg: 'bg-blue-100 dark:bg-blue-900/30',
},
invisible_clicks: {
label: 'Low visibility',
icon: EyeIcon,
color: 'text-violet-600 dark:text-violet-400',
bg: 'bg-violet-100 dark:bg-violet-900/30',
},
high_bounce: {
label: 'High bounce',
icon: AlertTriangleIcon,
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-100 dark:bg-red-900/30',
},
};
interface PagesInsightsProps {
projectId: string;
}
export function PagesInsights({ projectId }: PagesInsightsProps) {
const trpc = useTRPC();
const { range, interval, startDate, endDate } = useOverviewOptions();
const { apiUrl } = useAppContext();
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const pageSize = 8;
const dateInput = {
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
};
const gscPagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{ projectId, ...dateInput, limit: 1000 },
{ placeholderData: keepPreviousData }
)
);
const analyticsQuery = useQuery(
trpc.event.pages.queryOptions(
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval },
{ placeholderData: keepPreviousData }
)
);
const insights = useMemo<PageInsight[]>(() => {
const gscPages = gscPagesQuery.data ?? [];
const analyticsPages = analyticsQuery.data ?? [];
const analyticsMap = new Map(
analyticsPages.map((p) => [p.origin + p.path, p])
);
const results: PageInsight[] = [];
for (const gsc of gscPages) {
let origin = '';
let path = gsc.page;
try {
const url = new URL(gsc.page);
origin = url.origin;
path = url.pathname + url.search;
} catch {
// keep as-is
}
const analytics = analyticsMap.get(gsc.page);
// 1. Low CTR: ranking on page 1 but click rate is poor
if (gsc.position <= 10 && gsc.ctr < 0.04 && gsc.impressions >= 100) {
results.push({
page: gsc.page,
origin,
path,
type: 'low_ctr',
impact: gsc.impressions * (0.04 - gsc.ctr),
headline: `Ranking #${Math.round(gsc.position)} but only ${(gsc.ctr * 100).toFixed(1)}% CTR`,
suggestion:
'You are on page 1 but people rarely click. Rewrite your title tag and meta description to be more compelling and match search intent.',
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${(gsc.ctr * 100).toFixed(1)}% CTR`,
});
}
// 2. Near page 1: just off the first page with decent visibility
if (gsc.position > 10 && gsc.position <= 20 && gsc.impressions >= 100) {
results.push({
page: gsc.page,
origin,
path,
type: 'near_page_one',
impact: gsc.impressions / gsc.position,
headline: `Position ${Math.round(gsc.position)} — one push from page 1`,
suggestion:
'A content refresh, more internal links, or a few backlinks could move this into the top 10 and dramatically increase clicks.',
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks`,
});
}
// 3. Invisible clicks: high impressions but barely any clicks
if (gsc.impressions >= 500 && gsc.ctr < 0.01 && gsc.position > 10) {
results.push({
page: gsc.page,
origin,
path,
type: 'invisible_clicks',
impact: gsc.impressions,
headline: `${gsc.impressions.toLocaleString()} impressions but only ${gsc.clicks} clicks`,
suggestion:
'Google shows this page a lot, but it almost never gets clicked. Consider whether the page targets the right queries or if a different format (e.g. listicle, how-to) would perform better.',
metrics: `${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks · Pos ${Math.round(gsc.position)}`,
});
}
// 4. High bounce: good traffic but poor engagement (requires analytics match)
if (
analytics &&
analytics.bounce_rate >= 70 &&
analytics.sessions >= 20
) {
results.push({
page: gsc.page,
origin,
path,
type: 'high_bounce',
impact: analytics.sessions * (analytics.bounce_rate / 100),
headline: `${Math.round(analytics.bounce_rate)}% bounce rate on a page with ${analytics.sessions} sessions`,
suggestion:
'Visitors are leaving without engaging. Check if the page delivers on its title/meta promise, improve page speed, and make sure key content is above the fold.',
metrics: `${Math.round(analytics.bounce_rate)}% bounce · ${analytics.sessions} sessions · ${gsc.impressions.toLocaleString()} impr`,
});
}
}
// Also check analytics pages without GSC match for high bounce
for (const p of analyticsPages) {
const fullUrl = p.origin + p.path;
if (
!gscPagesQuery.data?.some((g) => g.page === fullUrl) &&
p.bounce_rate >= 75 &&
p.sessions >= 30
) {
results.push({
page: fullUrl,
origin: p.origin,
path: p.path,
type: 'high_bounce',
impact: p.sessions * (p.bounce_rate / 100),
headline: `${Math.round(p.bounce_rate)}% bounce rate with ${p.sessions} sessions`,
suggestion:
'High bounce rate with no search visibility. Review content quality and check if the page is indexed and targeting the right keywords.',
metrics: `${Math.round(p.bounce_rate)}% bounce · ${p.sessions} sessions`,
});
}
}
// Dedupe by (page, type), keep highest impact
const seen = new Set<string>();
const deduped = results.filter((r) => {
const key = `${r.page}::${r.type}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return deduped.sort((a, b) => b.impact - a.impact);
}, [gscPagesQuery.data, analyticsQuery.data]);
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
const filteredInsights = useMemo(() => {
if (!search.trim()) {
return insights;
}
const q = search.toLowerCase();
return insights.filter(
(i) =>
i.path.toLowerCase().includes(q) || i.page.toLowerCase().includes(q)
);
}, [insights, search]);
const pageCount = Math.ceil(filteredInsights.length / pageSize) || 1;
const paginatedInsights = useMemo(
() => filteredInsights.slice(page * pageSize, (page + 1) * pageSize),
[filteredInsights, page, pageSize]
);
const rangeStart = filteredInsights.length ? page * pageSize + 1 : 0;
const rangeEnd = Math.min((page + 1) * pageSize, filteredInsights.length);
if (!(isLoading || insights.length)) {
return null;
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">Opportunities</h3>
{filteredInsights.length > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
{filteredInsights.length}
</span>
)}
</div>
{filteredInsights.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{filteredInsights.length === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${filteredInsights.length}`}
</span>
<Pagination
canNextPage={page < pageCount - 1}
canPreviousPage={page > 0}
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
pageIndex={page}
previousPage={() => setPage((p) => Math.max(0, p - 1))}
/>
</div>
)}
</div>
<div className="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search pages"
type="search"
value={search}
/>
</div>
</div>
<div className="divide-y">
{isLoading &&
[1, 2, 3, 4].map((i) => (
<div className="flex items-start gap-3 p-4" key={i}>
<div className="mt-0.5 h-7 w-20 animate-pulse rounded-md bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted" />
</div>
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
))}
{paginatedInsights.map((insight, i) => {
const config = INSIGHT_CONFIG[insight.type];
const Icon = config.icon;
return (
<button
className="flex w-full items-start gap-3 p-4 text-left transition-colors hover:bg-muted/40"
key={`${insight.page}-${insight.type}-${i}`}
onClick={() =>
pushModal('PageDetails', {
type: 'page',
projectId,
value: insight.page,
})
}
type="button"
>
<div className="col min-w-0 flex-1 gap-2">
<span
className={cn(
'row shrink-0 items-center gap-1 self-start rounded-md px-1 py-0.5 font-medium text-xs',
config.color,
config.bg
)}
>
<Icon className="size-3" />
{config.label}
</span>
<div className="flex items-center gap-2">
<img
alt=""
className="size-3.5 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
src={`${apiUrl}/misc/favicon?url=${insight.origin}`}
/>
<span className="truncate font-medium font-mono text-xs">
{insight.path || insight.page}
</span>
<span className="ml-auto shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
{insight.metrics}
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
<span className="font-medium text-foreground">
{insight.headline}.
</span>{' '}
{insight.suggestion}
</p>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query';
import { Tooltiper } from '../ui/tooltip';
import { LazyComponent } from '@/components/lazy-component';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useTRPC } from '@/integrations/trpc/react';
interface SparklineBarsProps {
data: { date: string; pageviews: number }[];
}
const defaultGap = 1;
const height = 24;
const width = 100;
function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' {
const n = data.length;
if (n < 3) {
return '→';
}
const third = Math.max(1, Math.floor(n / 3));
const firstAvg =
data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third;
const lastAvg =
data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third;
const threshold = firstAvg * 0.05;
if (lastAvg - firstAvg > threshold) {
return '↑';
}
if (firstAvg - lastAvg > threshold) {
return '↓';
}
return '→';
}
function SparklineBars({ data }: SparklineBarsProps) {
if (!data.length) {
return <div style={{ height, width }} />;
}
const max = Math.max(...data.map((d) => d.pageviews), 1);
const total = data.length;
// Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible
let gap = defaultGap;
let barW = Math.floor((width - gap * (total - 1)) / total);
if (barW < 1 && total > 1) {
gap = 0;
barW = Math.floor((width - gap * (total - 1)) / total);
}
if (barW < 1) {
barW = 1;
}
const trend = getTrendDirection(data);
const trendColor =
trend === '↑'
? 'text-emerald-500'
: trend === '↓'
? 'text-red-500'
: 'text-muted-foreground';
return (
<div className="flex items-center gap-1.5">
<svg className="shrink-0" height={height} width={width}>
{data.map((d, i) => {
const barH = Math.max(
2,
Math.round((d.pageviews / max) * (height * 0.8))
);
return (
<rect
className="fill-chart-0"
height={barH}
key={d.date}
rx="1"
width={barW}
x={i * (barW + gap)}
y={height - barH}
/>
);
})}
</svg>
<Tooltiper
content={
trend === '↑'
? 'Upward trend'
: trend === '↓'
? 'Downward trend'
: 'Stable trend'
}
>
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
{trend}
</span>
</Tooltiper>
</div>
);
}
interface PageSparklineProps {
projectId: string;
origin: string;
path: string;
}
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
const { range, interval } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.event.pageTimeseries.queryOptions({
projectId,
range,
interval,
origin,
path,
})
);
return (
<LazyComponent fallback={<div style={{ height, width }} />}>
<SparklineBars data={query.data ?? []} />
</LazyComponent>
);
}

View File

@@ -0,0 +1,206 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ExternalLinkIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PageSparkline } from '@/components/pages/page-sparkline';
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
import { useAppContext } from '@/hooks/use-app-context';
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
export type PageRow = RouterOutputs['event']['pages'][number] & {
gsc?: { clicks: number; impressions: number; ctr: number; position: number };
};
export function useColumns({
projectId,
isGscConnected,
previousMap,
}: {
projectId: string;
isGscConnected: boolean;
previousMap?: Map<string, number>;
}): ColumnDef<PageRow>[] {
const number = useNumber();
const { apiUrl } = useAppContext();
return useMemo<ColumnDef<PageRow>[]>(() => {
const cols: ColumnDef<PageRow>[] = [
{
id: 'page',
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
header: createHeaderColumn('Page'),
size: 400,
meta: { bold: true },
cell: ({ row }) => {
const page = row.original;
return (
<div className="flex min-w-0 items-center gap-3">
<img
alt=""
className="size-4 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
/>
<div className="min-w-0">
{page.title && (
<div className="truncate font-medium text-sm leading-tight">
{page.title}
</div>
)}
<div className="flex min-w-0 items-center gap-1">
<span className="truncate font-mono text-muted-foreground text-xs">
{page.path}
</span>
<a
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
href={page.origin + page.path}
onClick={(e) => e.stopPropagation()}
rel="noreferrer noopener"
target="_blank"
>
<ExternalLinkIcon className="size-3 text-muted-foreground" />
</a>
</div>
</div>
</div>
);
},
},
{
id: 'trend',
header: 'Trend',
enableSorting: false,
size: 96,
cell: ({ row }) => (
<PageSparkline
origin={row.original.origin}
path={row.original.path}
projectId={projectId}
/>
),
},
{
accessorKey: 'pageviews',
header: createHeaderColumn('Views'),
size: 80,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.pageviews)}
</span>
),
},
{
accessorKey: 'sessions',
header: createHeaderColumn('Sessions'),
size: 90,
cell: ({ row }) => {
const prev = previousMap?.get(
row.original.origin + row.original.path
);
if (prev == null) {
return <span className="text-muted-foreground"></span>;
}
if (prev === 0) {
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span className="text-muted-foreground">new</span>
</div>
);
}
const pct = ((row.original.sessions - prev) / prev) * 100;
const isPos = pct >= 0;
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
>
{isPos ? '+' : ''}
{pct.toFixed(1)}%
</span>
</div>
);
},
},
{
accessorKey: 'bounce_rate',
header: createHeaderColumn('Bounce'),
size: 80,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{row.original.bounce_rate.toFixed(0)}%
</span>
),
},
{
accessorKey: 'avg_duration',
header: createHeaderColumn('Duration'),
size: 90,
cell: ({ row }) => (
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
{fancyMinutes(row.original.avg_duration)}
</span>
),
},
];
if (isGscConnected) {
cols.push(
{
id: 'gsc_impressions',
accessorFn: (row) => row.gsc?.impressions ?? 0,
header: createHeaderColumn('Impr.'),
size: 80,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.gsc.impressions)}
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'gsc_ctr',
accessorFn: (row) => row.gsc?.ctr ?? 0,
header: createHeaderColumn('CTR'),
size: 70,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{(row.original.gsc.ctr * 100).toFixed(1)}%
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'gsc_clicks',
accessorFn: (row) => row.gsc?.clicks ?? 0,
header: createHeaderColumn('Clicks'),
size: 80,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.gsc.clicks)}
</span>
) : (
<span className="text-muted-foreground"></span>
),
}
);
}
return cols;
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
}

View File

@@ -0,0 +1,143 @@
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useTable } from '@/components/ui/data-table/use-table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { type PageRow, useColumns } from './columns';
interface PagesTableProps {
projectId: string;
}
export function PagesTable({ projectId }: PagesTableProps) {
const trpc = useTRPC();
const { range, interval, startDate, endDate } = useOverviewOptions();
const { debouncedSearch, setSearch, search } = useSearchQueryState();
const pagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
search: debouncedSearch ?? undefined,
range,
interval,
},
{ placeholderData: keepPreviousData },
),
);
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId }),
);
const isGscConnected = !!(connectionQuery.data?.siteUrl);
const gscPagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{
projectId,
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
limit: 10_000,
},
{ enabled: isGscConnected },
),
);
const previousPagesQuery = useQuery(
trpc.event.previousPages.queryOptions(
{ projectId, range, interval },
{ placeholderData: keepPreviousData },
),
);
const previousMap = useMemo(() => {
const map = new Map<string, number>();
for (const p of previousPagesQuery.data ?? []) {
map.set(p.origin + p.path, p.sessions);
}
return map;
}, [previousPagesQuery.data]);
const gscMap = useMemo(() => {
const map = new Map<
string,
{ clicks: number; impressions: number; ctr: number; position: number }
>();
for (const row of gscPagesQuery.data ?? []) {
map.set(row.page, {
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
});
}
return map;
}, [gscPagesQuery.data]);
const rawData: PageRow[] = useMemo(() => {
return (pagesQuery.data ?? []).map((p) => ({
...p,
gsc: gscMap.get(p.origin + p.path),
}));
}, [pagesQuery.data, gscMap]);
const columns = useColumns({ projectId, isGscConnected, previousMap });
const { table } = useTable({
columns,
data: rawData,
loading: pagesQuery.isLoading,
pageSize: 50,
name: 'pages',
});
return (
<>
<DataTableToolbarContainer>
<AnimatedSearchInput
placeholder="Search pages"
value={search ?? ''}
onChange={setSearch}
/>
<div className="flex items-center gap-2">
<OverviewRange />
<OverviewInterval />
<DataTableViewOptions table={table} />
</div>
</DataTableToolbarContainer>
<DataTable
table={table}
loading={pagesQuery.isLoading}
empty={{
title: 'No pages',
description: debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web SDK to your site to get pages here.',
}}
onRowClick={(row) => {
if (!isGscConnected) {
return;
}
const page = row.original;
pushModal('PageDetails', {
type: 'page',
projectId,
value: page.origin + page.path,
});
}}
/>
</>
);
}

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

@@ -144,6 +144,7 @@ const data = {
"dropbox": "https://www.dropbox.com", "dropbox": "https://www.dropbox.com",
"openai": "https://openai.com", "openai": "https://openai.com",
"chatgpt.com": "https://chatgpt.com", "chatgpt.com": "https://chatgpt.com",
"copilot.com": "https://www.copilot.com",
"mailchimp": "https://mailchimp.com", "mailchimp": "https://mailchimp.com",
"activecampaign": "https://www.activecampaign.com", "activecampaign": "https://www.activecampaign.com",
"customer.io": "https://customer.io", "customer.io": "https://customer.io",

View File

@@ -1,4 +1,7 @@
import { ReportChart } from '@/components/report-chart'; import type { IServiceReport } from '@openpanel/db';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import EditReportName from '../report/edit-report-name';
import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType'; import { ReportLineType } from '@/components/report/ReportLineType';
@@ -14,18 +17,13 @@ import {
setReport, setReport,
} from '@/components/report/reportSlice'; } from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar'; import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { ReportChart } from '@/components/report-chart';
import { TimeWindowPicker } from '@/components/time-window-picker'; import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
import EditReportName from '../report/edit-report-name';
interface ReportEditorProps { interface ReportEditorProps {
report: IServiceReport | null; report: IServiceReport | null;
@@ -54,15 +52,15 @@ export default function ReportEditor({
return ( return (
<Sheet> <Sheet>
<div> <div>
<div className="p-4 flex items-center justify-between"> <div className="flex items-center justify-between p-4">
<EditReportName /> <EditReportName />
{initialReport?.id && ( {initialReport?.id && (
<Button <Button
variant="outline"
icon={ShareIcon} icon={ShareIcon}
onClick={() => onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id }) pushModal('ShareReportModal', { reportId: initialReport.id })
} }
variant="outline"
> >
Share Share
</Button> </Button>
@@ -71,9 +69,9 @@ export default function ReportEditor({
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6"> <div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button
className="self-start"
icon={GanttChartSquareIcon} icon={GanttChartSquareIcon}
variant="cta" variant="cta"
className="self-start"
> >
Pick events Pick events
</Button> </Button>
@@ -88,23 +86,26 @@ export default function ReportEditor({
/> />
<TimeWindowPicker <TimeWindowPicker
className="min-w-0 flex-1" className="min-w-0 flex-1"
endDate={report.endDate}
onChange={(value) => { onChange={(value) => {
dispatch(changeDateRanges(value)); dispatch(changeDateRanges(value));
}} }}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))} onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate} onIntervalChange={(interval) =>
dispatch(changeInterval(interval))
}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
startDate={report.startDate} startDate={report.startDate}
value={report.range}
/> />
<ReportInterval <ReportInterval
chartType={report.chartType}
className="min-w-0 flex-1" className="min-w-0 flex-1"
endDate={report.endDate}
interval={report.interval} interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))} onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range} range={report.range}
chartType={report.chartType}
startDate={report.startDate} startDate={report.startDate}
endDate={report.endDate}
/> />
<ReportLineType className="min-w-0 flex-1" /> <ReportLineType className="min-w-0 flex-1" />
</div> </div>
@@ -114,7 +115,7 @@ export default function ReportEditor({
</div> </div>
<div className="flex flex-col gap-4 p-4" id="report-editor"> <div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && ( {report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode /> <ReportChart isEditMode report={{ ...report, projectId }} />
)} )}
</div> </div>
</div> </div>

View File

@@ -14,9 +14,11 @@ import {
LayoutDashboardIcon, LayoutDashboardIcon,
LayoutPanelTopIcon, LayoutPanelTopIcon,
PlusIcon, PlusIcon,
SearchIcon,
SparklesIcon, SparklesIcon,
TrendingUpDownIcon, TrendingUpDownIcon,
UndoDotIcon, UndoDotIcon,
UserCircleIcon,
UsersIcon, UsersIcon,
WallpaperIcon, WallpaperIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -55,10 +57,11 @@ export default function SidebarProjectMenu({
label="Insights" label="Insights"
/> />
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" /> <SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" /> <SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
<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={UsersIcon} label="Profiles" /> <SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
<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,3 +1,9 @@
import { timeWindows } from '@openpanel/constants';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { bind } from 'bind-event-listener';
import { endOfDay, format, startOfDay } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -11,24 +17,18 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { pushModal, useOnPushModal } from '@/modals'; import { pushModal, useOnPushModal } from '@/modals';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { CalendarIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress'; import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress';
import { timeWindows } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import { endOfDay, format, startOfDay } from 'date-fns';
type Props = { interface Props {
value: IChartRange; value: IChartRange;
onChange: (value: IChartRange) => void; onChange: (value: IChartRange) => void;
onStartDateChange: (date: string) => void; onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void; onEndDateChange: (date: string) => void;
onIntervalChange: (interval: IInterval) => void;
endDate: string | null; endDate: string | null;
startDate: string | null; startDate: string | null;
className?: string; className?: string;
}; }
export function TimeWindowPicker({ export function TimeWindowPicker({
value, value,
onChange, onChange,
@@ -36,6 +36,7 @@ export function TimeWindowPicker({
onStartDateChange, onStartDateChange,
endDate, endDate,
onEndDateChange, onEndDateChange,
onIntervalChange,
className, className,
}: Props) { }: Props) {
const isDateRangerPickerOpen = useRef(false); const isDateRangerPickerOpen = useRef(false);
@@ -46,10 +47,11 @@ export function TimeWindowPicker({
const handleCustom = useCallback(() => { const handleCustom = useCallback(() => {
pushModal('DateRangerPicker', { pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => { onChange: ({ startDate, endDate, interval }) => {
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss')); onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss')); onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
onChange('custom'); onChange('custom');
onIntervalChange(interval);
}, },
startDate: startDate ? new Date(startDate) : undefined, startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined, endDate: endDate ? new Date(endDate) : undefined,
@@ -69,7 +71,7 @@ export function TimeWindowPicker({
} }
const match = Object.values(timeWindows).find( const match = Object.values(timeWindows).find(
(tw) => event.key === tw.shortcut.toLowerCase(), (tw) => event.key === tw.shortcut.toLowerCase()
); );
if (match?.key === 'custom') { if (match?.key === 'custom') {
handleCustom(); handleCustom();
@@ -84,9 +86,9 @@ export function TimeWindowPicker({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline"
icon={CalendarIcon}
className={cn('justify-start', className)} className={cn('justify-start', className)}
icon={CalendarIcon}
variant="outline"
> >
{timeWindow?.label} {timeWindow?.label}
</Button> </Button>

View File

@@ -9,7 +9,6 @@ import {
DayPicker, DayPicker,
getDefaultClassNames, getDefaultClassNames,
} from 'react-day-picker'; } from 'react-day-picker';
import { Button, buttonVariants } from '@/components/ui/button'; import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -29,99 +28,93 @@ function Calendar({
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} captionLayout={captionLayout}
className={cn( className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', 'group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className, className
)} )}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{ classNames={{
root: cn('w-fit', defaultClassNames.root), root: cn('w-fit', defaultClassNames.root),
months: cn( months: cn(
'flex gap-4 flex-col sm:flex-row relative', 'relative flex flex-col gap-4 sm:flex-row',
defaultClassNames.months, defaultClassNames.months
), ),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month), month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn( nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between', 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav, defaultClassNames.nav
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', 'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_previous, defaultClassNames.button_previous
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', 'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_next, defaultClassNames.button_next
), ),
month_caption: cn( month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)', 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption, defaultClassNames.month_caption
), ),
dropdowns: cn( dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5', 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
defaultClassNames.dropdowns, defaultClassNames.dropdowns
), ),
dropdown_root: cn( dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md', 'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
defaultClassNames.dropdown_root, defaultClassNames.dropdown_root
), ),
dropdown: cn( dropdown: cn(
'absolute bg-popover inset-0 opacity-0', 'absolute inset-0 bg-popover opacity-0',
defaultClassNames.dropdown, defaultClassNames.dropdown
), ),
caption_label: cn( caption_label: cn(
'select-none font-medium', 'select-none font-medium',
captionLayout === 'label' captionLayout === 'label'
? 'text-sm' ? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', : 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label, defaultClassNames.caption_label
), ),
table: 'w-full border-collapse', table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays), weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn( weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none', 'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
defaultClassNames.weekday, defaultClassNames.weekday
), ),
week: cn('flex w-full mt-2', defaultClassNames.week), week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn( week_number_header: cn(
'select-none w-(--cell-size)', 'w-(--cell-size) select-none',
defaultClassNames.week_number_header, defaultClassNames.week_number_header
), ),
week_number: cn( week_number: cn(
'text-[0.8rem] select-none text-muted-foreground', 'select-none text-[0.8rem] text-muted-foreground',
defaultClassNames.week_number, defaultClassNames.week_number
), ),
day: cn( day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none', 'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day, defaultClassNames.day
), ),
range_start: cn( range_start: cn(
'rounded-l-md bg-accent', 'rounded-l-md bg-accent',
defaultClassNames.range_start, defaultClassNames.range_start
), ),
range_middle: cn('rounded-none', defaultClassNames.range_middle), range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn( today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', 'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today, defaultClassNames.today
), ),
outside: cn( outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground', 'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside, defaultClassNames.outside
), ),
disabled: cn( disabled: cn(
'text-muted-foreground opacity-50', 'text-muted-foreground opacity-50',
defaultClassNames.disabled, defaultClassNames.disabled
), ),
hidden: cn('invisible', defaultClassNames.hidden), hidden: cn('invisible', defaultClassNames.hidden),
...classNames, ...classNames,
@@ -130,9 +123,9 @@ function Calendar({
Root: ({ className, rootRef, ...props }) => { Root: ({ className, rootRef, ...props }) => {
return ( return (
<div <div
className={cn(className)}
data-slot="calendar" data-slot="calendar"
ref={rootRef} ref={rootRef}
className={cn(className)}
{...props} {...props}
/> />
); );
@@ -169,6 +162,12 @@ function Calendar({
}, },
...components, ...components,
}} }}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
showOutsideDays={showOutsideDays}
{...props} {...props}
/> />
); );
@@ -184,29 +183,31 @@ function CalendarDayButton({
const ref = React.useRef<HTMLButtonElement>(null); const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (modifiers.focused) ref.current?.focus(); if (modifiers.focused) {
ref.current?.focus();
}
}, [modifiers.focused]); }, [modifiers.focused]);
return ( return (
<Button <Button
ref={ref} className={cn(
variant="ghost" 'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
size="icon" defaultClassNames.day,
className
)}
data-day={day.date.toLocaleDateString()} data-day={day.date.toLocaleDateString()}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
data-range-start={modifiers.range_start}
data-selected-single={ data-selected-single={
modifiers.selected && modifiers.selected &&
!modifiers.range_start && !modifiers.range_start &&
!modifiers.range_end && !modifiers.range_end &&
!modifiers.range_middle !modifiers.range_middle
} }
data-range-start={modifiers.range_start} ref={ref}
data-range-end={modifiers.range_end} size="icon"
data-range-middle={modifiers.range_middle} variant="ghost"
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props} {...props}
/> />
); );

View File

@@ -22,6 +22,7 @@ export interface DataTableProps<TData> {
title: string; title: string;
description: string; description: string;
}; };
onRowClick?: (row: import('@tanstack/react-table').Row<TData>) => void;
} }
declare module '@tanstack/react-table' { declare module '@tanstack/react-table' {
@@ -35,6 +36,7 @@ export function DataTable<TData>({
table, table,
loading, loading,
className, className,
onRowClick,
empty = { empty = {
title: 'No data', title: 'No data',
description: 'We could not find any data here yet', description: 'We could not find any data here yet',
@@ -78,6 +80,8 @@ export function DataTable<TData>({
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell <TableCell

View File

@@ -1,3 +1,5 @@
import { getISOWeek } from 'date-fns';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
export function formatDateInterval(options: { export function formatDateInterval(options: {
@@ -8,15 +10,19 @@ export function formatDateInterval(options: {
const { interval, date, short } = options; const { interval, date, short } = options;
try { try {
if (interval === 'hour' || interval === 'minute') { if (interval === 'hour' || interval === 'minute') {
if (short) {
return new Intl.DateTimeFormat('en-GB', { return new Intl.DateTimeFormat('en-GB', {
...(!short
? {
month: '2-digit',
day: '2-digit',
}
: {}),
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false,
}).format(date);
}
return new Intl.DateTimeFormat('en-GB', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date); }).format(date);
} }
@@ -25,6 +31,9 @@ export function formatDateInterval(options: {
} }
if (interval === 'week') { if (interval === 'week') {
if (short) {
return `W${getISOWeek(date)}`;
}
return new Intl.DateTimeFormat('en-GB', { return new Intl.DateTimeFormat('en-GB', {
weekday: 'short', weekday: 'short',
day: '2-digit', day: '2-digit',
@@ -33,6 +42,12 @@ export function formatDateInterval(options: {
} }
if (interval === 'day') { if (interval === 'day') {
if (short) {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
}).format(date);
}
return new Intl.DateTimeFormat('en-GB', { return new Intl.DateTimeFormat('en-GB', {
weekday: 'short', weekday: 'short',
day: '2-digit', day: '2-digit',
@@ -41,7 +56,7 @@ export function formatDateInterval(options: {
} }
return date.toISOString(); return date.toISOString();
} catch (e) { } catch {
return ''; return '';
} }
} }

View File

@@ -1,20 +1,24 @@
import { getDefaultIntervalByDates } from '@openpanel/constants';
import type { IInterval } from '@openpanel/validation';
import { endOfDay, subMonths } from 'date-fns';
import { CheckIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent } from './Modal/Container';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar'; import { Calendar } from '@/components/ui/calendar';
import { useBreakpoint } from '@/hooks/use-breakpoint'; import { useBreakpoint } from '@/hooks/use-breakpoint';
import { subMonths } from 'date-fns';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { CheckIcon, XIcon } from 'lucide-react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = { interface Props {
onChange: (payload: { startDate: Date; endDate: Date }) => void; onChange: (payload: {
startDate: Date;
endDate: Date;
interval: IInterval;
}) => void;
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
}; }
export default function DateRangerPicker({ export default function DateRangerPicker({
onChange, onChange,
startDate: initialStartDate, startDate: initialStartDate,
@@ -25,20 +29,20 @@ export default function DateRangerPicker({
const [endDate, setEndDate] = useState(initialEndDate); const [endDate, setEndDate] = useState(initialEndDate);
return ( return (
<ModalContent className="p-4 md:p-8 min-w-fit"> <ModalContent className="min-w-fit p-4 md:p-8">
<Calendar <Calendar
captionLayout="dropdown" captionLayout="dropdown"
initialFocus className="mx-auto min-h-[310px] p-0 [&_table]:mx-auto [&_table]:w-auto"
mode="range"
defaultMonth={subMonths( defaultMonth={subMonths(
startDate ? new Date(startDate) : new Date(), startDate ? new Date(startDate) : new Date(),
isBelowSm ? 0 : 1, isBelowSm ? 0 : 1
)} )}
selected={{ hidden={{
from: startDate, after: endOfDay(new Date()),
to: endDate,
}} }}
toDate={new Date()} initialFocus
mode="range"
numberOfMonths={isBelowSm ? 1 : 2}
onSelect={(range) => { onSelect={(range) => {
if (range?.from) { if (range?.from) {
setStartDate(range.from); setStartDate(range.from);
@@ -47,33 +51,39 @@ export default function DateRangerPicker({
setEndDate(range.to); setEndDate(range.to);
} }
}} }}
numberOfMonths={isBelowSm ? 1 : 2} selected={{
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0" from: startDate,
to: endDate,
}}
/> />
<div className="col flex-col-reverse md:row gap-2"> <div className="col md:row flex-col-reverse gap-2">
<Button <Button
icon={XIcon}
onClick={() => popModal()}
type="button" type="button"
variant="outline" variant="outline"
onClick={() => popModal()}
icon={XIcon}
> >
Cancel Cancel
</Button> </Button>
{startDate && endDate && ( {startDate && endDate && (
<Button <Button
type="button"
className="md:ml-auto" className="md:ml-auto"
icon={startDate && endDate ? CheckIcon : XIcon}
onClick={() => { onClick={() => {
popModal(); popModal();
if (startDate && endDate) { if (startDate && endDate) {
onChange({ onChange({
startDate: startDate, startDate,
endDate: endDate, endDate,
interval: getDefaultIntervalByDates(
startDate.toISOString(),
endDate.toISOString()
)!,
}); });
} }
}} }}
icon={startDate && endDate ? CheckIcon : XIcon} type="button"
> >
{startDate && endDate {startDate && endDate
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}` ? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`

View File

@@ -0,0 +1,440 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
interface GscViewsChartData {
date: string;
views: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData | GscViewsChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
if (!('date' in item)) {
return null;
}
if ('views' in item && item.views != null) {
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Views</span>
<span>{item.views.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
}
const clicks = 'clicks' in item ? item.clicks : undefined;
const impressions = 'impressions' in item ? item.impressions : undefined;
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
{clicks != null && (
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
{impressions != null && (
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
</>
);
});
type Props =
| {
type: 'page';
projectId: string;
value: string;
range: IChartRange;
interval: IInterval;
}
| {
type: 'query';
projectId: string;
value: string;
range: IChartRange;
interval: IInterval;
};
export default function GscDetails(props: Props) {
const { type, projectId, value, range, interval } = props;
const trpc = useTRPC();
const dateInput = {
range,
interval,
};
const pageQuery = useQuery(
trpc.gsc.getPageDetails.queryOptions(
{ projectId, page: value, ...dateInput },
{ enabled: type === 'page' }
)
);
const queryQuery = useQuery(
trpc.gsc.getQueryDetails.queryOptions(
{ projectId, query: value, ...dateInput },
{ enabled: type === 'query' }
)
);
const { origin: pageOrigin, path: pagePath } =
type === 'page'
? (() => {
try {
const url = new URL(value);
return { origin: url.origin, path: url.pathname + url.search };
} catch {
return {
origin: typeof window !== 'undefined' ? window.location.origin : '',
path: value,
};
}
})()
: { origin: '', path: '' };
const pageTimeseriesQuery = useQuery(
trpc.event.pageTimeseries.queryOptions(
{ projectId, ...dateInput, origin: pageOrigin, path: pagePath },
{ enabled: type === 'page' && !!pagePath }
)
);
const data = type === 'page' ? pageQuery.data : queryQuery.data;
const isLoading =
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const timeseries = data?.timeseries ?? [];
const pageTimeseries = pageTimeseriesQuery.data ?? [];
const breakdownRows =
type === 'page'
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
: ((data as { pages?: unknown[] } | undefined)?.pages ?? []);
const breakdownKey = type === 'page' ? 'query' : 'page';
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
const maxClicks = Math.max(
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
1
);
return (
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
{value}
</SheetTitle>
</SheetHeader>
<div className="col gap-6">
{type === 'page' && (
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
{isLoading || pageTimeseriesQuery.isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<GscViewsChart
data={pageTimeseries.map((r) => ({
date: r.date,
views: r.pageviews,
}))}
/>
)}
</div>
)}
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<GscTimeseriesChart data={timeseries} />
)}
</div>
<div className="card overflow-hidden">
<div className="border-b p-4">
<h3 className="font-medium text-sm">
Top {breakdownLabel.toLowerCase()}s
</h3>
</div>
{isLoading ? (
<OverviewWidgetTable
columns={[
{
name: breakdownLabel,
width: 'w-full',
render: () => <Skeleton className="h-4 w-2/3" />,
},
{
name: 'Clicks',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'Pos.',
width: '55px',
render: () => <Skeleton className="h-4 w-8" />,
},
]}
data={[1, 2, 3, 4, 5]}
getColumnPercentage={() => 0}
keyExtractor={(i) => String(i)}
/>
) : (
<OverviewWidgetTable
columns={[
{
name: breakdownLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<span className="block truncate font-mono text-xs">
{String(item[breakdownKey])}
</span>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.clicks as number).toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.impressions as number).toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{((item.ctr as number) * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position as number,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.position as number).toFixed(1)}
</span>
);
},
},
]}
data={breakdownRows as Record<string, string | number>[]}
getColumnPercentage={(item) =>
(item.clicks as number) / maxClicks
}
keyExtractor={(item) => String(item[breakdownKey])}
/>
)}
</div>
</div>
</SheetContent>
);
}
function GscViewsChart({
data,
}: {
data: Array<{ date: string; views: number }>;
}) {
const yAxisProps = useYAxisProps();
return (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-detail-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="views"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}
function GscTimeseriesChart({
data,
}: {
data: Array<{ date: string; clicks: number; impressions: number }>;
}) {
const yAxisProps = useYAxisProps();
return (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-detail-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}

View File

@@ -1,3 +1,4 @@
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';
@@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
import { op } from '@/utils/op'; import { op } from '@/utils/op';
const modals = { const modals = {
PageDetails,
OverviewTopPagesModal, OverviewTopPagesModal,
OverviewTopGenericModal, OverviewTopGenericModal,
RequestPasswordReset, RequestPasswordReset,

View File

@@ -0,0 +1,49 @@
import { GscBreakdownTable } from '@/components/page/gsc-breakdown-table';
import { GscClicksChart } from '@/components/page/gsc-clicks-chart';
import { PageViewsChart } from '@/components/page/page-views-chart';
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
type Props = {
type: 'page' | 'query';
projectId: string;
value: string;
};
export default function PageDetails({ type, projectId, value }: Props) {
return (
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
{value}
</SheetTitle>
</SheetHeader>
<div className="col gap-6">
{type === 'page' &&
(() => {
let origin: string;
let path: string;
try {
const url = new URL(value);
origin = url.origin;
path = url.pathname + url.search;
} catch {
// value is path-only (e.g. "/docs/foo")
origin =
typeof window !== 'undefined' ? window.location.origin : '';
path = value;
}
return (
<PageViewsChart
origin={origin}
path={path}
projectId={projectId}
/>
);
})()}
<GscClicksChart projectId={projectId} type={type} value={value} />
<GscBreakdownTable projectId={projectId} type={type} value={value} />
</div>
</SheetContent>
);
}

View File

@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs' import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs' import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions' import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports' import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references' import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime' import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets' import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking' import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports' import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events' import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details' import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients' import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
path: '/sessions', path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdRoute, getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any) } as any)
const AppOrganizationIdProjectIdSeoRoute =
AppOrganizationIdProjectIdSeoRouteImport.update({
id: '/seo',
path: '/seo',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdReportsRoute = const AppOrganizationIdProjectIdReportsRoute =
AppOrganizationIdProjectIdReportsRouteImport.update({ AppOrganizationIdProjectIdReportsRouteImport.update({
id: '/reports', id: '/reports',
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
path: '/imports', path: '/imports',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute, getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
id: '/gsc',
path: '/gsc',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsEventsRoute = const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({ AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
id: '/events', id: '/events',
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute '/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren '/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute '/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute '/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$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
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute '/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute '/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute '/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute '/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$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
@@ -747,6 +765,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute '/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren '/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
@@ -789,6 +808,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute '/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute '/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute '/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute '/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references' | '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports' | '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions' | '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations' | '/$organizationId/integrations'
| '/$organizationId/members' | '/$organizationId/members'
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients' | '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details' | '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events' | '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking' | '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets' | '/$organizationId/$projectId/settings/widgets'
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references' | '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports' | '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions' | '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations' | '/$organizationId/integrations'
| '/$organizationId/members' | '/$organizationId/members'
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients' | '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details' | '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events' | '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking' | '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets' | '/$organizationId/$projectId/settings/widgets'
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/realtime' | '/_app/$organizationId/$projectId/realtime'
| '/_app/$organizationId/$projectId/references' | '/_app/$organizationId/$projectId/references'
| '/_app/$organizationId/$projectId/reports' | '/_app/$organizationId/$projectId/reports'
| '/_app/$organizationId/$projectId/seo'
| '/_app/$organizationId/$projectId/sessions' | '/_app/$organizationId/$projectId/sessions'
| '/_app/$organizationId/integrations' | '/_app/$organizationId/integrations'
| '/_app/$organizationId/integrations/_tabs' | '/_app/$organizationId/integrations/_tabs'
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/clients' | '/_app/$organizationId/$projectId/settings/_tabs/clients'
| '/_app/$organizationId/$projectId/settings/_tabs/details' | '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events' | '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
| '/_app/$organizationId/$projectId/settings/_tabs/imports' | '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/tracking' | '/_app/$organizationId/$projectId/settings/_tabs/tracking'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets' | '/_app/$organizationId/$projectId/settings/_tabs/widgets'
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute parentRoute: typeof AppOrganizationIdProjectIdRoute
} }
'/_app/$organizationId/$projectId/seo': {
id: '/_app/$organizationId/$projectId/seo'
path: '/seo'
fullPath: '/$organizationId/$projectId/seo'
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/reports': { '/_app/$organizationId/$projectId/reports': {
id: '/_app/$organizationId/$projectId/reports' id: '/_app/$organizationId/$projectId/reports'
path: '/reports' path: '/reports'
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
} }
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
path: '/gsc'
fullPath: '/$organizationId/$projectId/settings/gsc'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/events': { '/_app/$organizationId/$projectId/settings/_tabs/events': {
id: '/_app/$organizationId/$projectId/settings/_tabs/events' id: '/_app/$organizationId/$projectId/settings/_tabs/events'
path: '/events' path: '/events'
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsDetailsRoute, AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
AppOrganizationIdProjectIdSettingsTabsEventsRoute: AppOrganizationIdProjectIdSettingsTabsEventsRoute:
AppOrganizationIdProjectIdSettingsTabsEventsRoute, AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsGscRoute:
AppOrganizationIdProjectIdSettingsTabsGscRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute: AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute, AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdReferencesRoute, AppOrganizationIdProjectIdReferencesRoute,
AppOrganizationIdProjectIdReportsRoute: AppOrganizationIdProjectIdReportsRoute:
AppOrganizationIdProjectIdReportsRoute, AppOrganizationIdProjectIdReportsRoute,
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
AppOrganizationIdProjectIdSessionsRoute: AppOrganizationIdProjectIdSessionsRoute:
AppOrganizationIdProjectIdSessionsRoute, AppOrganizationIdProjectIdSessionsRoute,
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute, AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,

View File

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

View File

@@ -1,349 +1,22 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { PagesTable } from '@/components/pages/table';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
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 { FloatingPagination } from '@/components/pagination-floating';
import { ReportChart } from '@/components/report-chart';
import { Skeleton } from '@/components/skeleton';
import { Input } from '@/components/ui/input';
import { TableButtons } from '@/components/ui/table';
import { useAppContext } from '@/hooks/use-app-context';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs';
import { memo, useEffect, useMemo, useState } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({ export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
component: Component, component: Component,
head: () => { head: () => ({
return { meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }],
meta: [ }),
{
title: createProjectTitle(PAGE_TITLES.PAGES),
},
],
};
},
}); });
function Component() { function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const trpc = useTRPC();
const take = 20;
const { range, interval } = useOverviewOptions();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(1),
);
const { debouncedSearch, setSearch, search } = useSearchQueryState();
// Track if we should use backend search (when client-side filtering finds nothing)
const [useBackendSearch, setUseBackendSearch] = useState(false);
// Reset to client-side filtering when search changes
useEffect(() => {
setUseBackendSearch(false);
setCursor(1);
}, [debouncedSearch, setCursor]);
// Query for all pages (without search) - used for client-side filtering
const allPagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: undefined, // No search - get all pages
range,
interval,
},
{
placeholderData: keepPreviousData,
},
),
);
// Query for backend search (only when client-side filtering finds nothing)
const backendSearchQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: debouncedSearch || undefined,
range,
interval,
},
{
placeholderData: keepPreviousData,
enabled: useBackendSearch && !!debouncedSearch,
},
),
);
// Client-side filtering: filter all pages by search query
const clientSideFiltered = useMemo(() => {
if (!debouncedSearch || useBackendSearch) {
return allPagesQuery.data ?? [];
}
const searchLower = debouncedSearch.toLowerCase();
return (allPagesQuery.data ?? []).filter(
(page) =>
page.path.toLowerCase().includes(searchLower) ||
page.origin.toLowerCase().includes(searchLower),
);
}, [allPagesQuery.data, debouncedSearch, useBackendSearch]);
// Check if client-side filtering found results
useEffect(() => {
if (
debouncedSearch &&
!useBackendSearch &&
allPagesQuery.isSuccess &&
clientSideFiltered.length === 0
) {
// No results from client-side filtering, switch to backend search
setUseBackendSearch(true);
}
}, [
debouncedSearch,
useBackendSearch,
allPagesQuery.isSuccess,
clientSideFiltered.length,
]);
// Determine which data source to use
const allData = useBackendSearch
? (backendSearchQuery.data ?? [])
: clientSideFiltered;
const isLoading = useBackendSearch
? backendSearchQuery.isLoading
: allPagesQuery.isLoading;
// Client-side pagination: slice the items based on cursor
const startIndex = (cursor - 1) * take;
const endIndex = startIndex + take;
const data = allData.slice(startIndex, endIndex);
const totalPages = Math.ceil(allData.length / take);
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader title="Pages" description="Access all your pages here" className="mb-8" />
title="Pages" <PagesTable projectId={projectId} />
description="Access all your pages here"
className="mb-8"
/>
<TableButtons>
<OverviewRange />
<OverviewInterval />
<Input
className="self-auto"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(1);
}}
/>
</TableButtons>
{data.length === 0 && !isLoading && (
<FullPageEmptyState
title="No pages"
description={
debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web sdk to your site to get pages here.'
}
/>
)}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PageCardSkeleton />
<PageCardSkeleton />
<PageCardSkeleton />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.map((page) => {
return (
<PageCard
key={page.origin + page.path}
page={page}
range={range}
interval={interval}
projectId={projectId}
/>
);
})}
</div>
{allData.length !== 0 && (
<div className="p-4">
<FloatingPagination
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
canNextPage={cursor < totalPages}
canPreviousPage={cursor > 1}
pageIndex={cursor - 1}
nextPage={() => {
setCursor((p) => Math.min(p + 1, totalPages));
}}
previousPage={() => {
setCursor((p) => Math.max(p - 1, 1));
}}
/>
</div>
)}
</PageContainer> </PageContainer>
); );
} }
const PageCard = memo(
({
page,
range,
interval,
projectId,
}: {
page: RouterOutputs['event']['pages'][number];
range: IChartRange;
interval: IInterval;
projectId: string;
}) => {
const number = useNumber();
const { apiUrl } = useAppContext();
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<img
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
alt={page.title}
className="size-10 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
<div className="col min-w-0">
<div className="font-medium leading-[28px] truncate">
{page.title}
</div>
<a
target="_blank"
rel="noreferrer"
href={`${page.origin}${page.path}`}
className="text-muted-foreground font-mono truncate hover:underline"
>
{page.path}
</a>
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.avg_duration, 'min')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
duration
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.bounce_rate / 100, '%')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
bounce rate
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.format(page.sessions)}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
sessions
</div>
</div>
</div>
<ReportChart
options={{
hideXAxis: true,
hideYAxis: true,
aspectRatio: 0.15,
}}
report={{
breakdowns: [],
metric: 'sum',
range,
interval,
previous: true,
chartType: 'linear',
projectId,
series: [
{
type: 'event',
id: 'A',
name: 'screen_view',
segment: 'event',
filters: [
{
id: 'path',
name: 'path',
value: [page.path],
operator: 'is',
},
{
id: 'origin',
name: 'origin',
value: [page.origin],
operator: 'is',
},
],
},
],
}}
/>
</div>
);
},
);
const PageCardSkeleton = memo(() => {
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<Skeleton className="size-10 rounded-sm" />
<div className="col min-w-0">
<Skeleton className="h-3 w-32 mb-2" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-16 mb-1" />
<Skeleton className="h-4 w-12" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-12 mb-1" />
<Skeleton className="h-4 w-16" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-14 mb-1" />
<Skeleton className="h-4 w-14" />
</div>
</div>
<div className="p-4">
<Skeleton className="h-16 w-full rounded" />
</div>
</div>
);
});

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

@@ -0,0 +1,821 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { GscCannibalization } from '@/components/page/gsc-cannibalization';
import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark';
import { GscPositionChart } from '@/components/page/gsc-position-chart';
import { PagesInsights } from '@/components/page/pages-insights';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Pagination } from '@/components/pagination';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Skeleton } from '@/components/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { getChartColor } from '@/utils/theme';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({
component: SeoPage,
head: () => ({
meta: [{ title: createProjectTitle('SEO') }],
}),
});
interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{item.clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{item.impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
function SeoPage() {
const { projectId, organizationId } = useAppParams();
const trpc = useTRPC();
const navigate = useNavigate();
const { range, startDate, endDate, interval } = useOverviewOptions();
const dateInput = {
range,
interval,
startDate,
endDate,
};
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId })
);
const connection = connectionQuery.data;
const isConnected = connection?.siteUrl;
const overviewQuery = useQuery(
trpc.gsc.getOverview.queryOptions(
{ projectId, ...dateInput, interval: interval ?? 'day' },
{ enabled: !!isConnected }
)
);
const pagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{ projectId, ...dateInput, limit: 50 },
{ enabled: !!isConnected }
)
);
const queriesQuery = useQuery(
trpc.gsc.getQueries.queryOptions(
{ projectId, ...dateInput, limit: 50 },
{ enabled: !!isConnected }
)
);
const searchEnginesQuery = useQuery(
trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput })
);
const aiEnginesQuery = useQuery(
trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput })
);
const previousOverviewQuery = useQuery(
trpc.gsc.getPreviousOverview.queryOptions(
{ projectId, ...dateInput, interval: interval ?? 'day' },
{ enabled: !!isConnected }
)
);
const [pagesPage, setPagesPage] = useState(0);
const [queriesPage, setQueriesPage] = useState(0);
const pageSize = 15;
const [pagesSearch, setPagesSearch] = useState('');
const [queriesSearch, setQueriesSearch] = useState('');
const pages = pagesQuery.data ?? [];
const queries = queriesQuery.data ?? [];
const filteredPages = useMemo(() => {
if (!pagesSearch.trim()) {
return pages;
}
const q = pagesSearch.toLowerCase();
return pages.filter((row) => {
return String(row.page).toLowerCase().includes(q);
});
}, [pages, pagesSearch]);
const filteredQueries = useMemo(() => {
if (!queriesSearch.trim()) {
return queries;
}
const q = queriesSearch.toLowerCase();
return queries.filter((row) => {
return String(row.query).toLowerCase().includes(q);
});
}, [queries, queriesSearch]);
const paginatedPages = useMemo(
() => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize),
[filteredPages, pagesPage, pageSize]
);
const paginatedQueries = useMemo(
() =>
filteredQueries.slice(
queriesPage * pageSize,
(queriesPage + 1) * pageSize
),
[filteredQueries, queriesPage, pageSize]
);
const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1;
const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1;
if (connectionQuery.isLoading) {
return (
<PageContainer>
<PageHeader description="Google Search Console data" title="SEO" />
<div className="mt-8 space-y-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
</PageContainer>
);
}
if (!isConnected) {
return (
<FullPageEmptyState
className="pt-[20vh]"
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
icon={SearchIcon}
title="No SEO data yet"
>
<Button
onClick={() =>
navigate({
to: '/$organizationId/$projectId/settings/gsc',
params: { organizationId, projectId },
})
}
>
Connect Google Search Console
</Button>
</FullPageEmptyState>
);
}
const overview = overviewQuery.data ?? [];
const prevOverview = previousOverviewQuery.data ?? [];
const sumOverview = (rows: typeof overview) =>
rows.reduce(
(acc, row) => ({
clicks: acc.clicks + row.clicks,
impressions: acc.impressions + row.impressions,
ctr: acc.ctr + row.ctr,
position: acc.position + row.position,
}),
{ clicks: 0, impressions: 0, ctr: 0, position: 0 }
);
const totals = sumOverview(overview);
const prevTotals = sumOverview(prevOverview);
const n = Math.max(overview.length, 1);
const pn = Math.max(prevOverview.length, 1);
return (
<PageContainer>
<PageHeader
actions={
<>
<OverviewRange />
<OverviewInterval />
</>
}
description={`Search performance for ${connection.siteUrl}`}
title="SEO"
/>
<div className="mt-8 space-y-8">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
<OverviewMetricCard
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
id="clicks"
isLoading={overviewQuery.isLoading}
label="Clicks"
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.impressions,
date: r.date,
}))}
id="impressions"
isLoading={overviewQuery.isLoading}
label="Impressions"
metric={{
current: totals.impressions,
previous: prevTotals.impressions,
}}
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.ctr * 100,
date: r.date,
}))}
id="ctr"
isLoading={overviewQuery.isLoading}
label="Avg CTR"
metric={{
current: (totals.ctr / n) * 100,
previous: (prevTotals.ctr / pn) * 100,
}}
unit="%"
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.position,
date: r.date,
}))}
id="position"
inverted
isLoading={overviewQuery.isLoading}
label="Avg Position"
metric={{
current: totals.position / n,
previous: prevTotals.position / pn,
}}
/>
</div>
<SearchEngines
engines={searchEnginesQuery.data?.engines ?? []}
isLoading={searchEnginesQuery.isLoading}
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
total={searchEnginesQuery.data?.total ?? 0}
/>
<AiEngines
engines={aiEnginesQuery.data?.engines ?? []}
isLoading={aiEnginesQuery.isLoading}
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
total={aiEnginesQuery.data?.total ?? 0}
/>
</div>
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GscPositionChart
data={overview}
isLoading={overviewQuery.isLoading}
/>
<GscCtrBenchmark
data={pagesQuery.data ?? []}
isLoading={pagesQuery.isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<GscTable
isLoading={pagesQuery.isLoading}
keyField="page"
keyLabel="Page"
maxClicks={Math.max(...paginatedPages.map((p) => p.clicks), 1)}
onNextPage={() =>
setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1))
}
onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))}
onRowClick={(value) =>
pushModal('PageDetails', { type: 'page', projectId, value })
}
onSearchChange={(v) => {
setPagesSearch(v);
setPagesPage(0);
}}
pageCount={pagesPageCount}
pageIndex={pagesPage}
pageSize={pageSize}
rows={paginatedPages}
searchPlaceholder="Search pages"
searchValue={pagesSearch}
title="Top pages"
totalCount={filteredPages.length}
/>
<GscTable
isLoading={queriesQuery.isLoading}
keyField="query"
keyLabel="Query"
maxClicks={Math.max(...paginatedQueries.map((q) => q.clicks), 1)}
onNextPage={() =>
setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1))
}
onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))}
onRowClick={(value) =>
pushModal('PageDetails', { type: 'query', projectId, value })
}
onSearchChange={(v) => {
setQueriesSearch(v);
setQueriesPage(0);
}}
pageCount={queriesPageCount}
pageIndex={queriesPage}
pageSize={pageSize}
rows={paginatedQueries}
searchPlaceholder="Search queries"
searchValue={queriesSearch}
title="Top queries"
totalCount={filteredQueries.length}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GscCannibalization
endDate={endDate ?? undefined}
interval={interval ?? 'day'}
projectId={projectId}
range={range}
startDate={startDate ?? undefined}
/>
<PagesInsights projectId={projectId} />
</div>
</div>
</PageContainer>
);
}
function TrafficSourceWidget({
title,
engines,
total,
previousTotal,
isLoading,
emptyMessage,
}: {
title: string;
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
emptyMessage: string;
}) {
const displayed =
engines.length > 8
? [
...engines.slice(0, 7),
{
name: 'Others',
sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0),
},
]
: engines.slice(0, 8);
const max = displayed[0]?.sessions ?? 1;
const pctChange =
previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null;
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b p-4">
<h3 className="font-medium text-sm">{title}</h3>
{!isLoading && total > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium font-mono text-sm tabular-nums">
{total.toLocaleString()}
</span>
{pctChange !== null && (
<span
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
>
{pctChange >= 0 ? '+' : ''}
{pctChange.toFixed(1)}%
</span>
)}
</div>
)}
</div>
<div className="grid grid-cols-2">
{isLoading &&
[1, 2, 3, 4].map((i) => (
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
<div className="size-4 animate-pulse rounded-sm bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
</div>
))}
{!isLoading && engines.length === 0 && (
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
{emptyMessage}
</p>
)}
{!isLoading &&
displayed.map((engine) => {
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
const barPct = (engine.sessions / max) * 100;
return (
<div className="relative px-4 py-2.5" key={engine.name}>
<div
className="absolute inset-y-0 left-0 bg-muted/50"
style={{ width: `${barPct}%` }}
/>
<div className="relative flex items-center gap-2">
{engine.name !== 'Others' && (
<SerieIcon
className="size-3.5 shrink-0 rounded-sm"
name={engine.name}
/>
)}
<span className="min-w-0 flex-1 truncate text-xs capitalize">
{engine.name.replace(/\..+$/, '')}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{engine.sessions.toLocaleString()}
</span>
<span className="shrink-0 font-mono text-muted-foreground text-xs">
{pct.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
function SearchEngines(props: {
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
}) {
return (
<TrafficSourceWidget
{...props}
emptyMessage="No search traffic in this period"
title="Search engines"
/>
);
}
function AiEngines(props: {
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
}) {
return (
<TrafficSourceWidget
{...props}
emptyMessage="No AI traffic in this period"
title="AI referrals"
/>
);
}
function GscChart({
data,
isLoading,
}: {
data: Array<{ date: string; clicks: number; impressions: number }>;
isLoading: boolean;
}) {
const color = getChartColor(0);
const yAxisProps = useYAxisProps();
return (
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={200} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-line-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-line-glow)"
isAnimationActive={false}
stroke={color}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-line-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}
interface GscTableRow {
clicks: number;
impressions: number;
ctr: number;
position: number;
[key: string]: string | number;
}
function GscTable({
title,
rows,
keyField,
keyLabel,
maxClicks,
isLoading,
onRowClick,
searchValue,
onSearchChange,
searchPlaceholder,
totalCount,
pageIndex,
pageSize,
pageCount,
onPreviousPage,
onNextPage,
}: {
title: string;
rows: GscTableRow[];
keyField: string;
keyLabel: string;
maxClicks: number;
isLoading: boolean;
onRowClick?: (value: string) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
totalCount?: number;
pageIndex?: number;
pageSize?: number;
pageCount?: number;
onPreviousPage?: () => void;
onNextPage?: () => void;
}) {
const showPagination =
totalCount != null &&
pageSize != null &&
pageCount != null &&
onPreviousPage != null &&
onNextPage != null &&
pageIndex != null;
const canPreviousPage = (pageIndex ?? 0) > 0;
const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1;
const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0;
const rangeEnd = Math.min(
(pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0),
totalCount ?? 0
);
if (isLoading) {
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b p-4">
<h3 className="font-medium text-sm">{title}</h3>
</div>
<OverviewWidgetTable
columns={[
{
name: keyLabel,
width: 'w-full',
render: () => <Skeleton className="h-4 w-2/3" />,
},
{
name: 'Clicks',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'Impr.',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'CTR',
width: '60px',
render: () => <Skeleton className="h-4 w-8" />,
},
{
name: 'Pos.',
width: '55px',
render: () => <Skeleton className="h-4 w-8" />,
},
]}
data={[1, 2, 3, 4, 5]}
getColumnPercentage={() => 0}
keyExtractor={(i) => String(i)}
/>
</div>
);
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="font-medium text-sm">{title}</h3>
{showPagination && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{totalCount === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
</span>
<Pagination
canNextPage={canNextPage}
canPreviousPage={canPreviousPage}
nextPage={onNextPage}
pageIndex={pageIndex}
previousPage={onPreviousPage}
/>
</div>
)}
</div>
{onSearchChange != null && (
<div className="relative">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder ?? 'Search'}
type="search"
value={searchValue ?? ''}
/>
</div>
)}
</div>
<OverviewWidgetTable
columns={[
{
name: keyLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<button
className="block w-full truncate text-left font-mono text-xs hover:underline"
onClick={() => onRowClick?.(String(item[keyField]))}
type="button"
>
{String(item[keyField])}
</button>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.clicks.toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.impressions.toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.ctr * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.position.toFixed(1)}
</span>
);
},
},
]}
data={rows}
getColumnPercentage={(item) => item.clicks / maxClicks}
keyExtractor={(item) => String(item[keyField])}
/>
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
)({
component: GscSettings,
});
function GscSettings() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [selectedSite, setSelectedSite] = useState('');
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions(
{ projectId },
{ refetchInterval: 5000 }
)
);
const sitesQuery = useQuery(
trpc.gsc.getSites.queryOptions(
{ projectId },
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
)
);
const initiateOAuth = useMutation(
trpc.gsc.initiateOAuth.mutationOptions({
onSuccess: (data) => {
window.location.href = data.url;
},
onError: () => {
toast.error('Failed to initiate Google Search Console connection');
},
})
);
const selectSite = useMutation(
trpc.gsc.selectSite.mutationOptions({
onSuccess: () => {
toast.success('Site connected', {
description: 'Backfill of 6 months of data has started.',
});
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to select site');
},
})
);
const disconnect = useMutation(
trpc.gsc.disconnect.mutationOptions({
onSuccess: () => {
toast.success('Disconnected from Google Search Console');
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to disconnect');
},
})
);
const connection = connectionQuery.data;
if (connectionQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
</div>
);
}
// Not connected at all
if (!connection) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connect your Google Search Console property to import search
performance data.
</p>
</div>
<div className="flex flex-col gap-4 rounded-lg border p-6">
<p className="text-muted-foreground text-sm">
You will be redirected to Google to authorize access. Only read-only
access to Search Console data is requested.
</p>
<Button
className="w-fit"
disabled={initiateOAuth.isPending}
onClick={() => initiateOAuth.mutate({ projectId })}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect Google Search Console
</Button>
</div>
</div>
);
}
// Connected but no site selected yet
if (!connection.siteUrl) {
const sites = sitesQuery.data ?? [];
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Select a property</h3>
<p className="mt-1 text-muted-foreground text-sm">
Choose which Google Search Console property to connect to this
project.
</p>
</div>
<div className="space-y-4 rounded-lg border p-6">
{sitesQuery.isLoading ? (
<Skeleton className="h-10 w-full" />
) : sites.length === 0 ? (
<p className="text-muted-foreground text-sm">
No Search Console properties found for this Google account.
</p>
) : (
<>
<Select onValueChange={setSelectedSite} value={selectedSite}>
<SelectTrigger>
<SelectValue placeholder="Select a property..." />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem key={site.siteUrl} value={site.siteUrl}>
{site.siteUrl}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedSite || selectSite.isPending}
onClick={() =>
selectSite.mutate({ projectId, siteUrl: selectedSite })
}
>
{selectSite.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect property
</Button>
</>
)}
</div>
<Button
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="ghost"
>
Cancel
</Button>
</div>
);
}
// Token expired — show reconnect prompt
if (connection.lastSyncStatus === 'token_expired') {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connected to Google Search Console.
</p>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
<div className="flex items-center gap-2 text-destructive">
<XCircleIcon className="h-4 w-4" />
<span className="font-medium text-sm">Authorization expired</span>
</div>
<p className="text-muted-foreground text-sm">
Your Google Search Console authorization has expired or been
revoked. Please reconnect to continue syncing data.
</p>
{connection.lastSyncError && (
<p className="break-words font-mono text-muted-foreground text-xs">
{connection.lastSyncError}
</p>
)}
<Button
className="w-fit"
disabled={initiateOAuth.isPending}
onClick={() => initiateOAuth.mutate({ projectId })}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Reconnect Google Search Console
</Button>
</div>
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="ghost"
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}
// Fully connected
const syncStatusIcon =
connection.lastSyncStatus === 'success' ? (
<CheckCircleIcon className="h-4 w-4" />
) : connection.lastSyncStatus === 'error' ? (
<XCircleIcon className="h-4 w-4" />
) : null;
const syncStatusVariant =
connection.lastSyncStatus === 'success'
? 'success'
: connection.lastSyncStatus === 'error'
? 'destructive'
: 'secondary';
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connected to Google Search Console.
</p>
</div>
<div className="divide-y rounded-lg border">
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Property</div>
<div className="font-mono text-muted-foreground text-sm">
{connection.siteUrl}
</div>
</div>
{connection.backfillStatus && (
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Backfill</div>
<Badge
className="capitalize"
variant={
connection.backfillStatus === 'completed'
? 'success'
: connection.backfillStatus === 'failed'
? 'destructive'
: connection.backfillStatus === 'running'
? 'default'
: 'secondary'
}
>
{connection.backfillStatus === 'running' && (
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
)}
{connection.backfillStatus}
</Badge>
</div>
)}
{connection.lastSyncedAt && (
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Last synced</div>
<div className="flex items-center gap-2">
{connection.lastSyncStatus && (
<Badge
className="capitalize"
variant={syncStatusVariant as any}
>
{syncStatusIcon}
{connection.lastSyncStatus}
</Badge>
)}
<span className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
addSuffix: true,
})}
</span>
</div>
</div>
)}
{connection.lastSyncError && (
<div className="p-4">
<div className="font-medium text-destructive text-sm">
Last error
</div>
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
{connection.lastSyncError}
</div>
</div>
)}
</div>
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="destructive"
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}

View File

@@ -45,6 +45,7 @@ function ProjectDashboard() {
{ id: 'tracking', label: 'Tracking script' }, { id: 'tracking', label: 'Tracking script' },
{ id: 'widgets', label: 'Widgets' }, { id: 'widgets', label: 'Widgets' },
{ id: 'imports', label: 'Imports' }, { id: 'imports', label: 'Imports' },
{ id: 'gsc', label: 'Google Search' },
]; ];
const handleTabChange = (tabId: string) => { const handleTabChange = (tabId: string) => {

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

@@ -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

@@ -78,6 +78,11 @@ export async function bootCron() {
type: 'onboarding', type: 'onboarding',
pattern: '0 * * * *', pattern: '0 * * * *',
}, },
{
name: 'gscSync',
type: 'gscSync',
pattern: '0 3 * * *',
},
]; ];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') { if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -1,11 +1,11 @@
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,
importQueue, importQueue,
insightsQueue, insightsQueue,
miscQueue, miscQueue,
@@ -14,13 +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 { 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';
@@ -59,6 +58,7 @@ function getEnabledQueues(): QueueName[] {
'misc', 'misc',
'import', 'import',
'insights', 'insights',
'gsc',
]; ];
} }
@@ -92,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>)[] = [];
@@ -116,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']>({
@@ -129,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);
@@ -169,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 });
@@ -208,9 +210,20 @@ export async function bootWorkers() {
logger.info('Started worker for insights', { concurrency }); logger.info('Started worker for insights', { concurrency });
} }
// Start gsc worker
if (enabledQueues.includes('gsc')) {
const concurrency = getConcurrencyFor('gsc', 5);
const gscWorker = new Worker(gscQueue.name, gscJob, {
...workerOptions,
concurrency,
});
workers.push(gscWorker);
logger.info('Started worker for gsc', { concurrency });
}
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.'
); );
} }
@@ -240,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', {
@@ -253,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,
@@ -279,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) {
@@ -325,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

@@ -5,6 +5,7 @@ import { createInitialSalts } from '@openpanel/db';
import { import {
cronQueue, cronQueue,
eventsGroupQueues, eventsGroupQueues,
gscQueue,
importQueue, importQueue,
insightsQueue, insightsQueue,
miscQueue, miscQueue,
@@ -12,9 +13,8 @@ import {
sessionsQueue, sessionsQueue,
} from '@openpanel/queue'; } from '@openpanel/queue';
import express from 'express'; import express from 'express';
import client from 'prom-client';
import { BullBoardGroupMQAdapter } from 'groupmq'; import { BullBoardGroupMQAdapter } from 'groupmq';
import client from 'prom-client';
import sourceMapSupport from 'source-map-support'; import sourceMapSupport from 'source-map-support';
import { bootCron } from './boot-cron'; import { bootCron } from './boot-cron';
import { bootWorkers } from './boot-workers'; import { bootWorkers } from './boot-workers';
@@ -39,7 +39,7 @@ async function start() {
createBullBoard({ createBullBoard({
queues: [ queues: [
...eventsGroupQueues.map( ...eventsGroupQueues.map(
(queue) => new BullBoardGroupMQAdapter(queue) as any, (queue) => new BullBoardGroupMQAdapter(queue) as any
), ),
new BullMQAdapter(sessionsQueue), new BullMQAdapter(sessionsQueue),
new BullMQAdapter(cronQueue), new BullMQAdapter(cronQueue),
@@ -47,8 +47,9 @@ async function start() {
new BullMQAdapter(miscQueue), new BullMQAdapter(miscQueue),
new BullMQAdapter(importQueue), new BullMQAdapter(importQueue),
new BullMQAdapter(insightsQueue), new BullMQAdapter(insightsQueue),
new BullMQAdapter(gscQueue),
], ],
serverAdapter: serverAdapter, serverAdapter,
}); });
app.use('/', serverAdapter.getRouter()); app.use('/', serverAdapter.getRouter());

View File

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

View File

@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
import type { CronQueuePayload } from '@openpanel/queue'; import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects'; import { jobdeleteProjects } from './cron.delete-projects';
import { gscSyncAllJob } from './gsc';
import { onboardingJob } from './cron.onboarding'; import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping'; import { ping } from './cron.ping';
import { salt } from './cron.salt'; import { salt } from './cron.salt';
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'onboarding': { case 'onboarding': {
return await onboardingJob(job); return await onboardingJob(job);
} }
case 'gscSync': {
return await gscSyncAllJob();
}
} }
} }

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,16 +120,15 @@ 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,
@@ -149,7 +147,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,11 +159,12 @@ 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 =
profileId && !isTimestampFromThePast
? await sessionBuffer.getExistingSession({ ? await sessionBuffer.getExistingSession({
profileId, profileId,
projectId, projectId,
@@ -198,49 +197,30 @@ 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 }
event: payload.name,
projectId,
}
); );
return null; return null;
} }
if (!sessionEnd) { if (session) {
const locked = await getLock( await extendSessionEndJob({
`session_start:${projectId}:${sessionId}`, projectId,
'1', deviceId,
1000 }).catch((error) => {
); logger.error('Error finding and extending session end job', { error });
if (locked) { throw error;
logger.info('Creating session start event', { event: payload }); });
} else {
await createEventAndNotify( await createEventAndNotify(
{ {
...payload, ...payload,
@@ -253,27 +233,12 @@ export async function incomingEvent(
logger.error('Error creating session start event', { event: payload }); logger.error('Error creating session start event', { event: payload });
throw error; 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

@@ -186,6 +186,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();

146
apps/worker/src/jobs/gsc.ts Normal file
View File

@@ -0,0 +1,146 @@
import { db, syncGscData } from '@openpanel/db';
import type { GscQueuePayload } from '@openpanel/queue';
import { gscQueue } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { logger } from '../utils/logger';
const BACKFILL_MONTHS = 6;
const CHUNK_DAYS = 14;
export function gscJob(job: Job<GscQueuePayload>) {
switch (job.data.type) {
case 'gscProjectSync':
return gscProjectSyncJob(job.data.payload.projectId);
case 'gscProjectBackfill':
return gscProjectBackfillJob(job.data.payload.projectId);
default:
throw new Error('Unknown GSC job type');
}
}
async function gscProjectSyncJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC sync skipped: no connection or siteUrl', { projectId });
return;
}
try {
// Sync rolling 3-day window (GSC data can arrive late)
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 2); // 3 days total
await syncGscData(projectId, startDate, endDate);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC sync completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC sync failed', { projectId, error });
throw error;
}
}
async function gscProjectBackfillJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC backfill skipped: no connection or siteUrl', {
projectId,
});
return;
}
await db.gscConnection.update({
where: { projectId },
data: { backfillStatus: 'running' },
});
try {
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS);
// Process in chunks to avoid timeouts and respect API limits
let chunkEnd = new Date(endDate);
while (chunkEnd > startDate) {
const chunkStart = new Date(chunkEnd);
chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1);
if (chunkStart < startDate) {
chunkStart.setTime(startDate.getTime());
}
logger.info('GSC backfill chunk', {
projectId,
from: chunkStart.toISOString().slice(0, 10),
to: chunkEnd.toISOString().slice(0, 10),
});
await syncGscData(projectId, chunkStart, chunkEnd);
chunkEnd = new Date(chunkStart);
chunkEnd.setDate(chunkEnd.getDate() - 1);
}
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'completed',
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC backfill completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'failed',
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC backfill failed', { projectId, error });
throw error;
}
}
export async function gscSyncAllJob() {
const connections = await db.gscConnection.findMany({
where: {
siteUrl: { not: '' },
},
select: { projectId: true },
});
logger.info('GSC nightly sync: enqueuing projects', {
count: connections.length,
});
for (const conn of connections) {
await gscQueue.add('gscProjectSync', {
type: 'gscProjectSync',
payload: { projectId: conn.projectId },
});
}
}

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() {
if ('getDelayedCount' in queue) {
const metric = await queue.getDelayedCount(); const metric = await queue.getDelayedCount();
this.set(metric); 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",
@@ -70,7 +71,8 @@
"noDangerouslySetInnerHtml": "off" "noDangerouslySetInnerHtml": "off"
}, },
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "off",
"noExcessiveCognitiveComplexity": "off"
} }
} }
}, },

View File

@@ -1,18 +0,0 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
);

View File

@@ -1,6 +1,7 @@
import { GitHub } from 'arctic'; import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic'; export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic'; import * as Arctic from 'arctic';
export { Arctic }; export { Arctic };
@@ -8,11 +9,17 @@ export { Arctic };
export const github = new GitHub( export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '', process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '', process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '', process.env.GITHUB_REDIRECT_URI ?? ''
); );
export const google = new Arctic.Google( export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '', process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '', process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '', process.env.GOOGLE_REDIRECT_URI ?? ''
);
export const googleGsc = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GSC_GOOGLE_REDIRECT_URI ?? ''
); );

View File

@@ -0,0 +1,85 @@
import fs from 'node:fs';
import path from 'node:path';
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
import { getIsCluster } from './helpers';
export async function up() {
const isClustered = getIsCluster();
const commonMetricColumns = [
'`clicks` UInt32 CODEC(Delta(4), LZ4)',
'`impressions` UInt32 CODEC(Delta(4), LZ4)',
'`ctr` Float32 CODEC(Gorilla, LZ4)',
'`position` Float32 CODEC(Gorilla, LZ4)',
'`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)',
];
const sqls: string[] = [
// Daily totals — accurate overview numbers
...createTable({
name: 'gsc_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
...commonMetricColumns,
],
orderBy: ['project_id', 'date'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
// Per-page breakdown
...createTable({
name: 'gsc_pages_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
'`page` String CODEC(ZSTD(3))',
...commonMetricColumns,
],
orderBy: ['project_id', 'date', 'page'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
// Per-query breakdown
...createTable({
name: 'gsc_queries_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
'`query` String CODEC(ZSTD(3))',
...commonMetricColumns,
],
orderBy: ['project_id', 'date', 'query'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
];
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
)
.join('\n\n---\n\n'),
);
if (!process.argv.includes('--dry')) {
await runClickhouseMigrationCommands(sqls);
}
}

View File

@@ -31,3 +31,5 @@ export * from './src/services/overview.service';
export * from './src/services/pages.service'; export * from './src/services/pages.service';
export * from './src/services/insights'; export * from './src/services/insights';
export * from './src/session-context'; export * from './src/session-context';
export * from './src/gsc';
export * from './src/encryption';

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "public"."gsc_connections" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"projectId" TEXT NOT NULL,
"siteUrl" TEXT NOT NULL DEFAULT '',
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"accessTokenExpiresAt" TIMESTAMP(3),
"lastSyncedAt" TIMESTAMP(3),
"lastSyncStatus" TEXT,
"lastSyncError" TEXT,
"backfillStatus" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gsc_connections_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "gsc_connections_projectId_key" ON "public"."gsc_connections"("projectId");
-- AddForeignKey
ALTER TABLE "public"."gsc_connections" ADD CONSTRAINT "gsc_connections_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -203,6 +203,7 @@ model Project {
notificationRules NotificationRule[] notificationRules NotificationRule[]
notifications Notification[] notifications Notification[]
imports Import[] imports Import[]
gscConnection GscConnection?
// When deleteAt > now(), the project will be deleted // When deleteAt > now(), the project will be deleted
deleteAt DateTime? deleteAt DateTime?
@@ -612,6 +613,24 @@ model InsightEvent {
@@map("insight_events") @@map("insight_events")
} }
model GscConnection {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
siteUrl String @default("")
accessToken String
refreshToken String
accessTokenExpiresAt DateTime?
lastSyncedAt DateTime?
lastSyncStatus String?
lastSyncError String?
backfillStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("gsc_connections")
}
model EmailUnsubscribe { model EmailUnsubscribe {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String email String

View File

@@ -2,42 +2,8 @@ import { getRedisCache } from '@openpanel/redis';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ch } from '../clickhouse/client'; import { ch } from '../clickhouse/client';
// Mock transformEvent to avoid circular dependency with buffers -> services -> buffers // Break circular dep: event-buffer -> event.service -> buffers/index -> EventBuffer
vi.mock('../services/event.service', () => ({ vi.mock('../services/event.service', () => ({}));
transformEvent: (event: any) => ({
id: event.id ?? 'id',
name: event.name,
deviceId: event.device_id,
profileId: event.profile_id,
projectId: event.project_id,
sessionId: event.session_id,
properties: event.properties ?? {},
createdAt: new Date(event.created_at ?? Date.now()),
country: event.country,
city: event.city,
region: event.region,
longitude: event.longitude,
latitude: event.latitude,
os: event.os,
osVersion: event.os_version,
browser: event.browser,
browserVersion: event.browser_version,
device: event.device,
brand: event.brand,
model: event.model,
duration: event.duration ?? 0,
path: event.path ?? '',
origin: event.origin ?? '',
referrer: event.referrer,
referrerName: event.referrer_name,
referrerType: event.referrer_type,
meta: event.meta,
importedAt: undefined,
sdkName: event.sdk_name,
sdkVersion: event.sdk_version,
profile: event.profile,
}),
}));
import { EventBuffer } from './event-buffer'; import { EventBuffer } from './event-buffer';
@@ -68,18 +34,16 @@ describe('EventBuffer', () => {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} as any; } as any;
// Get initial count
const initialCount = await eventBuffer.getBufferSize(); const initialCount = await eventBuffer.getBufferSize();
// Add event eventBuffer.add(event);
await eventBuffer.add(event); await eventBuffer.flush();
// Buffer counter should increase by 1
const newCount = await eventBuffer.getBufferSize(); const newCount = await eventBuffer.getBufferSize();
expect(newCount).toBe(initialCount + 1); expect(newCount).toBe(initialCount + 1);
}); });
it('adds multiple screen_views - moves previous to buffer with duration', async () => { it('adds screen_view directly to buffer queue', async () => {
const t0 = Date.now(); const t0 = Date.now();
const sessionId = 'session_1'; const sessionId = 'session_1';
@@ -99,60 +63,23 @@ describe('EventBuffer', () => {
created_at: new Date(t0 + 1000).toISOString(), created_at: new Date(t0 + 1000).toISOString(),
} as any; } as any;
const view3 = {
project_id: 'p1',
profile_id: 'u1',
session_id: sessionId,
name: 'screen_view',
created_at: new Date(t0 + 3000).toISOString(),
} as any;
// Add first screen_view
const count1 = await eventBuffer.getBufferSize(); const count1 = await eventBuffer.getBufferSize();
await eventBuffer.add(view1);
// Should be stored as "last" but NOT in queue yet eventBuffer.add(view1);
await eventBuffer.flush();
// screen_view goes directly to buffer
const count2 = await eventBuffer.getBufferSize(); const count2 = await eventBuffer.getBufferSize();
expect(count2).toBe(count1); // No change in buffer expect(count2).toBe(count1 + 1);
// Last screen_view should be retrievable eventBuffer.add(view2);
const last1 = await eventBuffer.getLastScreenView({ await eventBuffer.flush();
projectId: 'p1',
sessionId: sessionId,
});
expect(last1).not.toBeNull();
expect(last1!.createdAt.toISOString()).toBe(view1.created_at);
// Add second screen_view
await eventBuffer.add(view2);
// Now view1 should be in buffer
const count3 = await eventBuffer.getBufferSize(); const count3 = await eventBuffer.getBufferSize();
expect(count3).toBe(count1 + 1); expect(count3).toBe(count1 + 2);
// view2 should now be the "last"
const last2 = await eventBuffer.getLastScreenView({
projectId: 'p1',
sessionId: sessionId,
});
expect(last2!.createdAt.toISOString()).toBe(view2.created_at);
// Add third screen_view
await eventBuffer.add(view3);
// Now view2 should also be in buffer
const count4 = await eventBuffer.getBufferSize();
expect(count4).toBe(count1 + 2);
// view3 should now be the "last"
const last3 = await eventBuffer.getLastScreenView({
projectId: 'p1',
sessionId: sessionId,
});
expect(last3!.createdAt.toISOString()).toBe(view3.created_at);
}); });
it('adds session_end - moves last screen_view and session_end to buffer', async () => { it('adds session_end directly to buffer queue', async () => {
const t0 = Date.now(); const t0 = Date.now();
const sessionId = 'session_2'; const sessionId = 'session_2';
@@ -172,148 +99,44 @@ describe('EventBuffer', () => {
created_at: new Date(t0 + 5000).toISOString(), created_at: new Date(t0 + 5000).toISOString(),
} as any; } as any;
// Add screen_view
const count1 = await eventBuffer.getBufferSize(); const count1 = await eventBuffer.getBufferSize();
await eventBuffer.add(view);
// Should be stored as "last", not in buffer yet eventBuffer.add(view);
eventBuffer.add(sessionEnd);
await eventBuffer.flush();
const count2 = await eventBuffer.getBufferSize(); const count2 = await eventBuffer.getBufferSize();
expect(count2).toBe(count1); expect(count2).toBe(count1 + 2);
// Add session_end
await eventBuffer.add(sessionEnd);
// Both should now be in buffer (+2)
const count3 = await eventBuffer.getBufferSize();
expect(count3).toBe(count1 + 2);
// Last screen_view should be cleared
const last = await eventBuffer.getLastScreenView({
projectId: 'p2',
sessionId: sessionId,
});
expect(last).toBeNull();
});
it('session_end with no previous screen_view - only adds session_end to buffer', async () => {
const sessionId = 'session_3';
const sessionEnd = {
project_id: 'p3',
profile_id: 'u3',
session_id: sessionId,
name: 'session_end',
created_at: new Date().toISOString(),
} as any;
const count1 = await eventBuffer.getBufferSize();
await eventBuffer.add(sessionEnd);
// Only session_end should be in buffer (+1)
const count2 = await eventBuffer.getBufferSize();
expect(count2).toBe(count1 + 1);
});
it('gets last screen_view by profileId', async () => {
const view = {
project_id: 'p4',
profile_id: 'u4',
session_id: 'session_4',
name: 'screen_view',
path: '/home',
created_at: new Date().toISOString(),
} as any;
await eventBuffer.add(view);
// Query by profileId
const result = await eventBuffer.getLastScreenView({
projectId: 'p4',
profileId: 'u4',
});
expect(result).not.toBeNull();
expect(result!.name).toBe('screen_view');
expect(result!.path).toBe('/home');
});
it('gets last screen_view by sessionId', async () => {
const sessionId = 'session_5';
const view = {
project_id: 'p5',
profile_id: 'u5',
session_id: sessionId,
name: 'screen_view',
path: '/about',
created_at: new Date().toISOString(),
} as any;
await eventBuffer.add(view);
// Query by sessionId
const result = await eventBuffer.getLastScreenView({
projectId: 'p5',
sessionId: sessionId,
});
expect(result).not.toBeNull();
expect(result!.name).toBe('screen_view');
expect(result!.path).toBe('/about');
});
it('returns null for non-existent last screen_view', async () => {
const result = await eventBuffer.getLastScreenView({
projectId: 'p_nonexistent',
profileId: 'u_nonexistent',
});
expect(result).toBeNull();
}); });
it('gets buffer count correctly', async () => { it('gets buffer count correctly', async () => {
// Initially 0
expect(await eventBuffer.getBufferSize()).toBe(0); expect(await eventBuffer.getBufferSize()).toBe(0);
// Add regular event eventBuffer.add({
await eventBuffer.add({
project_id: 'p6', project_id: 'p6',
name: 'event1', name: 'event1',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} as any); } as any);
await eventBuffer.flush();
expect(await eventBuffer.getBufferSize()).toBe(1); expect(await eventBuffer.getBufferSize()).toBe(1);
// Add another regular event eventBuffer.add({
await eventBuffer.add({
project_id: 'p6', project_id: 'p6',
name: 'event2', name: 'event2',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} as any); } as any);
await eventBuffer.flush();
expect(await eventBuffer.getBufferSize()).toBe(2); expect(await eventBuffer.getBufferSize()).toBe(2);
// Add screen_view (not counted until flushed) // screen_view also goes directly to buffer
await eventBuffer.add({ eventBuffer.add({
project_id: 'p6', project_id: 'p6',
profile_id: 'u6', profile_id: 'u6',
session_id: 'session_6', session_id: 'session_6',
name: 'screen_view', name: 'screen_view',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} as any); } as any);
await eventBuffer.flush();
// Still 2 (screen_view is pending)
expect(await eventBuffer.getBufferSize()).toBe(2);
// Add another screen_view (first one gets flushed)
await eventBuffer.add({
project_id: 'p6',
profile_id: 'u6',
session_id: 'session_6',
name: 'screen_view',
created_at: new Date(Date.now() + 1000).toISOString(),
} as any);
// Now 3 (2 regular + 1 flushed screen_view)
expect(await eventBuffer.getBufferSize()).toBe(3); expect(await eventBuffer.getBufferSize()).toBe(3);
}); });
@@ -330,8 +153,9 @@ describe('EventBuffer', () => {
created_at: new Date(Date.now() + 1000).toISOString(), created_at: new Date(Date.now() + 1000).toISOString(),
} as any; } as any;
await eventBuffer.add(event1); eventBuffer.add(event1);
await eventBuffer.add(event2); eventBuffer.add(event2);
await eventBuffer.flush();
expect(await eventBuffer.getBufferSize()).toBe(2); expect(await eventBuffer.getBufferSize()).toBe(2);
@@ -341,14 +165,12 @@ describe('EventBuffer', () => {
await eventBuffer.processBuffer(); await eventBuffer.processBuffer();
// Should insert both events
expect(insertSpy).toHaveBeenCalled(); expect(insertSpy).toHaveBeenCalled();
const callArgs = insertSpy.mock.calls[0]![0]; const callArgs = insertSpy.mock.calls[0]![0];
expect(callArgs.format).toBe('JSONEachRow'); expect(callArgs.format).toBe('JSONEachRow');
expect(callArgs.table).toBe('events'); expect(callArgs.table).toBe('events');
expect(Array.isArray(callArgs.values)).toBe(true); expect(Array.isArray(callArgs.values)).toBe(true);
// Buffer should be empty after processing
expect(await eventBuffer.getBufferSize()).toBe(0); expect(await eventBuffer.getBufferSize()).toBe(0);
insertSpy.mockRestore(); insertSpy.mockRestore();
@@ -359,14 +181,14 @@ describe('EventBuffer', () => {
process.env.EVENT_BUFFER_CHUNK_SIZE = '2'; process.env.EVENT_BUFFER_CHUNK_SIZE = '2';
const eb = new EventBuffer(); const eb = new EventBuffer();
// Add 4 events
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
await eb.add({ eb.add({
project_id: 'p8', project_id: 'p8',
name: `event${i}`, name: `event${i}`,
created_at: new Date(Date.now() + i).toISOString(), created_at: new Date(Date.now() + i).toISOString(),
} as any); } as any);
} }
await eb.flush();
const insertSpy = vi const insertSpy = vi
.spyOn(ch, 'insert') .spyOn(ch, 'insert')
@@ -374,14 +196,12 @@ describe('EventBuffer', () => {
await eb.processBuffer(); await eb.processBuffer();
// With chunk size 2 and 4 events, should be called twice
expect(insertSpy).toHaveBeenCalledTimes(2); expect(insertSpy).toHaveBeenCalledTimes(2);
const call1Values = insertSpy.mock.calls[0]![0].values as any[]; const call1Values = insertSpy.mock.calls[0]![0].values as any[];
const call2Values = insertSpy.mock.calls[1]![0].values as any[]; const call2Values = insertSpy.mock.calls[1]![0].values as any[];
expect(call1Values.length).toBe(2); expect(call1Values.length).toBe(2);
expect(call2Values.length).toBe(2); expect(call2Values.length).toBe(2);
// Restore
if (prev === undefined) delete process.env.EVENT_BUFFER_CHUNK_SIZE; if (prev === undefined) delete process.env.EVENT_BUFFER_CHUNK_SIZE;
else process.env.EVENT_BUFFER_CHUNK_SIZE = prev; else process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
@@ -396,129 +216,61 @@ describe('EventBuffer', () => {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} as any; } as any;
await eventBuffer.add(event); eventBuffer.add(event);
await eventBuffer.flush();
const count = await eventBuffer.getActiveVisitorCount('p9'); const count = await eventBuffer.getActiveVisitorCount('p9');
expect(count).toBeGreaterThanOrEqual(1); expect(count).toBeGreaterThanOrEqual(1);
}); });
it('handles multiple sessions independently', async () => { it('handles multiple sessions independently — all events go to buffer', async () => {
const t0 = Date.now(); const t0 = Date.now();
const count1 = await eventBuffer.getBufferSize();
// Session 1 eventBuffer.add({
const view1a = {
project_id: 'p10', project_id: 'p10',
profile_id: 'u10', profile_id: 'u10',
session_id: 'session_10a', session_id: 'session_10a',
name: 'screen_view', name: 'screen_view',
created_at: new Date(t0).toISOString(), created_at: new Date(t0).toISOString(),
} as any; } as any);
eventBuffer.add({
const view1b = {
project_id: 'p10',
profile_id: 'u10',
session_id: 'session_10a',
name: 'screen_view',
created_at: new Date(t0 + 1000).toISOString(),
} as any;
// Session 2
const view2a = {
project_id: 'p10', project_id: 'p10',
profile_id: 'u11', profile_id: 'u11',
session_id: 'session_10b', session_id: 'session_10b',
name: 'screen_view', name: 'screen_view',
created_at: new Date(t0).toISOString(), created_at: new Date(t0).toISOString(),
} as any; } as any);
eventBuffer.add({
const view2b = { project_id: 'p10',
profile_id: 'u10',
session_id: 'session_10a',
name: 'screen_view',
created_at: new Date(t0 + 1000).toISOString(),
} as any);
eventBuffer.add({
project_id: 'p10', project_id: 'p10',
profile_id: 'u11', profile_id: 'u11',
session_id: 'session_10b', session_id: 'session_10b',
name: 'screen_view', name: 'screen_view',
created_at: new Date(t0 + 2000).toISOString(), created_at: new Date(t0 + 2000).toISOString(),
} as any; } as any);
await eventBuffer.flush();
await eventBuffer.add(view1a); // All 4 events are in buffer directly
await eventBuffer.add(view2a); expect(await eventBuffer.getBufferSize()).toBe(count1 + 4);
await eventBuffer.add(view1b); // Flushes view1a
await eventBuffer.add(view2b); // Flushes view2a
// Should have 2 events in buffer (one from each session)
expect(await eventBuffer.getBufferSize()).toBe(2);
// Each session should have its own "last" screen_view
const last1 = await eventBuffer.getLastScreenView({
projectId: 'p10',
sessionId: 'session_10a',
});
expect(last1!.createdAt.toISOString()).toBe(view1b.created_at);
const last2 = await eventBuffer.getLastScreenView({
projectId: 'p10',
sessionId: 'session_10b',
});
expect(last2!.createdAt.toISOString()).toBe(view2b.created_at);
}); });
it('screen_view without session_id goes directly to buffer', async () => { it('bulk adds events to buffer', async () => {
const view = { const events = Array.from({ length: 5 }, (_, i) => ({
project_id: 'p11', project_id: 'p11',
profile_id: 'u11', name: `event${i}`,
name: 'screen_view', created_at: new Date(Date.now() + i).toISOString(),
created_at: new Date().toISOString(), })) as any[];
} as any;
const count1 = await eventBuffer.getBufferSize(); eventBuffer.bulkAdd(events);
await eventBuffer.add(view); await eventBuffer.flush();
// Should go directly to buffer (no session_id) expect(await eventBuffer.getBufferSize()).toBe(5);
const count2 = await eventBuffer.getBufferSize();
expect(count2).toBe(count1 + 1);
});
it('updates last screen_view when new one arrives from same profile but different session', async () => {
const t0 = Date.now();
const view1 = {
project_id: 'p12',
profile_id: 'u12',
session_id: 'session_12a',
name: 'screen_view',
path: '/page1',
created_at: new Date(t0).toISOString(),
} as any;
const view2 = {
project_id: 'p12',
profile_id: 'u12',
session_id: 'session_12b', // Different session!
name: 'screen_view',
path: '/page2',
created_at: new Date(t0 + 1000).toISOString(),
} as any;
await eventBuffer.add(view1);
await eventBuffer.add(view2);
// Both sessions should have their own "last"
const lastSession1 = await eventBuffer.getLastScreenView({
projectId: 'p12',
sessionId: 'session_12a',
});
expect(lastSession1!.path).toBe('/page1');
const lastSession2 = await eventBuffer.getLastScreenView({
projectId: 'p12',
sessionId: 'session_12b',
});
expect(lastSession2!.path).toBe('/page2');
// Profile should have the latest one
const lastProfile = await eventBuffer.getLastScreenView({
projectId: 'p12',
profileId: 'u12',
});
expect(lastProfile!.path).toBe('/page2');
}); });
}); });

View File

@@ -2,33 +2,13 @@ import { getSafeJson } from '@openpanel/json';
import { import {
type Redis, type Redis,
getRedisCache, getRedisCache,
getRedisPub,
publishEvent, publishEvent,
} from '@openpanel/redis'; } from '@openpanel/redis';
import { ch } from '../clickhouse/client'; import { ch } from '../clickhouse/client';
import { import { type IClickhouseEvent } from '../services/event.service';
type IClickhouseEvent,
type IServiceEvent,
transformEvent,
} from '../services/event.service';
import { BaseBuffer } from './base-buffer'; import { BaseBuffer } from './base-buffer';
/**
* Simplified Event Buffer
*
* Rules:
* 1. All events go into a single list buffer (event_buffer:queue)
* 2. screen_view events are handled specially:
* - Store current screen_view as "last" for the session
* - When a new screen_view arrives, flush the previous one with calculated duration
* 3. session_end events:
* - Retrieve the last screen_view (don't modify it)
* - Push both screen_view and session_end to buffer
* 4. Flush: Simply process all events from the list buffer
*/
export class EventBuffer extends BaseBuffer { export class EventBuffer extends BaseBuffer {
// Configurable limits
private batchSize = process.env.EVENT_BUFFER_BATCH_SIZE private batchSize = process.env.EVENT_BUFFER_BATCH_SIZE
? Number.parseInt(process.env.EVENT_BUFFER_BATCH_SIZE, 10) ? Number.parseInt(process.env.EVENT_BUFFER_BATCH_SIZE, 10)
: 4000; : 4000;
@@ -36,124 +16,26 @@ export class EventBuffer extends BaseBuffer {
? Number.parseInt(process.env.EVENT_BUFFER_CHUNK_SIZE, 10) ? Number.parseInt(process.env.EVENT_BUFFER_CHUNK_SIZE, 10)
: 1000; : 1000;
private microBatchIntervalMs = process.env.EVENT_BUFFER_MICRO_BATCH_MS
? Number.parseInt(process.env.EVENT_BUFFER_MICRO_BATCH_MS, 10)
: 10;
private microBatchMaxSize = process.env.EVENT_BUFFER_MICRO_BATCH_SIZE
? Number.parseInt(process.env.EVENT_BUFFER_MICRO_BATCH_SIZE, 10)
: 100;
private pendingEvents: IClickhouseEvent[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private isFlushing = false;
/** Tracks consecutive flush failures for observability; reset on success. */
private flushRetryCount = 0;
private activeVisitorsExpiration = 60 * 5; // 5 minutes private activeVisitorsExpiration = 60 * 5; // 5 minutes
/** How often (ms) we refresh the heartbeat key + zadd per visitor. */
// LIST - Stores all events ready to be flushed private heartbeatRefreshMs = 60_000; // 1 minute
private lastHeartbeat = new Map<string, number>();
private queueKey = 'event_buffer:queue'; private queueKey = 'event_buffer:queue';
// STRING - Tracks total buffer size incrementally
protected bufferCounterKey = 'event_buffer:total_count'; protected bufferCounterKey = 'event_buffer:total_count';
// Script SHAs for loaded Lua scripts
private scriptShas: {
addScreenView?: string;
addSessionEnd?: string;
} = {};
// Hash key for storing last screen_view per session
private getLastScreenViewKeyBySession(sessionId: string) {
return `event_buffer:last_screen_view:session:${sessionId}`;
}
// Hash key for storing last screen_view per profile
private getLastScreenViewKeyByProfile(projectId: string, profileId: string) {
return `event_buffer:last_screen_view:profile:${projectId}:${profileId}`;
}
/**
* Lua script for handling screen_view addition - RACE-CONDITION SAFE without GroupMQ
*
* Strategy: Use Redis GETDEL (atomic get-and-delete) to ensure only ONE thread
* can process the "last" screen_view at a time.
*
* KEYS[1] = last screen_view key (by session) - stores both event and timestamp as JSON
* KEYS[2] = last screen_view key (by profile, may be empty)
* KEYS[3] = queue key
* KEYS[4] = buffer counter key
* ARGV[1] = new event with timestamp as JSON: {"event": {...}, "ts": 123456}
* ARGV[2] = TTL for last screen_view (1 hour)
*/
private readonly addScreenViewScript = `
local sessionKey = KEYS[1]
local profileKey = KEYS[2]
local queueKey = KEYS[3]
local counterKey = KEYS[4]
local newEventData = ARGV[1]
local ttl = tonumber(ARGV[2])
-- GETDEL is atomic: get previous and delete in one operation
-- This ensures only ONE thread gets the previous event
local previousEventData = redis.call("GETDEL", sessionKey)
-- Store new screen_view as last for session
redis.call("SET", sessionKey, newEventData, "EX", ttl)
-- Store new screen_view as last for profile (if key provided)
if profileKey and profileKey ~= "" then
redis.call("SET", profileKey, newEventData, "EX", ttl)
end
-- If there was a previous screen_view, add it to queue with calculated duration
if previousEventData then
local prev = cjson.decode(previousEventData)
local curr = cjson.decode(newEventData)
-- Calculate duration (ensure non-negative to handle clock skew)
if prev.ts and curr.ts then
prev.event.duration = math.max(0, curr.ts - prev.ts)
end
redis.call("RPUSH", queueKey, cjson.encode(prev.event))
redis.call("INCR", counterKey)
return 1
end
return 0
`;
/**
* Lua script for handling session_end - RACE-CONDITION SAFE
*
* Uses GETDEL to atomically retrieve and delete the last screen_view
*
* KEYS[1] = last screen_view key (by session)
* KEYS[2] = last screen_view key (by profile, may be empty)
* KEYS[3] = queue key
* KEYS[4] = buffer counter key
* ARGV[1] = session_end event JSON
*/
private readonly addSessionEndScript = `
local sessionKey = KEYS[1]
local profileKey = KEYS[2]
local queueKey = KEYS[3]
local counterKey = KEYS[4]
local sessionEndJson = ARGV[1]
-- GETDEL is atomic: only ONE thread gets the last screen_view
local previousEventData = redis.call("GETDEL", sessionKey)
local added = 0
-- If there was a previous screen_view, add it to queue
if previousEventData then
local prev = cjson.decode(previousEventData)
redis.call("RPUSH", queueKey, cjson.encode(prev.event))
redis.call("INCR", counterKey)
added = added + 1
end
-- Add session_end to queue
redis.call("RPUSH", queueKey, sessionEndJson)
redis.call("INCR", counterKey)
added = added + 1
-- Delete profile key
if profileKey and profileKey ~= "" then
redis.call("DEL", profileKey)
end
return added
`;
constructor() { constructor() {
super({ super({
name: 'event', name: 'event',
@@ -161,112 +43,54 @@ return added
await this.processBuffer(); await this.processBuffer();
}, },
}); });
// Load Lua scripts into Redis on startup
this.loadScripts();
}
/**
* Load Lua scripts into Redis and cache their SHAs.
* This avoids sending the entire script on every call.
*/
private async loadScripts() {
try {
const redis = getRedisCache();
const [screenViewSha, sessionEndSha] = await Promise.all([
redis.script('LOAD', this.addScreenViewScript),
redis.script('LOAD', this.addSessionEndScript),
]);
this.scriptShas.addScreenView = screenViewSha as string;
this.scriptShas.addSessionEnd = sessionEndSha as string;
this.logger.info('Loaded Lua scripts into Redis', {
addScreenView: this.scriptShas.addScreenView,
addSessionEnd: this.scriptShas.addSessionEnd,
});
} catch (error) {
this.logger.error('Failed to load Lua scripts', { error });
}
} }
bulkAdd(events: IClickhouseEvent[]) { bulkAdd(events: IClickhouseEvent[]) {
const redis = getRedisCache();
const multi = redis.multi();
for (const event of events) { for (const event of events) {
this.add(event, multi); this.add(event);
} }
return multi.exec();
} }
/** add(event: IClickhouseEvent) {
* Add an event into Redis buffer. this.pendingEvents.push(event);
*
* Logic: if (this.pendingEvents.length >= this.microBatchMaxSize) {
* - screen_view: Store as "last" for session, flush previous if exists this.flushLocalBuffer();
* - session_end: Flush last screen_view + session_end return;
* - Other events: Add directly to queue }
*/
async add(event: IClickhouseEvent, _multi?: ReturnType<Redis['multi']>) { if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flushLocalBuffer();
}, this.microBatchIntervalMs);
}
}
public async flush() {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
await this.flushLocalBuffer();
}
private async flushLocalBuffer() {
if (this.isFlushing || this.pendingEvents.length === 0) {
return;
}
this.isFlushing = true;
const eventsToFlush = this.pendingEvents;
this.pendingEvents = [];
try { try {
const redis = getRedisCache(); const redis = getRedisCache();
const eventJson = JSON.stringify(event); const multi = redis.multi();
const multi = _multi || redis.multi();
if (event.session_id && event.name === 'screen_view') {
// Handle screen_view
const sessionKey = this.getLastScreenViewKeyBySession(event.session_id);
const profileKey = event.profile_id
? this.getLastScreenViewKeyByProfile(
event.project_id,
event.profile_id,
)
: '';
const timestamp = new Date(event.created_at || Date.now()).getTime();
// Combine event and timestamp into single JSON for atomic operations
const eventWithTimestamp = JSON.stringify({
event: event,
ts: timestamp,
});
this.evalScript(
multi,
'addScreenView',
this.addScreenViewScript,
4,
sessionKey,
profileKey,
this.queueKey,
this.bufferCounterKey,
eventWithTimestamp,
'3600', // 1 hour TTL
);
} else if (event.session_id && event.name === 'session_end') {
// Handle session_end
const sessionKey = this.getLastScreenViewKeyBySession(event.session_id);
const profileKey = event.profile_id
? this.getLastScreenViewKeyByProfile(
event.project_id,
event.profile_id,
)
: '';
this.evalScript(
multi,
'addSessionEnd',
this.addSessionEndScript,
4,
sessionKey,
profileKey,
this.queueKey,
this.bufferCounterKey,
eventJson,
);
} else {
// All other events go directly to queue
multi.rpush(this.queueKey, eventJson).incr(this.bufferCounterKey);
}
for (const event of eventsToFlush) {
multi.rpush(this.queueKey, JSON.stringify(event));
if (event.profile_id) { if (event.profile_id) {
this.incrementActiveVisitorCount( this.incrementActiveVisitorCount(
multi, multi,
@@ -274,57 +98,42 @@ return added
event.profile_id, event.profile_id,
); );
} }
}
multi.incrby(this.bufferCounterKey, eventsToFlush.length);
if (!_multi) {
await multi.exec(); await multi.exec();
}
await publishEvent('events', 'received', transformEvent(event)); this.flushRetryCount = 0;
this.pruneHeartbeatMap();
} catch (error) { } catch (error) {
this.logger.error('Failed to add event to Redis buffer', { error }); // Re-queue failed events at the front to preserve order and avoid data loss
this.pendingEvents = eventsToFlush.concat(this.pendingEvents);
this.flushRetryCount += 1;
this.logger.warn(
'Failed to flush local buffer to Redis; events re-queued',
{
error,
eventCount: eventsToFlush.length,
flushRetryCount: this.flushRetryCount,
},
);
} finally {
this.isFlushing = false;
// Events may have accumulated while we were flushing; schedule another flush if needed
if (this.pendingEvents.length > 0 && !this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flushLocalBuffer();
}, this.microBatchIntervalMs);
}
} }
} }
/**
* Execute a Lua script using EVALSHA (cached) or fallback to EVAL.
* This avoids sending the entire script on every call.
*/
private evalScript(
multi: ReturnType<Redis['multi']>,
scriptName: keyof typeof this.scriptShas,
scriptContent: string,
numKeys: number,
...args: (string | number)[]
) {
const sha = this.scriptShas[scriptName];
if (sha) {
// Use EVALSHA with cached SHA
multi.evalsha(sha, numKeys, ...args);
} else {
// Fallback to EVAL and try to reload script
multi.eval(scriptContent, numKeys, ...args);
this.logger.warn(`Script ${scriptName} not loaded, using EVAL fallback`);
// Attempt to reload scripts in background
this.loadScripts();
}
}
/**
* Process the Redis buffer - simplified version.
*
* Simply:
* 1. Fetch events from the queue (up to batchSize)
* 2. Parse and sort them
* 3. Insert into ClickHouse in chunks
* 4. Publish saved events
* 5. Clean up processed events from queue
*/
async processBuffer() { async processBuffer() {
const redis = getRedisCache(); const redis = getRedisCache();
try { try {
// Fetch events from queue
const queueEvents = await redis.lrange( const queueEvents = await redis.lrange(
this.queueKey, this.queueKey,
0, 0,
@@ -336,7 +145,6 @@ return added
return; return;
} }
// Parse events
const eventsToClickhouse: IClickhouseEvent[] = []; const eventsToClickhouse: IClickhouseEvent[] = [];
for (const eventStr of queueEvents) { for (const eventStr of queueEvents) {
const event = getSafeJson<IClickhouseEvent>(eventStr); const event = getSafeJson<IClickhouseEvent>(eventStr);
@@ -350,14 +158,12 @@ return added
return; return;
} }
// Sort events by creation time
eventsToClickhouse.sort( eventsToClickhouse.sort(
(a, b) => (a, b) =>
new Date(a.created_at || 0).getTime() - new Date(a.created_at || 0).getTime() -
new Date(b.created_at || 0).getTime(), new Date(b.created_at || 0).getTime(),
); );
// Insert events into ClickHouse in chunks
this.logger.info('Inserting events into ClickHouse', { this.logger.info('Inserting events into ClickHouse', {
totalEvents: eventsToClickhouse.length, totalEvents: eventsToClickhouse.length,
chunks: Math.ceil(eventsToClickhouse.length / this.chunkSize), chunks: Math.ceil(eventsToClickhouse.length / this.chunkSize),
@@ -371,14 +177,17 @@ return added
}); });
} }
// Publish "saved" events const countByProject = new Map<string, number>();
const pubMulti = getRedisPub().multi();
for (const event of eventsToClickhouse) { for (const event of eventsToClickhouse) {
await publishEvent('events', 'saved', transformEvent(event), pubMulti); countByProject.set(
event.project_id,
(countByProject.get(event.project_id) ?? 0) + 1,
);
}
for (const [projectId, count] of countByProject) {
publishEvent('events', 'batch', { projectId, count });
} }
await pubMulti.exec();
// Clean up processed events from queue
await redis await redis
.multi() .multi()
.ltrim(this.queueKey, queueEvents.length, -1) .ltrim(this.queueKey, queueEvents.length, -1)
@@ -394,45 +203,6 @@ return added
} }
} }
/**
* Retrieve the latest screen_view event for a given session or profile
*/
public async getLastScreenView(
params:
| {
sessionId: string;
}
| {
projectId: string;
profileId: string;
},
): Promise<IServiceEvent | null> {
const redis = getRedisCache();
let lastScreenViewKey: string;
if ('sessionId' in params) {
lastScreenViewKey = this.getLastScreenViewKeyBySession(params.sessionId);
} else {
lastScreenViewKey = this.getLastScreenViewKeyByProfile(
params.projectId,
params.profileId,
);
}
const eventDataStr = await redis.get(lastScreenViewKey);
if (eventDataStr) {
const eventData = getSafeJson<{ event: IClickhouseEvent; ts: number }>(
eventDataStr,
);
if (eventData?.event) {
return transformEvent(eventData.event);
}
}
return null;
}
public async getBufferSize() { public async getBufferSize() {
return this.getBufferSizeWithCounter(async () => { return this.getBufferSizeWithCounter(async () => {
const redis = getRedisCache(); const redis = getRedisCache();
@@ -440,16 +210,32 @@ return added
}); });
} }
private async incrementActiveVisitorCount( private pruneHeartbeatMap() {
const cutoff = Date.now() - this.activeVisitorsExpiration * 1000;
for (const [key, ts] of this.lastHeartbeat) {
if (ts < cutoff) {
this.lastHeartbeat.delete(key);
}
}
}
private incrementActiveVisitorCount(
multi: ReturnType<Redis['multi']>, multi: ReturnType<Redis['multi']>,
projectId: string, projectId: string,
profileId: string, profileId: string,
) { ) {
// Track active visitors and emit expiry events when inactive for TTL const key = `${projectId}:${profileId}`;
const now = Date.now(); const now = Date.now();
const last = this.lastHeartbeat.get(key) ?? 0;
if (now - last < this.heartbeatRefreshMs) {
return;
}
this.lastHeartbeat.set(key, now);
const zsetKey = `live:visitors:${projectId}`; const zsetKey = `live:visitors:${projectId}`;
const heartbeatKey = `live:visitor:${projectId}:${profileId}`; const heartbeatKey = `live:visitor:${projectId}:${profileId}`;
return multi multi
.zadd(zsetKey, now, profileId) .zadd(zsetKey, now, profileId)
.set(heartbeatKey, '1', 'EX', this.activeVisitorsExpiration); .set(heartbeatKey, '1', 'EX', this.activeVisitorsExpiration);
} }

View File

@@ -1,11 +1,11 @@
import { deepMergeObjects } from '@openpanel/common'; import { deepMergeObjects } from '@openpanel/common';
import { getSafeJson } from '@openpanel/json'; import { getSafeJson } from '@openpanel/json';
import type { ILogger } from '@openpanel/logger'; import type { ILogger } from '@openpanel/logger';
import { type Redis, getRedisCache } from '@openpanel/redis'; import { getRedisCache, type Redis } from '@openpanel/redis';
import shallowEqual from 'fast-deep-equal'; import shallowEqual from 'fast-deep-equal';
import { omit } from 'ramda'; import { omit } from 'ramda';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { TABLE_NAMES, ch, chQuery } from '../clickhouse/client'; import { ch, chQuery, TABLE_NAMES } from '../clickhouse/client';
import type { IClickhouseProfile } from '../services/profile.service'; import type { IClickhouseProfile } from '../services/profile.service';
import { BaseBuffer } from './base-buffer'; import { BaseBuffer } from './base-buffer';
@@ -89,7 +89,7 @@ export class ProfileBuffer extends BaseBuffer {
'os_version', 'os_version',
'browser_version', 'browser_version',
], ],
profile.properties, profile.properties
); );
} }
@@ -97,17 +97,17 @@ export class ProfileBuffer extends BaseBuffer {
? deepMergeObjects(existingProfile, omit(['created_at'], profile)) ? deepMergeObjects(existingProfile, omit(['created_at'], profile))
: profile; : profile;
if (profile && existingProfile) {
if ( if (
profile &&
existingProfile &&
shallowEqual( shallowEqual(
omit(['created_at'], existingProfile), omit(['created_at'], existingProfile),
omit(['created_at'], mergedProfile), omit(['created_at'], mergedProfile)
) )
) { ) {
this.logger.debug('Profile not changed, skipping'); this.logger.debug('Profile not changed, skipping');
return; return;
} }
}
this.logger.debug('Merged profile will be inserted', { this.logger.debug('Merged profile will be inserted', {
mergedProfile, mergedProfile,
@@ -151,11 +151,11 @@ export class ProfileBuffer extends BaseBuffer {
private async fetchProfile( private async fetchProfile(
profile: IClickhouseProfile, profile: IClickhouseProfile,
logger: ILogger, logger: ILogger
): Promise<IClickhouseProfile | null> { ): Promise<IClickhouseProfile | null> {
const existingProfile = await this.fetchFromCache( const existingProfile = await this.fetchFromCache(
profile.id, profile.id,
profile.project_id, profile.project_id
); );
if (existingProfile) { if (existingProfile) {
logger.debug('Profile found in Redis'); logger.debug('Profile found in Redis');
@@ -167,7 +167,7 @@ export class ProfileBuffer extends BaseBuffer {
public async fetchFromCache( public async fetchFromCache(
profileId: string, profileId: string,
projectId: string, projectId: string
): Promise<IClickhouseProfile | null> { ): Promise<IClickhouseProfile | null> {
const cacheKey = this.getProfileCacheKey({ const cacheKey = this.getProfileCacheKey({
profileId, profileId,
@@ -182,7 +182,7 @@ export class ProfileBuffer extends BaseBuffer {
private async fetchFromClickhouse( private async fetchFromClickhouse(
profile: IClickhouseProfile, profile: IClickhouseProfile,
logger: ILogger, logger: ILogger
): Promise<IClickhouseProfile | null> { ): Promise<IClickhouseProfile | null> {
logger.debug('Fetching profile from Clickhouse'); logger.debug('Fetching profile from Clickhouse');
const result = await chQuery<IClickhouseProfile>( const result = await chQuery<IClickhouseProfile>(
@@ -207,7 +207,7 @@ export class ProfileBuffer extends BaseBuffer {
} }
GROUP BY id, project_id GROUP BY id, project_id
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1`, LIMIT 1`
); );
logger.debug('Clickhouse fetch result', { logger.debug('Clickhouse fetch result', {
found: !!result[0], found: !!result[0],
@@ -221,7 +221,7 @@ export class ProfileBuffer extends BaseBuffer {
const profiles = await this.redis.lrange( const profiles = await this.redis.lrange(
this.redisKey, this.redisKey,
0, 0,
this.batchSize - 1, this.batchSize - 1
); );
if (profiles.length === 0) { if (profiles.length === 0) {
@@ -231,7 +231,7 @@ export class ProfileBuffer extends BaseBuffer {
this.logger.debug(`Processing ${profiles.length} profiles in buffer`); this.logger.debug(`Processing ${profiles.length} profiles in buffer`);
const parsedProfiles = profiles.map((p) => const parsedProfiles = profiles.map((p) =>
getSafeJson<IClickhouseProfile>(p), getSafeJson<IClickhouseProfile>(p)
); );
for (const chunk of this.chunks(parsedProfiles, this.chunkSize)) { for (const chunk of this.chunks(parsedProfiles, this.chunkSize)) {

View File

@@ -58,6 +58,9 @@ export const TABLE_NAMES = {
sessions: 'sessions', sessions: 'sessions',
events_imports: 'events_imports', events_imports: 'events_imports',
session_replay_chunks: 'session_replay_chunks', session_replay_chunks: 'session_replay_chunks',
gsc_daily: 'gsc_daily',
gsc_pages_daily: 'gsc_pages_daily',
gsc_queries_daily: 'gsc_queries_daily',
}; };
/** /**

View File

@@ -0,0 +1,44 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const ENCODING = 'base64';
function getKey(): Buffer {
const raw = process.env.ENCRYPTION_KEY;
if (!raw) {
throw new Error('ENCRYPTION_KEY environment variable is not set');
}
const buf = Buffer.from(raw, 'hex');
if (buf.length !== 32) {
throw new Error(
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'
);
}
return buf;
}
export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Format: base64(iv + tag + ciphertext)
return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
}
export function decrypt(ciphertext: string): string {
const key = getKey();
const buf = Buffer.from(ciphertext, ENCODING);
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final('utf8');
}

576
packages/db/src/gsc.ts Normal file
View File

@@ -0,0 +1,576 @@
import { cacheable } from '@openpanel/redis';
import { originalCh } from './clickhouse/client';
import { decrypt, encrypt } from './encryption';
import { createLogger } from '@openpanel/logger';
import { db } from './prisma-client';
const logger = createLogger({ name: 'db:gsc' });
export interface GscSite {
siteUrl: string;
permissionLevel: string;
}
async function refreshGscToken(
refreshToken: string
): Promise<{ accessToken: string; expiresAt: Date }> {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
throw new Error(
'GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is not set in this environment'
);
}
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to refresh GSC token: ${text}`);
}
const data = (await res.json()) as {
access_token: string;
expires_in: number;
};
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
return { accessToken: data.access_token, expiresAt };
}
export async function getGscAccessToken(projectId: string): Promise<string> {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
if (
conn.accessTokenExpiresAt &&
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
) {
logger.info('GSC using cached access token', {
projectId,
expiresAt: conn.accessTokenExpiresAt,
});
return decrypt(conn.accessToken);
}
logger.info('GSC access token expired, attempting refresh', {
projectId,
expiresAt: conn.accessTokenExpiresAt,
hasRefreshToken: !!conn.refreshToken,
});
try {
const { accessToken, expiresAt } = await refreshGscToken(
decrypt(conn.refreshToken)
);
await db.gscConnection.update({
where: { projectId },
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
});
logger.info('GSC token refreshed successfully', { projectId, expiresAt });
return accessToken;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to refresh token';
logger.error('GSC token refresh failed', { projectId, error: errorMessage });
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncStatus: 'token_expired',
lastSyncError: errorMessage,
},
});
throw new Error(
`GSC token refresh failed for project ${projectId}: ${errorMessage}`
);
}
}
export async function listGscSites(projectId: string): Promise<GscSite[]> {
const accessToken = await getGscAccessToken(projectId);
const res = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to list GSC sites: ${text}`);
}
const data = (await res.json()) as {
siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>;
};
return data.siteEntry ?? [];
}
interface GscApiRow {
keys: string[];
clicks: number;
impressions: number;
ctr: number;
position: number;
}
interface GscDimensionFilter {
dimension: string;
operator: string;
expression: string;
}
interface GscFilterGroup {
filters: GscDimensionFilter[];
}
async function queryGscSearchAnalytics(
accessToken: string,
siteUrl: string,
startDate: string,
endDate: string,
dimensions: string[],
dimensionFilterGroups?: GscFilterGroup[]
): Promise<GscApiRow[]> {
const encodedSiteUrl = encodeURIComponent(siteUrl);
const url = `https://www.googleapis.com/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
const allRows: GscApiRow[] = [];
let startRow = 0;
const rowLimit = 25000;
while (true) {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate,
endDate,
dimensions,
rowLimit,
startRow,
dataState: 'all',
...(dimensionFilterGroups && { dimensionFilterGroups }),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`);
}
const data = (await res.json()) as { rows?: GscApiRow[] };
const rows = data.rows ?? [];
allRows.push(...rows);
if (rows.length < rowLimit) break;
startRow += rowLimit;
}
return allRows;
}
function formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function nowString(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
export async function syncGscData(
projectId: string,
startDate: Date,
endDate: Date
): Promise<void> {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
if (!conn.siteUrl) {
throw new Error('No GSC site URL configured for this project');
}
const accessToken = await getGscAccessToken(projectId);
const start = formatDate(startDate);
const end = formatDate(endDate);
const syncedAt = nowString();
// 1. Daily totals — authoritative numbers for overview chart
const dailyRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date']
);
if (dailyRows.length > 0) {
await originalCh.insert({
table: 'gsc_daily',
values: dailyRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
// 2. Per-page breakdown
const pageRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date', 'page']
);
if (pageRows.length > 0) {
await originalCh.insert({
table: 'gsc_pages_daily',
values: pageRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
page: row.keys[1] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
// 3. Per-query breakdown
const queryRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date', 'query']
);
if (queryRows.length > 0) {
await originalCh.insert({
table: 'gsc_queries_daily',
values: queryRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
query: row.keys[1] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
}
export async function getGscOverview(
projectId: string,
startDate: string,
endDate: string,
interval: 'day' | 'week' | 'month' = 'day'
): Promise<
Array<{
date: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const dateExpr =
interval === 'month'
? 'toStartOfMonth(date)'
: interval === 'week'
? 'toStartOfWeek(date)'
: 'date';
const result = await originalCh.query({
query: `
SELECT
${dateExpr} as date,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY date
ORDER BY date ASC
`,
query_params: { projectId, startDate, endDate },
format: 'JSONEachRow',
});
return result.json();
}
export async function getGscPages(
projectId: string,
startDate: string,
endDate: string,
limit = 100
): Promise<
Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const result = await originalCh.query({
query: `
SELECT
page,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_pages_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY page
ORDER BY clicks DESC
LIMIT {limit: UInt32}
`,
query_params: { projectId, startDate, endDate, limit },
format: 'JSONEachRow',
});
return result.json();
}
export interface GscCannibalizedQuery {
query: string;
totalImpressions: number;
totalClicks: number;
pages: Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
}
export const getGscCannibalization = cacheable(
async (
projectId: string,
startDate: string,
endDate: string
): Promise<GscCannibalizedQuery[]> => {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
const accessToken = await getGscAccessToken(projectId);
const rows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['query', 'page']
);
const map = new Map<
string,
{
totalImpressions: number;
totalClicks: number;
pages: GscCannibalizedQuery['pages'];
}
>();
for (const row of rows) {
const query = row.keys[0] ?? '';
// Strip hash fragments — GSC records heading anchors (e.g. /page#section)
// as separate URLs but Google treats them as the same page
let page = row.keys[1] ?? '';
try {
const u = new URL(page);
u.hash = '';
page = u.toString();
} catch {
page = page.split('#')[0] ?? page;
}
const entry = map.get(query) ?? {
totalImpressions: 0,
totalClicks: 0,
pages: [],
};
entry.totalImpressions += row.impressions;
entry.totalClicks += row.clicks;
// Merge into existing page entry if already seen (from a different hash variant)
const existing = entry.pages.find((p) => p.page === page);
if (existing) {
const totalImpressions = existing.impressions + row.impressions;
if (totalImpressions > 0) {
existing.position =
(existing.position * existing.impressions + row.position * row.impressions) / totalImpressions;
}
existing.clicks += row.clicks;
existing.impressions += row.impressions;
existing.ctr =
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
} else {
entry.pages.push({
page,
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
});
}
map.set(query, entry);
}
return [...map.entries()]
.filter(([, v]) => v.pages.length >= 2 && v.totalImpressions >= 100)
.sort(([, a], [, b]) => b.totalImpressions - a.totalImpressions)
.slice(0, 50)
.map(([query, v]) => ({
query,
totalImpressions: v.totalImpressions,
totalClicks: v.totalClicks,
pages: v.pages.sort((a, b) =>
a.position !== b.position
? a.position - b.position
: b.impressions - a.impressions
),
}));
},
60 * 60 * 4
);
export async function getGscPageDetails(
projectId: string,
page: string,
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] }];
const [timeseriesRows, queryRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['query'], filterGroups),
]);
return {
timeseries: timeseriesRows.map((row) => ({
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
queries: queryRows.map((row) => ({
query: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
};
}
export async function getGscQueryDetails(
projectId: string,
query: string,
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'query', operator: 'equals', expression: query }] }];
const [timeseriesRows, pageRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['page'], filterGroups),
]);
return {
timeseries: timeseriesRows.map((row) => ({
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
pages: pageRows.map((row) => ({
page: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
};
}
export async function getGscQueries(
projectId: string,
startDate: string,
endDate: string,
limit = 100
): Promise<
Array<{
query: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const result = await originalCh.query({
query: `
SELECT
query,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_queries_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY query
ORDER BY clicks DESC
LIMIT {limit: UInt32}
`,
query_params: { projectId, startDate, endDate, limit },
format: 'JSONEachRow',
});
return result.json();
}

View File

@@ -1,4 +1,4 @@
import { cacheable, cacheableLru } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import type { Client, Prisma } from '../prisma-client'; import type { Client, Prisma } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
@@ -34,7 +34,4 @@ export async function getClientById(
}); });
} }
export const getClientByIdCached = cacheableLru(getClientById, { export const getClientByIdCached = cacheable(getClientById, 60 * 5);
maxSize: 1000,
ttl: 60 * 5,
});

View File

@@ -168,7 +168,6 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
device: event.device, device: event.device,
brand: event.brand, brand: event.brand,
model: event.model, model: event.model,
duration: event.duration,
path: event.path, path: event.path,
origin: event.origin, origin: event.origin,
referrer: event.referrer, referrer: event.referrer,
@@ -216,7 +215,7 @@ export interface IServiceEvent {
device?: string | undefined; device?: string | undefined;
brand?: string | undefined; brand?: string | undefined;
model?: string | undefined; model?: string | undefined;
duration: number; duration?: number;
path: string; path: string;
origin: string; origin: string;
referrer: string | undefined; referrer: string | undefined;
@@ -247,7 +246,7 @@ export interface IServiceEventMinimal {
browser?: string | undefined; browser?: string | undefined;
device?: string | undefined; device?: string | undefined;
brand?: string | undefined; brand?: string | undefined;
duration: number; duration?: number;
path: string; path: string;
origin: string; origin: string;
referrer: string | undefined; referrer: string | undefined;
@@ -379,7 +378,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
device: payload.device ?? '', device: payload.device ?? '',
brand: payload.brand ?? '', brand: payload.brand ?? '',
model: payload.model ?? '', model: payload.model ?? '',
duration: payload.duration, duration: payload.duration ?? 0,
referrer: payload.referrer ?? '', referrer: payload.referrer ?? '',
referrer_name: payload.referrerName ?? '', referrer_name: payload.referrerName ?? '',
referrer_type: payload.referrerType ?? '', referrer_type: payload.referrerType ?? '',
@@ -477,7 +476,7 @@ export async function getEventList(options: GetEventListOptions) {
sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`; sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
} }
if (!cursor && !(startDate && endDate)) { if (!(cursor || (startDate && endDate))) {
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`; sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
} }
@@ -562,9 +561,6 @@ export async function getEventList(options: GetEventListOptions) {
if (select.model) { if (select.model) {
sb.select.model = 'model'; sb.select.model = 'model';
} }
if (select.duration) {
sb.select.duration = 'duration';
}
if (select.path) { if (select.path) {
sb.select.path = 'path'; sb.select.path = 'path';
} }
@@ -771,7 +767,6 @@ class EventService {
where, where,
select, select,
limit, limit,
orderBy,
filters, filters,
}: { }: {
projectId: string; projectId: string;
@@ -811,7 +806,6 @@ class EventService {
select.event.deviceId && 'e.device_id as device_id', select.event.deviceId && 'e.device_id as device_id',
select.event.name && 'e.name as name', select.event.name && 'e.name as name',
select.event.path && 'e.path as path', select.event.path && 'e.path as path',
select.event.duration && 'e.duration as duration',
select.event.country && 'e.country as country', select.event.country && 'e.country as country',
select.event.city && 'e.city as city', select.event.city && 'e.city as city',
select.event.os && 'e.os as os', select.event.os && 'e.os as os',
@@ -896,7 +890,6 @@ class EventService {
select.event.deviceId && 'e.device_id as device_id', select.event.deviceId && 'e.device_id as device_id',
select.event.name && 'e.name as name', select.event.name && 'e.name as name',
select.event.path && 'e.path as path', select.event.path && 'e.path as path',
select.event.duration && 'e.duration as duration',
select.event.country && 'e.country as country', select.event.country && 'e.country as country',
select.event.city && 'e.city as city', select.event.city && 'e.city as city',
select.event.os && 'e.os as os', select.event.os && 'e.os as os',
@@ -1032,7 +1025,6 @@ class EventService {
id: true, id: true,
name: true, name: true,
createdAt: true, createdAt: true,
duration: true,
country: true, country: true,
city: true, city: true,
os: true, os: true,

View File

@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
}, },
}); });
}, },
60 * 24 60 * 24,
); );
function getIntegration(integrationId: string | null) { function getIntegration(integrationId: string | null) {

View File

@@ -416,6 +416,30 @@ export class OverviewService {
const where = this.getRawWhereClause('sessions', filters); const where = this.getRawWhereClause('sessions', filters);
const fillConfig = this.getFillConfig(interval, startDate, endDate); const fillConfig = this.getFillConfig(interval, startDate, endDate);
// CTE: per-event screen_view durations via window function
const rawScreenViewDurationsQuery = clix(this.client, timezone)
.select([
`${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
`dateDiff('millisecond', created_at, lead(created_at, 1, created_at) OVER (PARTITION BY session_id ORDER BY created_at)) AS duration`,
])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('name', '=', 'screen_view')
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(this.getRawWhereClause('events', filters));
// CTE: avg duration per date bucket
const avgDurationByDateQuery = clix(this.client, timezone)
.select([
'date',
'round(avgIf(duration, duration > 0), 2) / 1000 AS avg_session_duration',
])
.from('raw_screen_view_durations')
.groupBy(['date']);
// Session aggregation with bounce rates // Session aggregation with bounce rates
const sessionAggQuery = clix(this.client, timezone) const sessionAggQuery = clix(this.client, timezone)
.select([ .select([
@@ -473,6 +497,8 @@ export class OverviewService {
.where('date', '!=', rollupDate) .where('date', '!=', rollupDate)
) )
.with('overall_unique_visitors', overallUniqueVisitorsQuery) .with('overall_unique_visitors', overallUniqueVisitorsQuery)
.with('raw_screen_view_durations', rawScreenViewDurationsQuery)
.with('avg_duration_by_date', avgDurationByDateQuery)
.select<{ .select<{
date: string; date: string;
bounce_rate: number; bounce_rate: number;
@@ -489,8 +515,7 @@ export class OverviewService {
'dss.bounce_rate as bounce_rate', 'dss.bounce_rate as bounce_rate',
'uniq(e.profile_id) AS unique_visitors', 'uniq(e.profile_id) AS unique_visitors',
'uniq(e.session_id) AS total_sessions', 'uniq(e.session_id) AS total_sessions',
'round(avgIf(duration, duration > 0), 2) / 1000 AS _avg_session_duration', 'coalesce(dur.avg_session_duration, 0) AS avg_session_duration',
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
'count(*) AS total_screen_views', 'count(*) AS total_screen_views',
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session', 'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session',
'(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors', '(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
@@ -502,6 +527,10 @@ export class OverviewService {
'daily_session_stats AS dss', 'daily_session_stats AS dss',
`${clix.toStartOf('e.created_at', interval as any)} = dss.date` `${clix.toStartOf('e.created_at', interval as any)} = dss.date`
) )
.leftJoin(
'avg_duration_by_date AS dur',
`${clix.toStartOf('e.created_at', interval as any)} = dur.date`
)
.where('e.project_id', '=', projectId) .where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view') .where('e.name', '=', 'screen_view')
.where('e.created_at', 'BETWEEN', [ .where('e.created_at', 'BETWEEN', [
@@ -509,7 +538,7 @@ export class OverviewService {
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.rawWhere(this.getRawWhereClause('events', filters)) .rawWhere(this.getRawWhereClause('events', filters))
.groupBy(['date', 'dss.bounce_rate']) .groupBy(['date', 'dss.bounce_rate', 'dur.avg_session_duration'])
.orderBy('date', 'ASC') .orderBy('date', 'ASC')
.fill(fillConfig.from, fillConfig.to, fillConfig.step) .fill(fillConfig.from, fillConfig.to, fillConfig.step)
.transform({ .transform({

View File

@@ -1,4 +1,5 @@
import { TABLE_NAMES, ch } from '../clickhouse/client'; import type { IInterval } from '@openpanel/validation';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder'; import { clix } from '../clickhouse/query-builder';
export interface IGetPagesInput { export interface IGetPagesInput {
@@ -7,6 +8,15 @@ export interface IGetPagesInput {
endDate: string; endDate: string;
timezone: string; timezone: string;
search?: string; search?: string;
limit?: number;
}
export interface IPageTimeseriesRow {
origin: string;
path: string;
date: string;
pageviews: number;
sessions: number;
} }
export interface ITopPage { export interface ITopPage {
@@ -28,6 +38,7 @@ export class PagesService {
endDate, endDate,
timezone, timezone,
search, search,
limit,
}: IGetPagesInput): Promise<ITopPage[]> { }: IGetPagesInput): Promise<ITopPage[]> {
// CTE: Get titles from the last 30 days for faster retrieval // CTE: Get titles from the last 30 days for faster retrieval
const titlesCte = clix(this.client, timezone) const titlesCte = clix(this.client, timezone)
@@ -41,6 +52,24 @@ export class PagesService {
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY')) .where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'))
.groupBy(['origin', 'path']); .groupBy(['origin', 'path']);
// CTE: compute screen_view durations via window function (leadInFrame gives next event's timestamp)
const screenViewDurationsCte = clix(this.client, timezone)
.select([
'project_id',
'session_id',
'path',
'origin',
`dateDiff('millisecond', created_at, lead(created_at, 1, created_at) OVER (PARTITION BY session_id ORDER BY created_at)) AS duration`,
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('name', '=', 'screen_view')
.where('path', '!=', '')
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
]);
// Pre-filtered sessions subquery for better performance // Pre-filtered sessions subquery for better performance
const sessionsSubquery = clix(this.client, timezone) const sessionsSubquery = clix(this.client, timezone)
.select(['id', 'project_id', 'is_bounce']) .select(['id', 'project_id', 'is_bounce'])
@@ -55,6 +84,7 @@ export class PagesService {
// Main query: aggregate events and calculate bounce rate from pre-filtered sessions // Main query: aggregate events and calculate bounce rate from pre-filtered sessions
const query = clix(this.client, timezone) const query = clix(this.client, timezone)
.with('page_titles', titlesCte) .with('page_titles', titlesCte)
.with('screen_view_durations', screenViewDurationsCte)
.select<ITopPage>([ .select<ITopPage>([
'e.origin as origin', 'e.origin as origin',
'e.path as path', 'e.path as path',
@@ -68,13 +98,63 @@ export class PagesService {
2 2
) as bounce_rate`, ) as bounce_rate`,
]) ])
.from(`${TABLE_NAMES.events} e`, false) .from('screen_view_durations e', false)
.leftJoin( .leftJoin(
sessionsSubquery, sessionsSubquery,
'e.session_id = s.id AND e.project_id = s.project_id', 'e.session_id = s.id AND e.project_id = s.project_id',
's', 's'
) )
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key') .leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
.when(!!search, (q) => {
const term = `%${search}%`;
q.whereGroup()
.where('e.path', 'LIKE', term)
.orWhere('e.origin', 'LIKE', term)
.orWhere('pt.title', 'LIKE', term)
.end();
})
.groupBy(['e.origin', 'e.path', 'pt.title'])
.orderBy('sessions', 'DESC');
if (limit !== undefined) {
query.limit(limit);
}
return query.execute();
}
async getPageTimeseries({
projectId,
startDate,
endDate,
timezone,
interval,
filterOrigin,
filterPath,
}: IGetPagesInput & {
interval: IInterval;
filterOrigin?: string;
filterPath?: string;
}): Promise<IPageTimeseriesRow[]> {
const dateExpr = clix.toStartOf('e.created_at', interval, timezone);
const useDateOnly = interval === 'month' || interval === 'week';
const fillFrom = clix.toStartOf(
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
interval
);
const fillTo = clix.datetime(
endDate,
useDateOnly ? 'toDate' : 'toDateTime'
);
const fillStep = clix.toInterval('1', interval);
return clix(this.client, timezone)
.select<IPageTimeseriesRow>([
'e.origin as origin',
'e.path as path',
`${dateExpr} AS date`,
'count() as pageviews',
'uniq(e.session_id) as sessions',
])
.from(`${TABLE_NAMES.events} e`, false)
.where('e.project_id', '=', projectId) .where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view') .where('e.name', '=', 'screen_view')
.where('e.path', '!=', '') .where('e.path', '!=', '')
@@ -82,14 +162,12 @@ export class PagesService {
clix.datetime(startDate, 'toDateTime'), clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.when(!!search, (q) => { .when(!!filterOrigin, (q) => q.where('e.origin', '=', filterOrigin!))
q.where('e.path', 'LIKE', `%${search}%`); .when(!!filterPath, (q) => q.where('e.path', '=', filterPath!))
}) .groupBy(['e.origin', 'e.path', 'date'])
.groupBy(['e.origin', 'e.path', 'pt.title']) .orderBy('date', 'ASC')
.orderBy('sessions', 'DESC') .fill(fillFrom, fillTo, fillStep)
.limit(1000); .execute();
return query.execute();
} }
} }

View File

@@ -1,6 +1,6 @@
import { cacheable } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { TABLE_NAMES, chQuery } from '../clickhouse/client'; import { chQuery, TABLE_NAMES } from '../clickhouse/client';
import type { Prisma, Project } from '../prisma-client'; import type { Prisma, Project } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
@@ -25,6 +25,7 @@ export async function getProjectById(id: string) {
return res; return res;
} }
/** L1 LRU (60s) + L2 Redis. clear() invalidates Redis + local LRU; other nodes may serve stale from LRU for up to 60s. */
export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24); export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24);
export async function getProjectWithClients(id: string) { export async function getProjectWithClients(id: string) {
@@ -44,7 +45,7 @@ export async function getProjectWithClients(id: string) {
return res; return res;
} }
export async function getProjectsByOrganizationId(organizationId: string) { export function getProjectsByOrganizationId(organizationId: string) {
return db.project.findMany({ return db.project.findMany({
where: { where: {
organizationId, organizationId,
@@ -95,7 +96,7 @@ export async function getProjects({
if (access.length > 0) { if (access.length > 0) {
return projects.filter((project) => return projects.filter((project) =>
access.some((a) => a.projectId === project.id), access.some((a) => a.projectId === project.id)
); );
} }
@@ -104,7 +105,7 @@ export async function getProjects({
export const getProjectEventsCount = async (projectId: string) => { export const getProjectEventsCount = async (projectId: string) => {
const res = await chQuery<{ count: number }>( const res = await chQuery<{ count: number }>(
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND name NOT IN ('session_start', 'session_end')`, `SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND name NOT IN ('session_start', 'session_end')`
); );
return res[0]?.count; return res[0]?.count;
}; };

View File

@@ -1,9 +1,9 @@
import { generateSalt } from '@openpanel/common/server'; import { generateSalt } from '@openpanel/common/server';
import { cacheableLru } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
export const getSalts = cacheableLru( export const getSalts = cacheable(
'op:salt', 'op:salt',
async () => { async () => {
const [curr, prev] = await db.salt.findMany({ const [curr, prev] = await db.salt.findMany({
@@ -24,10 +24,7 @@ export const getSalts = cacheableLru(
return salts; return salts;
}, },
{ 60 * 5,
maxSize: 2,
ttl: 60 * 5,
},
); );
export async function createInitialSalts() { export async function createInitialSalts() {

View File

@@ -1,221 +0,0 @@
import { db } from '@openpanel/db';
import { Polar } from '@polar-sh/sdk';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { getSuccessUrl } from '..';
// Register the autocomplete prompt
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
interface Answers {
isProduction: boolean;
polarApiKey: string;
productId: string;
organizationId: string;
}
async function promptForInput() {
// Get all organizations first
const organizations = await db.organization.findMany({
select: {
id: true,
name: true,
},
});
// Step 1: Collect Polar credentials first
const polarCredentials = await inquirer.prompt<{
isProduction: boolean;
polarApiKey: string;
polarOrganizationId: string;
}>([
{
type: 'list',
name: 'isProduction',
message: 'Is this for production?',
choices: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
},
{
type: 'string',
name: 'polarApiKey',
message: 'Enter your Polar API key:',
validate: (input: string) => {
if (!input) return 'API key is required';
return true;
},
},
]);
// Step 2: Initialize Polar client and fetch products
const polar = new Polar({
accessToken: polarCredentials.polarApiKey,
server: polarCredentials.isProduction ? 'production' : 'sandbox',
});
console.log('Fetching products from Polar...');
const productsResponse = await polar.products.list({
limit: 100,
isArchived: false,
sorting: ['price_amount'],
});
const products = productsResponse.result.items;
if (products.length === 0) {
throw new Error('No products found in Polar');
}
// Step 3: Continue with product selection and organization selection
const restOfAnswers = await inquirer.prompt<{
productId: string;
organizationId: string;
}>([
{
type: 'autocomplete',
name: 'productId',
message: 'Select product:',
source: (answersSoFar: any, input = '') => {
return products
.filter(
(product) =>
product.name.toLowerCase().includes(input.toLowerCase()) ||
product.id.toLowerCase().includes(input.toLowerCase()),
)
.map((product) => {
const price = product.prices?.[0];
const priceStr =
price && 'priceAmount' in price && price.priceAmount
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
: 'No price';
return {
name: `${product.name} (${priceStr})`,
value: product.id,
};
});
},
},
{
type: 'autocomplete',
name: 'organizationId',
message: 'Select organization:',
source: (answersSoFar: any, input = '') => {
return organizations
.filter(
(org) =>
org.name.toLowerCase().includes(input.toLowerCase()) ||
org.id.toLowerCase().includes(input.toLowerCase()),
)
.map((org) => ({
name: `${org.name} (${org.id})`,
value: org.id,
}));
},
},
]);
return {
...polarCredentials,
...restOfAnswers,
};
}
async function main() {
console.log('Assigning existing product to organization...');
const input = await promptForInput();
const polar = new Polar({
accessToken: input.polarApiKey,
server: input.isProduction ? 'production' : 'sandbox',
});
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
id: true,
name: true,
createdBy: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
projects: {
select: {
id: true,
},
},
},
});
if (!organization.createdBy) {
throw new Error(
`Organization ${organization.name} does not have a creator. Cannot proceed.`,
);
}
const user = organization.createdBy;
// Fetch product details for review
const product = await polar.products.get({ id: input.productId });
const price = product.prices?.[0];
const priceStr =
price && 'priceAmount' in price && price.priceAmount
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
: 'No price';
console.log('\nReview the following settings:');
console.table({
product: product.name,
price: priceStr,
organization: organization.name,
email: user.email,
name:
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
});
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message: 'Do you want to proceed?',
default: false,
},
]);
if (!confirmed) {
console.log('Operation canceled');
return;
}
const checkoutLink = await polar.checkoutLinks.create({
paymentProcessor: 'stripe',
productId: input.productId,
allowDiscountCodes: false,
metadata: {
organizationId: organization.id,
userId: user.id,
},
successUrl: getSuccessUrl(
input.isProduction
? 'https://dashboard.openpanel.dev'
: 'http://localhost:3000',
organization.id,
),
});
console.log('\nCheckout link created:');
console.table(checkoutLink);
console.log('\nProduct assigned successfully!');
}
main()
.catch(console.error)
.finally(() => db.$disconnect());

View File

@@ -6,7 +6,7 @@ import type {
} from '@openpanel/db'; } from '@openpanel/db';
import { createLogger } from '@openpanel/logger'; import { createLogger } from '@openpanel/logger';
import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis'; import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
import { Queue, QueueEvents } from 'bullmq'; import { Queue } from 'bullmq';
import { Queue as GroupQueue } from 'groupmq'; import { Queue as GroupQueue } from 'groupmq';
import type { ITrackPayload } from '../../validation'; import type { ITrackPayload } from '../../validation';
@@ -66,6 +66,10 @@ export interface EventsQueuePayloadIncomingEvent {
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
deviceId: string; deviceId: string;
sessionId: string; sessionId: string;
session?: Pick<
IServiceCreateEventPayload,
'referrer' | 'referrerName' | 'referrerType'
>;
}; };
} }
export interface EventsQueuePayloadCreateEvent { export interface EventsQueuePayloadCreateEvent {
@@ -126,6 +130,10 @@ export type CronQueuePayloadFlushReplay = {
type: 'flushReplay'; type: 'flushReplay';
payload: undefined; payload: undefined;
}; };
export type CronQueuePayloadGscSync = {
type: 'gscSync';
payload: undefined;
};
export type CronQueuePayload = export type CronQueuePayload =
| CronQueuePayloadSalt | CronQueuePayloadSalt
| CronQueuePayloadFlushEvents | CronQueuePayloadFlushEvents
@@ -136,7 +144,8 @@ export type CronQueuePayload =
| CronQueuePayloadPing | CronQueuePayloadPing
| CronQueuePayloadProject | CronQueuePayloadProject
| CronQueuePayloadInsightsDaily | CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding; | CronQueuePayloadOnboarding
| CronQueuePayloadGscSync;
export type MiscQueuePayloadTrialEndingSoon = { export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon'; type: 'trialEndingSoon';
@@ -201,9 +210,6 @@ export const sessionsQueue = new Queue<SessionsQueuePayload>(
}, },
} }
); );
export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), {
connection: getRedisQueue(),
});
export const cronQueue = new Queue<CronQueuePayload>(getQueueName('cron'), { export const cronQueue = new Queue<CronQueuePayload>(getQueueName('cron'), {
connection: getRedisQueue(), connection: getRedisQueue(),
@@ -268,3 +274,21 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
}, },
} }
); );
export type GscQueuePayloadSync = {
type: 'gscProjectSync';
payload: { projectId: string };
};
export type GscQueuePayloadBackfill = {
type: 'gscProjectBackfill';
payload: { projectId: string };
};
export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill;
export const gscQueue = new Queue<GscQueuePayload>(getQueueName('gsc'), {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 50,
removeOnFail: 100,
},
});

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