feat: add metrics api and table

This commit is contained in:
moonrailgun 2023-10-06 22:08:15 +08:00
parent b5708b241a
commit b8e8681d64
9 changed files with 278 additions and 16 deletions

View File

@ -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<MetricsTableProps> = 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 (
<Table
rowKey="x"
loading={isLoading}
dataSource={metrics}
columns={columns}
size="small"
/>
);
});
MetricsTable.displayName = 'MetricsTable';

View File

@ -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<PagesTableProps> = React.memo((props) => {
return <MetricsTable {...props} type="url" title={['pages', 'views']} />;
});
PagesTable.displayName = 'PagesTable';

View File

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

View File

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

View File

@ -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(() => {
<Divider />
<div className="flex">
<div className="flex-1">left</div>
<div className="flex-1">
<PagesTable
websiteId={websiteId}
startAt={dayjs().subtract(1, 'day').unix() * 1000}
endAt={dayjs().unix() * 1000}
/>
</div>
<Divider type="vertical" />
<div className="flex-1">right</div>
</div>

View File

@ -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
`;
}

View File

@ -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<string, any> = {};
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 [];
}),
});

View File

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

View File

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