feat: add telemetry metrics table

This commit is contained in:
moonrailgun 2024-03-04 00:48:51 +08:00
parent 1306187f01
commit 38dd60feee
10 changed files with 154 additions and 34 deletions

View File

@ -0,0 +1,82 @@
import { Table } from 'antd';
import { ColumnsType } from 'antd/es/table/interface';
import React from 'react';
import { AppRouterOutput, trpc } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user';
import { sum } from 'lodash-es';
import { formatNumber } from '../../utils/common';
import { useTranslation } from '@i18next-toolkit/react';
type MetricsItemType = AppRouterOutput['telemetry']['metrics'][number];
interface MetricsTableProps {
telemetryId: string;
title: [string, string];
type: 'source' | 'url' | 'referrer' | 'country';
startAt: number;
endAt: number;
}
export const TelemetryMetricsTable: React.FC<MetricsTableProps> = React.memo(
(props) => {
const { telemetryId, title, type, startAt, endAt } = props;
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { isLoading, data: metrics = [] } = trpc.telemetry.metrics.useQuery({
workspaceId,
telemetryId,
type,
startAt,
endAt,
});
const total = sum(metrics.map((m) => m.y));
const columns: ColumnsType<MetricsItemType> = [
{
title: title[0],
dataIndex: 'x',
ellipsis: true,
render: (val) =>
val ?? <span className="italic opacity-60">{t('(None)')}</span>,
},
{
title: title[1],
dataIndex: 'y',
width: 100,
align: 'center',
render: (val) => {
const percent = (Number(val) / total) * 100;
return (
<div className="flex">
<div className="w-12 text-right">{formatNumber(val)}</div>
<div className="inline-block w-12 relative border-l ml-1 px-1">
<div
className="bg-blue-300 absolute h-full bg-opacity-25 left-0 top-0 pointer-events-none"
style={{ width: `${percent}%` }}
/>
<span>{percent.toFixed(0)}%</span>
</div>
</div>
);
},
},
];
return (
<Table
rowKey="x"
loading={isLoading}
dataSource={metrics}
columns={columns}
pagination={{
pageSize: 10,
hideOnSinglePage: true,
}}
size="small"
/>
);
}
);
TelemetryMetricsTable.displayName = 'TelemetryMetricsTable';

View File

