diff --git a/server/admin/src/client/App.tsx b/server/admin/src/client/App.tsx index e75551a1..dec756cd 100644 --- a/server/admin/src/client/App.tsx +++ b/server/admin/src/client/App.tsx @@ -11,6 +11,7 @@ import { IconCompass, IconDashboard, IconEmail, + IconExperiment, IconFile, IconMessage, IconNotification, @@ -31,6 +32,7 @@ import { import { i18n } from './i18n'; import { GroupList } from './resources/group'; import { UserList } from './resources/user'; +import { TailchatAnalytics } from './routes/analytics'; import { CacheManager } from './routes/cache'; import { TailchatNetwork } from './routes/network'; import { SocketIOAdmin } from './routes/socketio'; @@ -50,6 +52,10 @@ function App() { authProvider={authProvider} i18n={i18n} > + }> + + + } list={} /> { + const id = 'userCount'; + const color = '#82ca9d'; const { t } = useTranslation(); const { value: newUserCountSummary } = useAsync(async () => { const { data } = await request.get<{ @@ -230,9 +232,9 @@ const UserCountChart: React.FC = React.memo(() => { margin={{ top: 10, right: 30, left: 0, bottom: 0 }} > - - - + + + @@ -243,9 +245,9 @@ const UserCountChart: React.FC = React.memo(() => { type="monotone" dataKey="count" label={t('custom.dashboard.newUserCount')} - stroke="#82ca9d" + stroke={color} fillOpacity={1} - fill="url(#userCountColor)" + fill={`url(#${id}Color)`} /> @@ -254,6 +256,8 @@ const UserCountChart: React.FC = React.memo(() => { UserCountChart.displayName = 'UserCountChart'; const MessageCountChart: React.FC = React.memo(() => { + const id = 'messageCount'; + const color = '#8884d8'; const { t } = useTranslation(); const { value: messageCountSummary } = useAsync(async () => { const { data } = await request.get<{ @@ -275,9 +279,9 @@ const MessageCountChart: React.FC = React.memo(() => { margin={{ top: 10, right: 30, left: 0, bottom: 0 }} > - - - + + + @@ -290,7 +294,7 @@ const MessageCountChart: React.FC = React.memo(() => { label={t('custom.dashboard.messageCount')} stroke="#8884d8" fillOpacity={1} - fill="url(#messageCountColor)" + fill={`url(#${id}Color)`} /> diff --git a/server/admin/src/client/i18n.ts b/server/admin/src/client/i18n.ts index 2477563c..90e529d9 100644 --- a/server/admin/src/client/i18n.ts +++ b/server/admin/src/client/i18n.ts @@ -44,6 +44,12 @@ export const i18n: TushanContextProps['i18n'] = { '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: { nodeList: 'Node List', id: 'ID', @@ -106,6 +112,9 @@ export const i18n: TushanContextProps['i18n'] = { translation: { ...i18nZhTranslation, resources: { + analytics: { + name: '分析', + }, users: { name: '用户管理', fields: { @@ -228,6 +237,12 @@ export const i18n: TushanContextProps['i18n'] = { tushan: 'Tailchat Admin后台 由 tushan 提供技术支持', }, }, + analytics: { + activeGroupTop5: '前 5 名活跃群组', + activeUserTop5: '前 5 名活跃用户', + largeGroupTop5: '最大的 5 个群组', + fileStorageUserTop5: '文件存储用量最大 5 名用户', + }, network: { nodeList: '节点列表', id: 'ID', diff --git a/server/admin/src/client/routes/analytics/index.tsx b/server/admin/src/client/routes/analytics/index.tsx new file mode 100644 index 00000000..860b9751 --- /dev/null +++ b/server/admin/src/client/routes/analytics/index.tsx @@ -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 ( +
+ + + + + {t('custom.analytics.activeGroupTop5')} + + + + + + + + + + {t('custom.analytics.activeUserTop5')} + + + + + + + + + + + + {t('custom.analytics.largeGroupTop5')} + + + + + + + + + + {t('custom.analytics.fileStorageUserTop5')} + + + + + + +
+ ); +}); +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 ( + + + + + + + + + + ); +}); +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 ( + + + + + + + + + + ); +}); +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 ( + + + + + + + + + + ); +}); +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 ( + + + + fileSize(val)} + /> + + + + + + ); +}); +FileStorageChart.displayName = 'FileStorageChart'; diff --git a/server/admin/src/server/router/analytics.ts b/server/admin/src/server/router/analytics.ts new file mode 100644 index 00000000..509aa939 --- /dev/null +++ b/server/admin/src/server/router/analytics.ts @@ -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 }; diff --git a/server/admin/src/server/router/api.ts b/server/admin/src/server/router/api.ts index e8336dde..4602ab9e 100644 --- a/server/admin/src/server/router/api.ts +++ b/server/admin/src/server/router/api.ts @@ -12,6 +12,7 @@ import groupModel from '../../../../models/group/group'; import { raExpressMongoose } from '../middleware/express-mongoose-ra-json-server'; import { cacheRouter } from './cache'; import discoverModel from '../../../../plugins/com.msgbyte.discover/models/discover'; +import { analyticsRouter } from './analytics'; const router = Router(); @@ -46,6 +47,7 @@ router.post('/login', (req, res) => { } }); +router.use('/analytics', analyticsRouter); router.use('/network', networkRouter); router.use('/config', configRouter); router.use('/file', fileRouter);