feat: add metrics api and table
This commit is contained in:
parent
b5708b241a
commit
b8e8681d64
56
src/client/components/website/MetricsTable.tsx
Normal file
56
src/client/components/website/MetricsTable.tsx
Normal 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';
|
12
src/client/components/website/PagesTable.tsx
Normal file
12
src/client/components/website/PagesTable.tsx
Normal 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';
|
@ -2,27 +2,27 @@ import { Button, message } from 'antd';
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Column, ColumnConfig } from '@ant-design/charts';
|
import { Column, ColumnConfig } from '@ant-design/charts';
|
||||||
import { SyncOutlined } from '@ant-design/icons';
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
import { DateFilter } from './DateFilter';
|
import { DateFilter } from '../DateFilter';
|
||||||
import { HealthBar } from './HealthBar';
|
import { HealthBar } from '../HealthBar';
|
||||||
import {
|
import {
|
||||||
StatsItemType,
|
StatsItemType,
|
||||||
useWorkspaceWebsitePageview,
|
useWorkspaceWebsitePageview,
|
||||||
useWorkspaceWebsiteStats,
|
useWorkspaceWebsiteStats,
|
||||||
WebsiteInfo,
|
WebsiteInfo,
|
||||||
} from '../api/model/website';
|
} from '../../api/model/website';
|
||||||
import { Loading } from './Loading';
|
import { Loading } from '../Loading';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
DateUnit,
|
DateUnit,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateWithUnit,
|
formatDateWithUnit,
|
||||||
getDateArray,
|
getDateArray,
|
||||||
} from '../utils/date';
|
} from '../../utils/date';
|
||||||
import { useEvent } from '../hooks/useEvent';
|
import { useEvent } from '../../hooks/useEvent';
|
||||||
import { MetricCard } from './MetricCard';
|
import { MetricCard } from '../MetricCard';
|
||||||
import { formatNumber, formatShortTime } from '../utils/common';
|
import { formatNumber, formatShortTime } from '../../utils/common';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { WebsiteOnlineCount } from './WebsiteOnlineCount';
|
import { WebsiteOnlineCount } from '../WebsiteOnlineCount';
|
||||||
|
|
||||||
export const WebsiteOverview: React.FC<{
|
export const WebsiteOverview: React.FC<{
|
||||||
website: WebsiteInfo;
|
website: WebsiteInfo;
|
@ -1,7 +1,7 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { ArrowRightOutlined, EditOutlined } from '@ant-design/icons';
|
import { ArrowRightOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import { Button, Divider } from 'antd';
|
import { Button, Divider } from 'antd';
|
||||||
import { WebsiteOverview } from '../components/WebsiteOverview';
|
import { WebsiteOverview } from '../components/website/WebsiteOverview';
|
||||||
import { useCurrentWorkspaceId } from '../store/user';
|
import { useCurrentWorkspaceId } from '../store/user';
|
||||||
import { Loading } from '../components/Loading';
|
import { Loading } from '../components/Loading';
|
||||||
import { useWorspaceWebsites } from '../api/model/website';
|
import { useWorspaceWebsites } from '../api/model/website';
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { Divider } from 'antd';
|
import { Divider } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { trpc } from '../../api/trpc';
|
import { trpc } from '../../api/trpc';
|
||||||
import { ErrorTip } from '../../components/ErrorTip';
|
import { ErrorTip } from '../../components/ErrorTip';
|
||||||
import { Loading } from '../../components/Loading';
|
import { Loading } from '../../components/Loading';
|
||||||
import { NotFoundTip } from '../../components/NotFoundTip';
|
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';
|
import { useCurrentWorkspaceId } from '../../store/user';
|
||||||
|
|
||||||
export const WebsiteDetail: React.FC = React.memo(() => {
|
export const WebsiteDetail: React.FC = React.memo(() => {
|
||||||
@ -37,7 +39,13 @@ export const WebsiteDetail: React.FC = React.memo(() => {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<div className="flex">
|
<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" />
|
<Divider type="vertical" />
|
||||||
<div className="flex-1">right</div>
|
<div className="flex-1">right</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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 { flattenJSON, hashUuid, isCuid, parseToken } from '../utils/common';
|
||||||
import { prisma } from './_client';
|
import { prisma } from './_client';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
@ -7,10 +7,12 @@ import {
|
|||||||
DATA_TYPE,
|
DATA_TYPE,
|
||||||
EVENT_NAME_LENGTH,
|
EVENT_NAME_LENGTH,
|
||||||
EVENT_TYPE,
|
EVENT_TYPE,
|
||||||
|
SESSION_COLUMNS,
|
||||||
URL_LENGTH,
|
URL_LENGTH,
|
||||||
} from '../utils/const';
|
} from '../utils/const';
|
||||||
import type { DynamicData } from '../utils/types';
|
import type { DynamicData } from '../utils/types';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { QueryFilters, parseFilters } from '../utils/prisma';
|
||||||
|
|
||||||
export interface WebsiteEventPayload {
|
export interface WebsiteEventPayload {
|
||||||
data?: object;
|
data?: object;
|
||||||
@ -272,3 +274,76 @@ export async function getWebsiteOnlineUserCount(
|
|||||||
|
|
||||||
return res?.[0].x ?? 0;
|
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
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
@ -2,6 +2,13 @@ import { router, workspaceProcedure } from '../trpc';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getWebsiteOnlineUserCount } from '../../model/website';
|
import { getWebsiteOnlineUserCount } from '../../model/website';
|
||||||
import { prisma } from '../../model/_client';
|
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({
|
export const websiteRouter = router({
|
||||||
onlineCount: workspaceProcedure
|
onlineCount: workspaceProcedure
|
||||||
@ -24,7 +31,7 @@ export const websiteRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { websiteId, workspaceId } = input;
|
const { websiteId } = input;
|
||||||
|
|
||||||
const website = await prisma.website.findUnique({
|
const website = await prisma.website.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -34,4 +41,106 @@ export const websiteRouter = router({
|
|||||||
|
|
||||||
return website;
|
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 [];
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -170,7 +170,7 @@ export async function parseDateRange({
|
|||||||
websiteId: string;
|
websiteId: string;
|
||||||
startAt: number;
|
startAt: number;
|
||||||
endAt: number;
|
endAt: number;
|
||||||
unit: string;
|
unit?: string;
|
||||||
}) {
|
}) {
|
||||||
// All-time
|
// All-time
|
||||||
if (+startAt === 0 && +endAt === 1) {
|
if (+startAt === 0 && +endAt === 1) {
|
||||||
|
@ -67,6 +67,8 @@ export const DATA_TYPE = {
|
|||||||
array: 5,
|
array: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event'];
|
||||||
|
|
||||||
export const SESSION_COLUMNS = [
|
export const SESSION_COLUMNS = [
|
||||||
'browser',
|
'browser',
|
||||||
'os',
|
'os',
|
||||||
|
Loading…
Reference in New Issue
Block a user