feat(admin): add analytics page

- activeGroupTop5
- activeUserTop5
- largeGroupTop5
- fileStorageUserTop5
pull/105/merge
moonrailgun 2 years ago
parent 288f5a61e8
commit 39e0b2cee7

@ -11,6 +11,7 @@ import {
IconCompass, IconCompass,
IconDashboard, IconDashboard,
IconEmail, IconEmail,
IconExperiment,
IconFile, IconFile,
IconMessage, IconMessage,
IconNotification, IconNotification,
@ -31,6 +32,7 @@ import {
import { i18n } from './i18n'; import { i18n } from './i18n';
import { GroupList } from './resources/group'; import { GroupList } from './resources/group';
import { UserList } from './resources/user'; import { UserList } from './resources/user';
import { TailchatAnalytics } from './routes/analytics';
import { CacheManager } from './routes/cache'; import { CacheManager } from './routes/cache';
import { TailchatNetwork } from './routes/network'; import { TailchatNetwork } from './routes/network';
import { SocketIOAdmin } from './routes/socketio'; import { SocketIOAdmin } from './routes/socketio';
@ -50,6 +52,10 @@ function App() {
authProvider={authProvider} authProvider={authProvider}
i18n={i18n} i18n={i18n}
> >
<CustomRoute name="analytics" icon={<IconExperiment />}>
<TailchatAnalytics />
</CustomRoute>
<Resource name="users" icon={<IconUser />} list={<UserList />} /> <Resource name="users" icon={<IconUser />} list={<UserList />} />
<Resource <Resource

@ -209,6 +209,8 @@ const DataItem: React.FC<{
DataItem.displayName = 'DataItem'; DataItem.displayName = 'DataItem';
const UserCountChart: React.FC = React.memo(() => { const UserCountChart: React.FC = React.memo(() => {
const id = 'userCount';
const color = '#82ca9d';
const { t } = useTranslation(); const { t } = useTranslation();
const { value: newUserCountSummary } = useAsync(async () => { const { value: newUserCountSummary } = useAsync(async () => {
const { data } = await request.get<{ const { data } = await request.get<{
@ -230,9 +232,9 @@ const UserCountChart: React.FC = React.memo(() => {
margin={{ top: 10, right: 30, left: 0, bottom: 0 }} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
> >
<defs> <defs>
<linearGradient id="userCountColor" x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`${id}Color`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} /> <stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} /> <stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="date" /> <XAxis dataKey="date" />
@ -243,9 +245,9 @@ const UserCountChart: React.FC = React.memo(() => {
type="monotone" type="monotone"
dataKey="count" dataKey="count"
label={t('custom.dashboard.newUserCount')} label={t('custom.dashboard.newUserCount')}
stroke="#82ca9d" stroke={color}
fillOpacity={1} fillOpacity={1}
fill="url(#userCountColor)" fill={`url(#${id}Color)`}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -254,6 +256,8 @@ const UserCountChart: React.FC = React.memo(() => {
UserCountChart.displayName = 'UserCountChart'; UserCountChart.displayName = 'UserCountChart';
const MessageCountChart: React.FC = React.memo(() => { const MessageCountChart: React.FC = React.memo(() => {
const id = 'messageCount';
const color = '#8884d8';
const { t } = useTranslation(); const { t } = useTranslation();
const { value: messageCountSummary } = useAsync(async () => { const { value: messageCountSummary } = useAsync(async () => {
const { data } = await request.get<{ const { data } = await request.get<{
@ -275,9 +279,9 @@ const MessageCountChart: React.FC = React.memo(() => {
margin={{ top: 10, right: 30, left: 0, bottom: 0 }} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
> >
<defs> <defs>
<linearGradient id="messageCountColor" x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`${id}Color`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8} /> <stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} /> <stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="date" /> <XAxis dataKey="date" />
@ -290,7 +294,7 @@ const MessageCountChart: React.FC = React.memo(() => {
label={t('custom.dashboard.messageCount')} label={t('custom.dashboard.messageCount')}
stroke="#8884d8" stroke="#8884d8"
fillOpacity={1} fillOpacity={1}
fill="url(#messageCountColor)" fill={`url(#${id}Color)`}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>

@ -44,6 +44,12 @@ export const i18n: TushanContextProps['i18n'] = {
'Tailchat: The next-generation noIM Application in your own workspace', 'Tailchat: The next-generation noIM Application in your own workspace',
}, },
}, },
analytics: {
activeGroupTop5: 'Active Group Top 5',
activeUserTop5: 'Active User Top 5',
largeGroupTop5: 'Large Group Top 5',
fileStorageUserTop5: 'File Storage User 5',
},
network: { network: {
nodeList: 'Node List', nodeList: 'Node List',
id: 'ID', id: 'ID',
@ -106,6 +112,9 @@ export const i18n: TushanContextProps['i18n'] = {
translation: { translation: {
...i18nZhTranslation, ...i18nZhTranslation,
resources: { resources: {
analytics: {
name: '分析',
},
users: { users: {
name: '用户管理', name: '用户管理',
fields: { fields: {
@ -228,6 +237,12 @@ export const i18n: TushanContextProps['i18n'] = {
tushan: 'Tailchat Admin后台 由 tushan 提供技术支持', tushan: 'Tailchat Admin后台 由 tushan 提供技术支持',
}, },
}, },
analytics: {
activeGroupTop5: '前 5 名活跃群组',
activeUserTop5: '前 5 名活跃用户',
largeGroupTop5: '最大的 5 个群组',
fileStorageUserTop5: '文件存储用量最大 5 名用户',
},
network: { network: {
nodeList: '节点列表', nodeList: '节点列表',
id: 'ID', id: 'ID',

@ -0,0 +1,203 @@
import fileSize from 'filesize';
import React from 'react';
import {
Card,
Grid,
Tooltip,
Typography,
useAsync,
useTranslation,
} from 'tushan';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
XAxis,
YAxis,
} from 'tushan/chart';
import { request } from '../../request';
export const TailchatAnalytics: React.FC = React.memo(() => {
const { t } = useTranslation();
return (
<div>
<Grid.Row gutter={4}>
<Grid.Col md={12}>
<Card>
<Typography.Title heading={4}>
{t('custom.analytics.activeGroupTop5')}
</Typography.Title>
<ActiveGroupChart />
</Card>
</Grid.Col>
<Grid.Col md={12}>
<Card>
<Typography.Title heading={4}>
{t('custom.analytics.activeUserTop5')}
</Typography.Title>
<ActiveUserChart />
</Card>
</Grid.Col>
</Grid.Row>
<Grid.Row gutter={4} style={{ marginTop: 8 }}>
<Grid.Col md={12}>
<Card>
<Typography.Title heading={4}>
{t('custom.analytics.largeGroupTop5')}
</Typography.Title>
<LargeGroupChart />
</Card>
</Grid.Col>
<Grid.Col md={12}>
<Card>
<Typography.Title heading={4}>
{t('custom.analytics.fileStorageUserTop5')}
</Typography.Title>
<FileStorageChart />
</Card>
</Grid.Col>
</Grid.Row>
</div>
);
});
TailchatAnalytics.displayName = 'TailchatAnalytics';
const ActiveGroupChart: React.FC = React.memo(() => {
const { value } = useAsync(async () => {
const { data } = await request.get<{
activeGroups: {
groupId: string;
groupName: string;
messageCount: number;
}[];
}>('/analytics/activeGroups');
return data.activeGroups;
}, []);
return (
<ResponsiveContainer width="100%" height={320}>
<BarChart
data={value}
layout="vertical"
maxBarSize={40}
margin={{ left: 60 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="messageCount" type="number" />
<YAxis dataKey="groupName" type="category" />
<Tooltip />
<Bar dataKey="messageCount" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
);
});
ActiveGroupChart.displayName = 'ActiveGroupChart';
const ActiveUserChart: React.FC = React.memo(() => {
const { value } = useAsync(async () => {
const { data } = await request.get<{
activeUsers: {
groupId: string;
groupName: string;
messageCount: number;
}[];
}>('/analytics/activeUsers');
return data.activeUsers;
}, []);
return (
<ResponsiveContainer width="100%" height={320}>
<BarChart
data={value}
layout="vertical"
maxBarSize={40}
margin={{ left: 60 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="messageCount" type="number" />
<YAxis dataKey="userName" type="category" />
<Tooltip />
<Bar dataKey="messageCount" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
);
});
ActiveUserChart.displayName = 'ActiveUserChart';
const LargeGroupChart: React.FC = React.memo(() => {
const { value } = useAsync(async () => {
const { data } = await request.get<{
largeGroups: {
name: string;
memberCount: number;
}[];
}>('/analytics/largeGroups');
return data.largeGroups;
}, []);
return (
<ResponsiveContainer width="100%" height={320}>
<BarChart
data={value}
layout="vertical"
maxBarSize={40}
margin={{ left: 60 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="memberCount" type="number" />
<YAxis dataKey="name" type="category" />
<Tooltip />
<Bar dataKey="memberCount" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
);
});
LargeGroupChart.displayName = 'LargeGroupChart';
const FileStorageChart: React.FC = React.memo(() => {
const { value } = useAsync(async () => {
const { data } = await request.get<{
fileStorageUserTop: {
userId: string;
userName: string;
fileStorageTotal: number;
}[];
}>('/analytics/fileStorageUserTop');
return data.fileStorageUserTop;
}, []);
return (
<ResponsiveContainer width="100%" height={320}>
<BarChart
data={value}
layout="vertical"
maxBarSize={40}
margin={{ left: 60 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="fileStorageTotal"
type="number"
tickFormatter={(val) => fileSize(val)}
/>
<YAxis dataKey="userName" type="category" />
<Tooltip />
<Bar dataKey="fileStorageTotal" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
);
});
FileStorageChart.displayName = 'FileStorageChart';

@ -0,0 +1,218 @@
import { Router } from 'express';
import { auth } from '../middleware/auth';
import messageModel from '../../../../models/chat/message';
import groupModel from '../../../../models/group/group';
import fileModel from '../../../../models/file';
import dayjs from 'dayjs';
import { db } from 'tailchat-server-sdk';
const router = Router();
router.get('/activeGroups', auth(), async (req, res) => {
// 返回最近7天的最活跃的群组
const day = 7;
const aggregateRes: { _id: string; count: number }[] = await messageModel
.aggregate([
{
$match: {
createdAt: {
$gte: dayjs().subtract(day, 'd').startOf('d').toDate(),
$lt: dayjs().endOf('d').toDate(),
},
},
},
{
$group: {
_id: '$groupId' as any,
count: {
$sum: 1,
},
},
},
{
$sort: {
count: -1,
},
},
{
$limit: 5,
},
{
$lookup: {
from: 'groups',
localField: '_id',
foreignField: '_id',
as: 'groupInfo',
},
},
{
$project: {
_id: 0,
groupId: '$_id',
messageCount: '$count',
groupName: {
$arrayElemAt: ['$groupInfo.name', 0],
},
},
},
])
.exec();
const activeGroups = aggregateRes;
res.json({ activeGroups });
});
router.get('/activeUsers', auth(), async (req, res) => {
// 返回最近7天的最活跃的用户
const day = 7;
const aggregateRes: { _id: string; count: number }[] = await messageModel
.aggregate([
{
$match: {
author: {
$ne: new db.Types.ObjectId('000000000000000000000000'),
},
createdAt: {
$gte: dayjs().subtract(day, 'd').startOf('d').toDate(),
$lt: dayjs().endOf('d').toDate(),
},
},
},
{
$group: {
_id: '$author' as any,
count: {
$sum: 1,
},
},
},
{
$sort: {
count: -1,
},
},
{
$limit: 5,
},
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'userInfo',
},
},
{
$project: {
_id: 0,
userId: '$_id',
messageCount: '$count',
userName: {
$concat: [
{
$arrayElemAt: ['$userInfo.nickname', 0],
},
'#',
{
$arrayElemAt: ['$userInfo.discriminator', 0],
},
],
// $arrayElemAt: ['$userInfo.nickname', 0],
},
},
},
])
.exec();
const activeUsers = aggregateRes;
res.json({ activeUsers });
});
router.get('/largeGroups', auth(), async (req, res) => {
// 返回最大的 5 个群组
const limit = 5;
const aggregateRes: { _id: string; count: number }[] = await groupModel
.aggregate([
{
$project: {
name: 1,
memberCount: {
$size: '$members',
},
},
},
{
$sort: {
memberCount: -1,
},
},
{
$limit: limit,
},
])
.exec();
const largeGroups = aggregateRes;
res.json({ largeGroups });
});
router.get('/fileStorageUserTop', auth(), async (req, res) => {
// 返回最大的 5 个群组
const limit = 5;
const aggregateRes: { _id: string; count: number }[] = await fileModel
.aggregate([
{
$group: {
_id: '$userId',
total: {
$sum: '$size',
},
} as any,
},
{
$sort: {
total: -1,
},
},
{
$limit: limit,
},
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'userInfo',
},
},
{
$project: {
_id: 0,
userId: '$_id',
fileStorageTotal: '$total',
userName: {
$concat: [
{
$arrayElemAt: ['$userInfo.nickname', 0],
},
'#',
{
$arrayElemAt: ['$userInfo.discriminator', 0],
},
],
// $arrayElemAt: ['$userInfo.nickname', 0],
},
},
},
])
.exec();
const fileStorageUserTop = aggregateRes;
res.json({ fileStorageUserTop });
});
export { router as analyticsRouter };

@ -12,6 +12,7 @@ import groupModel from '../../../../models/group/group';
import { raExpressMongoose } from '../middleware/express-mongoose-ra-json-server'; import { raExpressMongoose } from '../middleware/express-mongoose-ra-json-server';
import { cacheRouter } from './cache'; import { cacheRouter } from './cache';
import discoverModel from '../../../../plugins/com.msgbyte.discover/models/discover'; import discoverModel from '../../../../plugins/com.msgbyte.discover/models/discover';
import { analyticsRouter } from './analytics';
const router = Router(); const router = Router();
@ -46,6 +47,7 @@ router.post('/login', (req, res) => {
} }
}); });
router.use('/analytics', analyticsRouter);
router.use('/network', networkRouter); router.use('/network', networkRouter);
router.use('/config', configRouter); router.use('/config', configRouter);
router.use('/file', fileRouter); router.use('/file', fileRouter);

Loading…
Cancel
Save