diff --git a/src/client/components/website/MetricsTable.tsx b/src/client/components/website/MetricsTable.tsx new file mode 100644 index 0000000..70c063c --- /dev/null +++ b/src/client/components/website/MetricsTable.tsx @@ -0,0 +1,56 @@ +import { Table } from 'antd'; +import { ColumnsType } from 'antd/es/table/interface'; +import React from 'react'; +import { trpc } from '../../api/trpc'; +import { useCurrentWorkspaceId } from '../../store/user'; + +interface MetricsTableProps { + websiteId: string; + title: [string, string]; + type: + | 'url' + | 'language' + | 'referrer' + | 'browser' + | 'os' + | 'device' + | 'country' + | 'event'; + startAt: number; + endAt: number; +} +export const MetricsTable: React.FC = React.memo((props) => { + const workspaceId = useCurrentWorkspaceId()!; + const { websiteId, title, type, startAt, endAt } = props; + + const { isLoading, data: metrics = [] } = trpc.website.metrics.useQuery({ + workspaceId, + websiteId, + type, + startAt, + endAt, + }); + + const columns: ColumnsType<{ x: string; y: number }> = [ + { + title: title[0], + dataIndex: 'x', + }, + { + title: title[1], + dataIndex: 'y', + width: 100, + }, + ]; + + return ( + + ); +}); +MetricsTable.displayName = 'MetricsTable'; diff --git a/src/client/components/website/PagesTable.tsx b/src/client/components/website/PagesTable.tsx new file mode 100644 index 0000000..5f48a16 --- /dev/null +++ b/src/client/components/website/PagesTable.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { MetricsTable } from './MetricsTable'; + +interface PagesTableProps { + websiteId: string; + startAt: number; + endAt: number; +} +export const PagesTable: React.FC = React.memo((props) => { + return ; +}); +PagesTable.displayName = 'PagesTable'; diff --git a/src/client/components/WebsiteOverview.tsx b/src/client/components/website/WebsiteOverview.tsx similarity index 92% rename from src/client/components/WebsiteOverview.tsx rename to src/client/components/website/WebsiteOverview.tsx index e3f83be..4b9da1a 100644 --- a/src/client/components/WebsiteOverview.tsx +++ b/src/client/components/website/WebsiteOverview.tsx @@ -2,27 +2,27 @@ import { Button, message } from 'antd'; import React, { useMemo } from 'react'; import { Column, ColumnConfig } from '@ant-design/charts'; import { SyncOutlined } from '@ant-design/icons'; -import { DateFilter } from './DateFilter'; -import { HealthBar } from './HealthBar'; +import { DateFilter } from '../DateFilter'; +import { HealthBar } from '../HealthBar'; import { StatsItemType, useWorkspaceWebsitePageview, useWorkspaceWebsiteStats, WebsiteInfo, -} from '../api/model/website'; -import { Loading } from './Loading'; +} from '../../api/model/website'; +import { Loading } from '../Loading'; import dayjs from 'dayjs'; import { DateUnit, formatDate, formatDateWithUnit, getDateArray, -} from '../utils/date'; -import { useEvent } from '../hooks/useEvent'; -import { MetricCard } from './MetricCard'; -import { formatNumber, formatShortTime } from '../utils/common'; -import { useTheme } from '../hooks/useTheme'; -import { WebsiteOnlineCount } from './WebsiteOnlineCount'; +} from '../../utils/date'; +import { useEvent } from '../../hooks/useEvent'; +import { MetricCard } from '../MetricCard'; +import { formatNumber, formatShortTime } from '../../utils/common'; +import { useTheme } from '../../hooks/useTheme'; +import { WebsiteOnlineCount } from '../WebsiteOnlineCount'; export const WebsiteOverview: React.FC<{ website: WebsiteInfo; diff --git a/src/client/pages/Dashboard.tsx b/src/client/pages/Dashboard.tsx index 92ddb76..ccb79f4 100644 --- a/src/client/pages/Dashboard.tsx +++ b/src/client/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import { ArrowRightOutlined, EditOutlined } from '@ant-design/icons'; import { Button, Divider } from 'antd'; -import { WebsiteOverview } from '../components/WebsiteOverview'; +import { WebsiteOverview } from '../components/website/WebsiteOverview'; import { useCurrentWorkspaceId } from '../store/user'; import { Loading } from '../components/Loading'; import { useWorspaceWebsites } from '../api/model/website'; diff --git a/src/client/pages/Website/Detail.tsx b/src/client/pages/Website/Detail.tsx index 1356172..f292b7d 100644 --- a/src/client/pages/Website/Detail.tsx +++ b/src/client/pages/Website/Detail.tsx @@ -1,11 +1,13 @@ import { Divider } from 'antd'; +import dayjs from 'dayjs'; import React from 'react'; import { useParams } from 'react-router'; import { trpc } from '../../api/trpc'; import { ErrorTip } from '../../components/ErrorTip'; import { Loading } from '../../components/Loading'; import { NotFoundTip } from '../../components/NotFoundTip'; -import { WebsiteOverview } from '../../components/WebsiteOverview'; +import { PagesTable } from '../../components/website/PagesTable'; +import { WebsiteOverview } from '../../components/website/WebsiteOverview'; import { useCurrentWorkspaceId } from '../../store/user'; export const WebsiteDetail: React.FC = React.memo(() => { @@ -37,7 +39,13 @@ export const WebsiteDetail: React.FC = React.memo(() => {
-
left
+
+ +
right
diff --git a/src/server/model/website.ts b/src/server/model/website.ts index 14f9d66..29b9699 100644 --- a/src/server/model/website.ts +++ b/src/server/model/website.ts @@ -1,4 +1,4 @@ -import { Website, WebsiteSession } from '@prisma/client'; +import { Prisma, Website, WebsiteSession } from '@prisma/client'; import { flattenJSON, hashUuid, isCuid, parseToken } from '../utils/common'; import { prisma } from './_client'; import { Request } from 'express'; @@ -7,10 +7,12 @@ import { DATA_TYPE, EVENT_NAME_LENGTH, EVENT_TYPE, + SESSION_COLUMNS, URL_LENGTH, } from '../utils/const'; import type { DynamicData } from '../utils/types'; import dayjs from 'dayjs'; +import { QueryFilters, parseFilters } from '../utils/prisma'; export interface WebsiteEventPayload { data?: object; @@ -272,3 +274,76 @@ export async function getWebsiteOnlineUserCount( return res?.[0].x ?? 0; } + +export async function getSessionMetrics( + websiteId: string, + column: string, + filters: QueryFilters +): Promise<{ x: string; y: number }[]> { + const { filterQuery, joinSession, params } = await parseFilters( + websiteId, + { + ...filters, + }, + { + joinSession: SESSION_COLUMNS.includes(column), + } + ); + const includeCountry = column === 'city' || column === 'subdivision1'; + + return prisma.$queryRaw`select + ${column} x, + count(distinct "WebsiteEvent"."sessionId") y + ${includeCountry ? Prisma.sql([', country']) : Prisma.empty} + from "WebsiteEvent" + ${joinSession} + where "WebsiteEvent"."websiteId" = ${websiteId} + and "WebsiteEvent"."createdAt" + between ${params.startDate}::timestamptz and ${ + params.endDate + }::timestamptz + and "WebsiteEvent"."eventType" = ${EVENT_TYPE.pageView} + ${filterQuery} + group by 1 + ${includeCountry ? Prisma.sql([', 3']) : Prisma.empty} + order by 2 desc + limit 100`; +} + +export async function getPageviewMetrics( + websiteId: string, + column: string, + filters: QueryFilters +): Promise<{ x: string; y: number }[]> { + const eventType = + column === 'eventName' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView; + const { filterQuery, joinSession, params } = await parseFilters( + websiteId, + { + ...filters, + }, + { joinSession: SESSION_COLUMNS.includes(column) } + ); + + let excludeDomain = Prisma.empty; + if (column === 'referrerDomain') { + excludeDomain = Prisma.sql`and ("WebsiteEvent"."referrerDomain" != ${params.websiteDomain} or "WebsiteEvent"."referrerDomain" is null)`; + } + + return prisma.$queryRaw` + select ${Prisma.sql([`"${column}"`])} x, count(*) y + from "WebsiteEvent" + ${joinSession} + where "WebsiteEvent"."websiteId" = ${websiteId} + and "WebsiteEvent"."createdAt" + between ${params.startDate}::timestamptz and ${ + params.endDate + }::timestamptz + and "eventType" = ${eventType} + ${excludeDomain} + ${filterQuery} + group by 1 + order by 2 desc + limit 100 + `; +} diff --git a/src/server/trpc/routers/website.ts b/src/server/trpc/routers/website.ts index 34fe64d..1af147a 100644 --- a/src/server/trpc/routers/website.ts +++ b/src/server/trpc/routers/website.ts @@ -2,6 +2,13 @@ import { router, workspaceProcedure } from '../trpc'; import { z } from 'zod'; import { getWebsiteOnlineUserCount } from '../../model/website'; import { prisma } from '../../model/_client'; +import { + EVENT_COLUMNS, + FILTER_COLUMNS, + SESSION_COLUMNS, +} from '../../utils/const'; +import { parseDateRange } from '../../utils/common'; +import { getSessionMetrics, getPageviewMetrics } from '../../model/website'; export const websiteRouter = router({ onlineCount: workspaceProcedure @@ -24,7 +31,7 @@ export const websiteRouter = router({ }) ) .query(async ({ input }) => { - const { websiteId, workspaceId } = input; + const { websiteId } = input; const website = await prisma.website.findUnique({ where: { @@ -34,4 +41,106 @@ export const websiteRouter = router({ return website; }), + metrics: workspaceProcedure + .input( + z.object({ + websiteId: z.string(), + type: z.enum([ + 'url', + 'language', + 'referrer', + 'browser', + 'os', + 'device', + 'country', + 'event', + ]), + startAt: z.number(), + endAt: z.number(), + url: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + os: z.string().optional(), + browser: z.string().optional(), + device: z.string().optional(), + country: z.string().optional(), + region: z.string().optional(), + city: z.string().optional(), + language: z.string().optional(), + event: z.string().optional(), + }) + ) + .query(async ({ input }) => { + const { + websiteId, + type, + startAt, + endAt, + url, + referrer, + title, + os, + browser, + device, + country, + region, + city, + language, + event, + } = input; + + const { startDate, endDate } = await parseDateRange({ + websiteId, + startAt, + endAt, + }); + + const filters = { + startDate, + endDate, + url, + referrer, + title, + os, + browser, + device, + country, + region, + city, + language, + event, + }; + + const column = FILTER_COLUMNS[type] || type; + + if (SESSION_COLUMNS.includes(type)) { + const data = await getSessionMetrics(websiteId, column, filters); + + if (type === 'language') { + const combined: Record = {}; + + for (const { x, y } of data) { + const key = String(x).toLowerCase().split('-')[0]; + + if (combined[key] === undefined) { + combined[key] = { x: key, y }; + } else { + combined[key].y += y; + } + } + + return Object.values(combined); + } + + return data; + } + + if (EVENT_COLUMNS.includes(type)) { + const data = await getPageviewMetrics(websiteId, column, filters); + + return data; + } + + return []; + }), }); diff --git a/src/server/utils/common.ts b/src/server/utils/common.ts index 558b116..382484c 100644 --- a/src/server/utils/common.ts +++ b/src/server/utils/common.ts @@ -170,7 +170,7 @@ export async function parseDateRange({ websiteId: string; startAt: number; endAt: number; - unit: string; + unit?: string; }) { // All-time if (+startAt === 0 && +endAt === 1) { diff --git a/src/server/utils/const.ts b/src/server/utils/const.ts index 34c76d8..b4ab365 100644 --- a/src/server/utils/const.ts +++ b/src/server/utils/const.ts @@ -67,6 +67,8 @@ export const DATA_TYPE = { array: 5, } as const; +export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event']; + export const SESSION_COLUMNS = [ 'browser', 'os',