feat: add telemetry overview
This commit is contained in:
parent
0bd98adf96
commit
5bad815e62
@ -1,7 +1,7 @@
|
|||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { formatNumber } from '../../utils/common';
|
import { formatNumber } from '../utils/common';
|
||||||
import { useGlobalStateStore } from '../../store/global';
|
import { useGlobalStateStore } from '../store/global';
|
||||||
import { useTranslation } from '@i18next-toolkit/react';
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
|
|
||||||
interface MetricCardProps {
|
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 { WebsiteInfo } from '../../api/model/website';
|
||||||
import { getDateArray } from '../../utils/date';
|
import { getDateArray } 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 { WebsiteOnlineCount } from './WebsiteOnlineCount';
|
import { WebsiteOnlineCount } from './WebsiteOnlineCount';
|
||||||
import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate';
|
import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate';
|
||||||
@ -151,7 +151,7 @@ export const WebsiteOverview: React.FC<{
|
|||||||
});
|
});
|
||||||
WebsiteOverview.displayName = 'WebsiteOverview';
|
WebsiteOverview.displayName = 'WebsiteOverview';
|
||||||
|
|
||||||
export const MetricsBar: React.FC<{
|
const MetricsBar: React.FC<{
|
||||||
stats: AppRouterOutput['website']['stats'];
|
stats: AppRouterOutput['website']['stats'];
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
const { t } = useTranslation();
|
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 React from 'react';
|
||||||
import { TelemetryList } from '../../components/telemetry/TelemetryList';
|
import { TelemetryList } from '../../components/telemetry/TelemetryList';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { TelemetryDetailPage } from './Detail';
|
||||||
|
|
||||||
export const TelemetryPage: React.FC = React.memo(() => {
|
export const TelemetryPage: React.FC = React.memo(() => {
|
||||||
return <TelemetryList />;
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<TelemetryList />} />
|
||||||
|
<Route path="/:telemetryId" element={<TelemetryDetailPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
TelemetryPage.displayName = 'TelemetryPage';
|
TelemetryPage.displayName = 'TelemetryPage';
|
||||||
|
@ -5,11 +5,11 @@ export const baseFilterSchema = z.object({
|
|||||||
country: z.string(),
|
country: z.string(),
|
||||||
region: z.string(),
|
region: z.string(),
|
||||||
city: z.string(),
|
city: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const websiteFilterSchema = baseFilterSchema.merge(
|
export const websiteFilterSchema = baseFilterSchema.merge(
|
||||||
z.object({
|
z.object({
|
||||||
timezone: z.string(),
|
|
||||||
referrer: z.string(),
|
referrer: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
os: 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(),
|
value: z.number(),
|
||||||
prev: z.number(),
|
prev: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const websiteStatsSchema = z.object({
|
export const baseStatsSchema = z.object({
|
||||||
bounces: websiteStatsItemType,
|
pageviews: statsItemType,
|
||||||
pageviews: websiteStatsItemType,
|
uniques: statsItemType,
|
||||||
totaltime: websiteStatsItemType,
|
|
||||||
uniques: websiteStatsItemType,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const websiteStatsSchema = baseStatsSchema.merge(
|
||||||
|
z.object({
|
||||||
|
totaltime: statsItemType,
|
||||||
|
bounces: statsItemType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
@ -231,7 +231,8 @@ export async function getTelemetryStats(
|
|||||||
from (
|
from (
|
||||||
select
|
select
|
||||||
"TelemetryEvent"."sessionId",
|
"TelemetryEvent"."sessionId",
|
||||||
${getDateQuery('"TelemetryEvent"."createdAt"', 'hour')}
|
${getDateQuery('"TelemetryEvent"."createdAt"', 'hour')},
|
||||||
|
count(*) as c
|
||||||
from "TelemetryEvent"
|
from "TelemetryEvent"
|
||||||
join "Telemetry"
|
join "Telemetry"
|
||||||
on "TelemetryEvent"."telemetryId" = "Telemetry"."id"
|
on "TelemetryEvent"."telemetryId" = "Telemetry"."id"
|
||||||
|
@ -14,14 +14,20 @@ import {
|
|||||||
import { prisma } from '../../model/_client';
|
import { prisma } from '../../model/_client';
|
||||||
import { TelemetryModelSchema } from '../../prisma/zod';
|
import { TelemetryModelSchema } from '../../prisma/zod';
|
||||||
import { OpenApiMeta } from 'trpc-openapi';
|
import { OpenApiMeta } from 'trpc-openapi';
|
||||||
import { baseFilterSchema } from '../../model/_schema/filter';
|
import {
|
||||||
|
baseFilterSchema,
|
||||||
|
baseStatsSchema,
|
||||||
|
statsItemType,
|
||||||
|
} from '../../model/_schema/filter';
|
||||||
import {
|
import {
|
||||||
getTelemetryPageview,
|
getTelemetryPageview,
|
||||||
getTelemetryPageviewMetrics,
|
getTelemetryPageviewMetrics,
|
||||||
getTelemetrySession,
|
getTelemetrySession,
|
||||||
getTelemetrySessionMetrics,
|
getTelemetrySessionMetrics,
|
||||||
|
getTelemetryStats,
|
||||||
} from '../../model/telemetry';
|
} from '../../model/telemetry';
|
||||||
import { BaseQueryFilters } from '../../utils/prisma';
|
import { BaseQueryFilters } from '../../utils/prisma';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export const telemetryRouter = router({
|
export const telemetryRouter = router({
|
||||||
all: workspaceProcedure
|
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;
|
return res;
|
||||||
}),
|
}),
|
||||||
eventCount: workspaceProcedure
|
eventCount: workspaceProcedure
|
||||||
@ -257,6 +288,85 @@ export const telemetryRouter = router({
|
|||||||
|
|
||||||
return [];
|
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 {
|
function buildTelemetryOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
|
||||||
|
Loading…
Reference in New Issue
Block a user