feat: add telemetry overview
This commit is contained in:
parent
0bd98adf96
commit
5bad815e62
@ -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 {
|
156
src/client/components/telemetry/TelemetryOverview.tsx
Normal file
156
src/client/components/telemetry/TelemetryOverview.tsx
Normal 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';
|
@ -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();
|
||||
|
30
src/client/pages/Telemetry/Detail.tsx
Normal file
30
src/client/pages/Telemetry/Detail.tsx
Normal 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';
|
@ -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';
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
@ -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"
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user