feat: add telemetry overview

This commit is contained in:
moonrailgun 2024-02-28 00:34:02 +08:00
parent 0bd98adf96
commit 5bad815e62
8 changed files with 323 additions and 14 deletions

View File

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

View File

@ -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 (
<Spin spinning={loading}>
<div className="flex">
<div className="flex flex-1 text-2xl font-bold items-center">
<span className="mr-2">{info?.name}</span>
</div>
<div>{actions}</div>
</div>
<div className="flex mb-10 flex-wrap justify-between">
<div className="flex-1">{stats && <MetricsBar stats={stats} />}</div>
<div className="flex items-center gap-2 justify-end flex-wrap w-full lg:w-1/3">
<div className="mr-2">
<Switch
checked={showPreviousPeriod}
onChange={(checked) =>
useGlobalStateStore.setState({
showPreviousPeriod: checked,
})
}
/>
<span className="ml-1">{t('Previous period')}</span>
</div>
<Button
size="large"
icon={<SyncOutlined />}
onClick={handleRefresh}
/>
{showDateFilter && (
<div>
<DateFilter />
</div>
)}
</div>
</div>
<div>
<TimeEventChart data={chartData} unit={unit} />
</div>
</Spin>
);
});
TelemetryOverview.displayName = 'TelemetryOverview';
const MetricsBar: React.FC<{
stats: AppRouterOutput['telemetry']['stats'];
}> = React.memo((props) => {
const { t } = useTranslation();
const { pageviews, uniques } = props.stats || {};
return (
<div className="flex gap-5 flex-wrap w-full">
<MetricCard
label={t('views')}
value={pageviews.value}
prev={pageviews.prev}
change={pageviews.value - pageviews.prev}
/>
<MetricCard
label={t('visitors')}
value={uniques.value}
prev={uniques.prev}
change={uniques.value - uniques.prev}
/>
</div>
);
});
MetricsBar.displayName = 'MetricsBar';

View File

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

View File

@ -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 <NotFoundTip />;
}
return (
<div className="py-6">
<Card>
<Card.Grid hoverable={false} className="!w-full">
<TelemetryOverview
telemetryId={telemetryId}
showDateFilter={true}
workspaceId={workspaceId}
/>
</Card.Grid>
</Card>
</div>
);
});
TelemetryDetailPage.displayName = 'TelemetryDetailPage';

View File

@ -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 <TelemetryList />;
return (
<Routes>
<Route path="/" element={<TelemetryList />} />
<Route path="/:telemetryId" element={<TelemetryDetailPage />} />
</Routes>
);
});
TelemetryPage.displayName = 'TelemetryPage';

View File

@ -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,
})
);

View File

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

View File

@ -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<string, { value: number; prev: number }>);
return baseStatsSchema.parse(stats);
}),
});
function buildTelemetryOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {