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