@ -4,10 +4,18 @@ import { useParams } from 'react-router';
import { NotFoundTip } from '../../components/NotFoundTip'; import { NotFoundTip } from '../../components/NotFoundTip';
import { useCurrentWorkspaceId } from '../../store/user'; import { useCurrentWorkspaceId } from '../../store/user';
import { TelemetryOverview } from '../../components/telemetry/TelemetryOverview'; import { TelemetryOverview } from '../../components/telemetry/TelemetryOverview';
import { TelemetryMetricsTable } from '../../components/telemetry/TelemetryMetricsTable';
import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate';
import { useTranslation } from '@i18next-toolkit/react';
export const TelemetryDetailPage: React.FC = React.memo(() => { export const TelemetryDetailPage: React.FC = React.memo(() => {
const { telemetryId } = useParams(); const { telemetryId } = useParams();
const workspaceId = useCurrentWorkspaceId(); const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { startDate, endDate } = useGlobalRangeDate();
const startAt = startDate.valueOf();
const endAt = endDate.valueOf();
if (!telemetryId) { if (!telemetryId) {
return <NotFoundTip />; return <NotFoundTip />;
@ -23,6 +31,26 @@ export const TelemetryDetailPage: React.FC = React.memo(() => {
workspaceId={workspaceId} workspaceId={workspaceId}
/> />
</Card.Grid> </Card.Grid>
<Card.Grid hoverable={false} className="!w-1/2 min-h-[470px]">
<TelemetryMetricsTable
telemetryId={telemetryId}
type="source"
title={[t('Source'), t('Views')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
<Card.Grid hoverable={false} className="!w-1/2 min-h-[470px]">
<TelemetryMetricsTable
telemetryId={telemetryId}
type="country"
title={[t('Countries'), t('Visitors')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
</Card> </Card>
</div> </div>
); );

View File

@ -66,6 +66,7 @@
"k51bac044": "Abbrechen", "k51bac044": "Abbrechen",
"k53ae02a5": "Neu", "k53ae02a5": "Neu",
"k542b527c": "Ereignisse", "k542b527c": "Ereignisse",
"k58267a45": "Source",
"k58f90514": "Bot-Token", "k58f90514": "Bot-Token",
"k593cf342": "Sind Sie sicher, diesen Monitor zu löschen?", "k593cf342": "Sind Sie sicher, diesen Monitor zu löschen?",
"k5a839f71": "Betriebszeit", "k5a839f71": "Betriebszeit",

View File

@ -66,6 +66,7 @@
"k51bac044": "Cancel", "k51bac044": "Cancel",
"k53ae02a5": "New", "k53ae02a5": "New",
"k542b527c": "Events", "k542b527c": "Events",
"k58267a45": "Source",
"k58f90514": "Bot Token", "k58f90514": "Bot Token",
"k593cf342": "Did you sure delete this monitor?", "k593cf342": "Did you sure delete this monitor?",
"k5a839f71": "Uptime", "k5a839f71": "Uptime",

View File

@ -66,6 +66,7 @@
"k51bac044": "Annuler", "k51bac044": "Annuler",
"k53ae02a5": "Nouveau", "k53ae02a5": "Nouveau",
"k542b527c": "Événements", "k542b527c": "Événements",
"k58267a45": "Source",
"k58f90514": "Jeton de bot", "k58f90514": "Jeton de bot",
"k593cf342": "Êtes-vous sûr de vouloir supprimer ce moniteur ?", "k593cf342": "Êtes-vous sûr de vouloir supprimer ce moniteur ?",
"k5a839f71": "Disponibilité", "k5a839f71": "Disponibilité",

View File

@ -66,6 +66,7 @@
"k51bac044": "キャンセル", "k51bac044": "キャンセル",
"k53ae02a5": "新規", "k53ae02a5": "新規",
"k542b527c": "イベント", "k542b527c": "イベント",
"k58267a45": "Source",
"k58f90514": "ボットトークン", "k58f90514": "ボットトークン",
"k593cf342": "このモニターを削除してもよろしいですか?", "k593cf342": "このモニターを削除してもよろしいですか?",
"k5a839f71": "アップタイム", "k5a839f71": "アップタイム",

View File

@ -66,6 +66,7 @@
"k51bac044": "Отмена", "k51bac044": "Отмена",
"k53ae02a5": "Новый", "k53ae02a5": "Новый",
"k542b527c": "События", "k542b527c": "События",
"k58267a45": "Source",
"k58f90514": "Токен бота", "k58f90514": "Токен бота",
"k593cf342": "Вы уверены, что хотите удалить этот монитор?", "k593cf342": "Вы уверены, что хотите удалить этот монитор?",
"k5a839f71": "Время работы", "k5a839f71": "Время работы",

View File

@ -66,6 +66,7 @@
"k51bac044": "取消", "k51bac044": "取消",
"k53ae02a5": "新建", "k53ae02a5": "新建",
"k542b527c": "事件", "k542b527c": "事件",
"k58267a45": "Source",
"k58f90514": "机器人令牌", "k58f90514": "机器人令牌",
"k593cf342": "您确定要删除这个监控器吗?", "k593cf342": "您确定要删除这个监控器吗?",
"k5a839f71": "正常运行时间", "k5a839f71": "正常运行时间",

View File

@ -309,3 +309,28 @@ export async function getTelemetryPageviewMetrics(
limit 100 limit 100
`; `;
} }
export async function getTelemetryUrlMetrics(
telemetryId: string,
filters: BaseQueryFilters
): Promise<{ x: string; y: number }[]> {
const { filterQuery, joinSession, params } = await parseTelemetryFilters(
telemetryId,
{
...filters,
}
);
return prisma.$queryRaw`
select CONCAT("urlOrigin", "urlPath") x, count(*) y
from "TelemetryEvent"
${joinSession}
where "TelemetryEvent"."telemetryId" = ${telemetryId}
and "TelemetryEvent"."createdAt"
between ${params.startDate}::timestamptz and ${params.endDate}::timestamptz
${filterQuery}
group by 1
order by 2 desc
limit 100
`;
}

View File

@ -25,6 +25,7 @@ import {
getTelemetrySession, getTelemetrySession,
getTelemetrySessionMetrics, getTelemetrySessionMetrics,
getTelemetryStats, getTelemetryStats,
getTelemetryUrlMetrics,
} from '../../model/telemetry'; } from '../../model/telemetry';
import { BaseQueryFilters } from '../../utils/prisma'; import { BaseQueryFilters } from '../../utils/prisma';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -199,17 +200,8 @@ export const telemetryRouter = router({
.input( .input(
z z
.object({ .object({
websiteId: z.string(), telemetryId: z.string(),
type: z.enum([ type: z.enum(['source', 'url', 'referrer', 'country']),
'url',
'language',
'referrer',
'browser',
'os',
'device',
'country',
'event',
]),
startAt: z.number(), startAt: z.number(),
endAt: z.number(), endAt: z.number(),
}) })
@ -224,7 +216,7 @@ export const telemetryRouter = router({
) )
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const { websiteId, type, startAt, endAt, url, country, region, city } = const { telemetryId, type, startAt, endAt, url, country, region, city } =
input; input;
const startDate = new Date(startAt); const startDate = new Date(startAt);
@ -245,40 +237,27 @@ export const telemetryRouter = router({
city, city,
}; };
if (type === 'source') {
const data = await getTelemetryUrlMetrics(telemetryId, filters);
return data.map((d) => ({ x: d.x, y: Number(d.y) }));
}
const column = FILTER_COLUMNS[type] || type; const column = FILTER_COLUMNS[type] || type;
if (SESSION_COLUMNS.includes(type)) { if (SESSION_COLUMNS.includes(type)) {
const data = await getTelemetrySessionMetrics( const data = await getTelemetrySessionMetrics(
websiteId, telemetryId,
column, column,
filters 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).map((d) => ({
x: d.x,
y: Number(d.y),
}));
}
return data.map((d) => ({ x: d.x, y: Number(d.y) })); return data.map((d) => ({ x: d.x, y: Number(d.y) }));
} }
if (EVENT_COLUMNS.includes(type)) { if (EVENT_COLUMNS.includes(type)) {
const data = await getTelemetryPageviewMetrics( const data = await getTelemetryPageviewMetrics(
websiteId, telemetryId,
column, column,
filters filters
); );