From 5bad815e62352fa2ee5835233e98d3f1370890b8 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Wed, 28 Feb 2024 00:34:02 +0800 Subject: [PATCH] feat: add telemetry overview --- .../components/{website => }/MetricCard.tsx | 4 +- .../telemetry/TelemetryOverview.tsx | 156 ++++++++++++++++++ .../components/website/WebsiteOverview.tsx | 4 +- src/client/pages/Telemetry/Detail.tsx | 30 ++++ src/client/pages/Telemetry/index.tsx | 9 +- src/server/model/_schema/filter.ts | 19 ++- src/server/model/telemetry.ts | 3 +- src/server/trpc/routers/telemetry.ts | 112 ++++++++++++- 8 files changed, 323 insertions(+), 14 deletions(-) rename src/client/components/{website => }/MetricCard.tsx (93%) create mode 100644 src/client/components/telemetry/TelemetryOverview.tsx create mode 100644 src/client/pages/Telemetry/Detail.tsx diff --git a/src/client/components/website/MetricCard.tsx b/src/client/components/MetricCard.tsx similarity index 93% rename from src/client/components/website/MetricCard.tsx rename to src/client/components/MetricCard.tsx index f55eb96..10acf4b 100644 --- a/src/client/components/website/MetricCard.tsx +++ b/src/client/components/MetricCard.tsx @@ -1,7 +1,7 @@ import { Tag } from 'antd'; import React from 'react'; -import { formatNumber } from '../../utils/common'; -import { useGlobalStateStore } from '../../store/global'; +import { formatNumber } from '../utils/common'; +import { useGlobalStateStore } from '../store/global'; import { useTranslation } from '@i18next-toolkit/react'; interface MetricCardProps { diff --git a/src/client/components/telemetry/TelemetryOverview.tsx b/src/client/components/telemetry/TelemetryOverview.tsx new file mode 100644 index 0000000..c95a94e --- /dev/null +++ b/src/client/components/telemetry/TelemetryOverview.tsx @@ -0,0 +1,156 @@ +import { Button, message, Spin, Switch } from 'antd'; +import React from 'react'; +import { SyncOutlined } from '@ant-design/icons'; +import { DateFilter } from '../DateFilter'; +import { getDateArray } from '../../utils/date'; +import { useEvent } from '../../hooks/useEvent'; +import { MetricCard } from '../MetricCard'; +import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate'; +import { AppRouterOutput, trpc } from '../../api/trpc'; +import { getUserTimezone } from '../../api/model/user'; +import { useGlobalStateStore } from '../../store/global'; +import { useTranslation } from '@i18next-toolkit/react'; +import { TimeEventChart } from '../TimeEventChart'; + +export const TelemetryOverview: React.FC<{ + workspaceId: string; + telemetryId: string; + showDateFilter?: boolean; + actions?: React.ReactNode; +}> = React.memo((props) => { + const { t } = useTranslation(); + const { workspaceId, telemetryId, showDateFilter = false, actions } = props; + const { startDate, endDate, unit, refresh } = useGlobalRangeDate(); + const showPreviousPeriod = useGlobalStateStore( + (state) => state.showPreviousPeriod + ); + + const { data: info } = trpc.telemetry.info.useQuery({ + workspaceId, + telemetryId, + }); + + const { + data: chartData = [], + isLoading: isLoadingPageview, + refetch: refetchPageview, + } = trpc.telemetry.pageviews.useQuery( + { + workspaceId, + telemetryId, + startAt: startDate.valueOf(), + endAt: endDate.valueOf(), + unit, + timezone: getUserTimezone(), + }, + { + select(data) { + const pageviews = data.pageviews ?? []; + const sessions = data.sessions ?? []; + + const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit); + const sessionsArr = getDateArray(sessions, startDate, endDate, unit); + + return [ + ...pageviewsArr.map((item) => ({ ...item, type: 'pageview' })), + ...sessionsArr.map((item) => ({ ...item, type: 'session' })), + ]; + }, + } + ); + + const { + data: stats, + isLoading: isLoadingStats, + refetch: refetchStats, + } = trpc.telemetry.stats.useQuery({ + workspaceId, + telemetryId, + startAt: startDate.unix() * 1000, + endAt: endDate.unix() * 1000, + timezone: getUserTimezone(), + unit, + }); + + const handleRefresh = useEvent(async () => { + refresh(); + + await Promise.all([refetchPageview(), refetchStats()]); + + message.success(t('Refreshed')); + }); + + const loading = isLoadingPageview || isLoadingStats; + + return ( + +
+
+ {info?.name} +
+ +
{actions}
+
+ +
+
{stats && }
+ +
+
+ + useGlobalStateStore.setState({ + showPreviousPeriod: checked, + }) + } + /> + {t('Previous period')} +
+ +
+
+ +
+ +
+
+ ); +}); +TelemetryOverview.displayName = 'TelemetryOverview'; + +const MetricsBar: React.FC<{ + stats: AppRouterOutput['telemetry']['stats']; +}> = React.memo((props) => { + const { t } = useTranslation(); + const { pageviews, uniques } = props.stats || {}; + + return ( +
+ + +
+ ); +}); +MetricsBar.displayName = 'MetricsBar'; diff --git a/src/client/components/website/WebsiteOverview.tsx b/src/client/components/website/WebsiteOverview.tsx index 09e696e..8ae3678 100644 --- a/src/client/components/website/WebsiteOverview.tsx +++ b/src/client/components/website/WebsiteOverview.tsx @@ -5,7 +5,7 @@ import { DateFilter } from '../DateFilter'; import { WebsiteInfo } from '../../api/model/website'; import { getDateArray } from '../../utils/date'; import { useEvent } from '../../hooks/useEvent'; -import { MetricCard } from './MetricCard'; +import { MetricCard } from '../MetricCard'; import { formatNumber, formatShortTime } from '../../utils/common'; import { WebsiteOnlineCount } from './WebsiteOnlineCount'; import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate'; @@ -151,7 +151,7 @@ export const WebsiteOverview: React.FC<{ }); WebsiteOverview.displayName = 'WebsiteOverview'; -export const MetricsBar: React.FC<{ +const MetricsBar: React.FC<{ stats: AppRouterOutput['website']['stats']; }> = React.memo((props) => { const { t } = useTranslation(); diff --git a/src/client/pages/Telemetry/Detail.tsx b/src/client/pages/Telemetry/Detail.tsx new file mode 100644 index 0000000..5ef6b4e --- /dev/null +++ b/src/client/pages/Telemetry/Detail.tsx @@ -0,0 +1,30 @@ +import { Card } from 'antd'; +import React from 'react'; +import { useParams } from 'react-router'; +import { NotFoundTip } from '../../components/NotFoundTip'; +import { useCurrentWorkspaceId } from '../../store/user'; +import { TelemetryOverview } from '../../components/telemetry/TelemetryOverview'; + +export const TelemetryDetailPage: React.FC = React.memo(() => { + const { telemetryId } = useParams(); + const workspaceId = useCurrentWorkspaceId(); + + if (!telemetryId) { + return ; + } + + return ( +
+ + + + + +
+ ); +}); +TelemetryDetailPage.displayName = 'TelemetryDetailPage'; diff --git a/src/client/pages/Telemetry/index.tsx b/src/client/pages/Telemetry/index.tsx index 76b7c71..5f7c2f9 100644 --- a/src/client/pages/Telemetry/index.tsx +++ b/src/client/pages/Telemetry/index.tsx @@ -1,7 +1,14 @@ import React from 'react'; import { TelemetryList } from '../../components/telemetry/TelemetryList'; +import { Route, Routes } from 'react-router-dom'; +import { TelemetryDetailPage } from './Detail'; export const TelemetryPage: React.FC = React.memo(() => { - return ; + return ( + + } /> + } /> + + ); }); TelemetryPage.displayName = 'TelemetryPage'; diff --git a/src/server/model/_schema/filter.ts b/src/server/model/_schema/filter.ts index 2cce966..c44c7f4 100644 --- a/src/server/model/_schema/filter.ts +++ b/src/server/model/_schema/filter.ts @@ -5,11 +5,11 @@ export const baseFilterSchema = z.object({ country: z.string(), region: z.string(), city: z.string(), + timezone: z.string(), }); export const websiteFilterSchema = baseFilterSchema.merge( z.object({ - timezone: z.string(), referrer: z.string(), title: z.string(), os: z.string(), @@ -18,14 +18,19 @@ export const websiteFilterSchema = baseFilterSchema.merge( }) ); -const websiteStatsItemType = z.object({ +export const statsItemType = z.object({ value: z.number(), prev: z.number(), }); -export const websiteStatsSchema = z.object({ - bounces: websiteStatsItemType, - pageviews: websiteStatsItemType, - totaltime: websiteStatsItemType, - uniques: websiteStatsItemType, +export const baseStatsSchema = z.object({ + pageviews: statsItemType, + uniques: statsItemType, }); + +export const websiteStatsSchema = baseStatsSchema.merge( + z.object({ + totaltime: statsItemType, + bounces: statsItemType, + }) +); diff --git a/src/server/model/telemetry.ts b/src/server/model/telemetry.ts index 091ff5c..df1c1bc 100644 --- a/src/server/model/telemetry.ts +++ b/src/server/model/telemetry.ts @@ -231,7 +231,8 @@ export async function getTelemetryStats( from ( select "TelemetryEvent"."sessionId", - ${getDateQuery('"TelemetryEvent"."createdAt"', 'hour')} + ${getDateQuery('"TelemetryEvent"."createdAt"', 'hour')}, + count(*) as c from "TelemetryEvent" join "Telemetry" on "TelemetryEvent"."telemetryId" = "Telemetry"."id" diff --git a/src/server/trpc/routers/telemetry.ts b/src/server/trpc/routers/telemetry.ts index ed4e16d..dc05e44 100644 --- a/src/server/trpc/routers/telemetry.ts +++ b/src/server/trpc/routers/telemetry.ts @@ -14,14 +14,20 @@ import { import { prisma } from '../../model/_client'; import { TelemetryModelSchema } from '../../prisma/zod'; import { OpenApiMeta } from 'trpc-openapi'; -import { baseFilterSchema } from '../../model/_schema/filter'; +import { + baseFilterSchema, + baseStatsSchema, + statsItemType, +} from '../../model/_schema/filter'; import { getTelemetryPageview, getTelemetryPageviewMetrics, getTelemetrySession, getTelemetrySessionMetrics, + getTelemetryStats, } from '../../model/telemetry'; import { BaseQueryFilters } from '../../utils/prisma'; +import dayjs from 'dayjs'; export const telemetryRouter = router({ all: workspaceProcedure @@ -44,6 +50,31 @@ export const telemetryRouter = router({ }, }); + return res; + }), + info: workspaceProcedure + .meta( + buildTelemetryOpenapi({ + method: 'GET', + path: '/info', + }) + ) + .input( + z.object({ + telemetryId: z.string(), + }) + ) + .output(TelemetryModelSchema.nullable()) + .query(async ({ input }) => { + const { workspaceId, telemetryId } = input; + + const res = await prisma.telemetry.findUnique({ + where: { + workspaceId, + id: telemetryId, + }, + }); + return res; }), eventCount: workspaceProcedure @@ -257,6 +288,85 @@ export const telemetryRouter = router({ return []; }), + stats: workspaceProcedure + .meta( + buildTelemetryOpenapi({ + method: 'GET', + path: '/stats', + }) + ) + .input( + z + .object({ + telemetryId: z.string(), + startAt: z.number(), + endAt: z.number(), + unit: z.string().optional(), + }) + .merge(baseFilterSchema.partial()) + ) + .output(baseStatsSchema) + .query(async ({ input }) => { + const { + telemetryId, + timezone, + url, + country, + region, + city, + startAt, + endAt, + } = input; + + const startDate = new Date(startAt); + const endDate = new Date(endAt); + // const { startDate, endDate, unit } = await parseDateRange({ + // telemetryId, + // startAt: Number(startAt), + // endAt: Number(endAt), + // unit: input.unit, + // }); + + const diff = dayjs(endDate).diff(startDate, 'minutes'); + const prevStartDate = dayjs(startDate).subtract(diff, 'minutes').toDate(); + const prevEndDate = dayjs(endDate).subtract(diff, 'minutes').toDate(); + + const filters = { + startDate, + endDate, + timezone, + unit: input.unit, + url, + country, + region, + city, + } as BaseQueryFilters; + + const [metrics, prevPeriod] = await Promise.all([ + getTelemetryStats(telemetryId, { + ...filters, + startDate, + endDate, + }), + getTelemetryStats(telemetryId, { + ...filters, + startDate: prevStartDate, + endDate: prevEndDate, + }), + ]); + + const stats = Object.keys(metrics[0]).reduce((obj, key) => { + const current = Number(metrics[0][key]) || 0; + const prev = Number(prevPeriod[0][key]) || 0; + obj[key] = { + value: current, + prev, + }; + return obj; + }, {} as Record); + + return baseStatsSchema.parse(stats); + }), }); function buildTelemetryOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {