Compare commits
14 Commits
feature/gs
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a76b968ba | ||
|
|
9c3c1458bb | ||
|
|
a672b73947 | ||
|
|
bc08566cd4 | ||
|
|
bf39804767 | ||
|
|
664f1abe0a | ||
|
|
8afcf55154 | ||
|
|
4736f8509d | ||
|
|
05cf6bb39f | ||
|
|
6e1daf2c76 | ||
|
|
f2aa0273e6 | ||
|
|
1b898660ad | ||
|
|
9836f75e17 | ||
|
|
271d189ed0 |
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|||||||
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal 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());
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|||||||
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
12
apps/api/src/routes/gsc-callback.router.ts
Normal 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;
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
68
apps/public/src/app/(home)/_sections/feature-spotlight.tsx
Normal file
68
apps/public/src/app/(home)/_sections/feature-spotlight.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
37
apps/public/src/components/eu-flag.tsx
Normal file
37
apps/public/src/components/eu-flag.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
apps/public/src/components/illustrations/conversions.tsx
Normal file
71
apps/public/src/components/illustrations/conversions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/public/src/components/illustrations/notifications.tsx
Normal file
47
apps/public/src/components/illustrations/notifications.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/public/src/components/illustrations/retention.tsx
Normal file
63
apps/public/src/components/illustrations/retention.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/public/src/components/illustrations/revenue.tsx
Normal file
72
apps/public/src/components/illustrations/revenue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/public/src/components/illustrations/session-replay.tsx
Normal file
89
apps/public/src/components/illustrations/session-replay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -109,6 +87,5 @@ export function EventListItem(props: EventListItemProps) {
|
|||||||
</Tooltiper>
|
</Tooltiper>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +146,8 @@ 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({
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
255
apps/start/src/components/page/gsc-cannibalization.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
180
apps/start/src/components/page/page-views-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
apps/start/src/components/page/pages-insights.tsx
Normal file
360
apps/start/src/components/page/pages-insights.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/start/src/components/pages/page-sparkline.tsx
Normal file
122
apps/start/src/components/pages/page-sparkline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/start/src/components/pages/table/columns.tsx
Normal file
206
apps/start/src/components/pages/table/columns.tsx
Normal 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]);
|
||||||
|
}
|
||||||
143
apps/start/src/components/pages/table/index.tsx
Normal file
143
apps/start/src/components/pages/table/index.tsx
Normal 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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`
|
||||||
|
|||||||
440
apps/start/src/modals/gsc-details.tsx
Normal file
440
apps/start/src/modals/gsc-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
49
apps/start/src/modals/page-details.tsx
Normal file
49
apps/start/src/modals/page-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -42,5 +42,5 @@ function Component() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return <EventsTable query={query} />;
|
return <EventsTable query={query} showEventListener />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function generateNewSalt() {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
getSalts.clear();
|
await getSalts.clear();
|
||||||
|
|
||||||
return newSalt;
|
return newSalt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
146
apps/worker/src/jobs/gsc.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ?? '',
|
|
||||||
);
|
|
||||||
@@ -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 ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
85
packages/db/code-migrations/12-add-gsc.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
44
packages/db/src/encryption.ts
Normal file
44
packages/db/src/encryption.ts
Normal 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
576
packages/db/src/gsc.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
60 * 24
|
60 * 24,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getIntegration(integrationId: string | null) {
|
function getIntegration(integrationId: string | null) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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());
|
|
||||||
@@ -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
Reference in New Issue
Block a user