From 8d42e94e57b9426f122cee4ded52d9e640116502 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 17 Jan 2019 17:16:08 +0900
Subject: [PATCH] Implement active users chart

Resolve #3904
---
 locales/ja-JP.yml                             |  1 +
 src/chart/active-users.ts                     | 48 +++++++++++++++++++
 src/client/app/admin/views/charts.vue         | 36 +++++++++++---
 .../api/endpoints/charts/active-users.ts      | 34 +++++++++++++
 .../api/endpoints/notes/hybrid-timeline.ts    |  3 ++
 .../api/endpoints/notes/local-timeline.ts     |  5 ++
 src/server/api/endpoints/notes/timeline.ts    |  3 ++
 src/services/note/create.ts                   |  3 ++
 8 files changed, 127 insertions(+), 6 deletions(-)
 create mode 100644 src/chart/active-users.ts
 create mode 100644 src/server/api/endpoints/charts/active-users.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8c75fe6eb1..a7796c4d11 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1209,6 +1209,7 @@ admin/views/charts.vue:
     notes-total: "投稿の積算"
     users: "ユーザーの増減"
     users-total: "ユーザーの積算"
+    active-users: "アクティブユーザー数"
     drive: "ドライブ使用量の増減"
     drive-total: "ドライブ使用量の積算"
     drive-files: "ドライブのファイル数の増減"
diff --git a/src/chart/active-users.ts b/src/chart/active-users.ts
new file mode 100644
index 0000000000..06d9b8aa90
--- /dev/null
+++ b/src/chart/active-users.ts
@@ -0,0 +1,48 @@
+import autobind from 'autobind-decorator';
+import Chart, { Obj } from '.';
+import { IUser, isLocalUser } from '../models/user';
+
+/**
+ * アクティブユーザーに関するチャート
+ */
+type ActiveUsersLog = {
+	local: {
+		/**
+		 * アクティブユーザー数
+		 */
+		count: number;
+	};
+
+	remote: ActiveUsersLog['local'];
+};
+
+class ActiveUsersChart extends Chart<ActiveUsersLog> {
+	constructor() {
+		super('activeUsers');
+	}
+
+	@autobind
+	protected async getTemplate(init: boolean, latest?: ActiveUsersLog): Promise<ActiveUsersLog> {
+		return {
+			local: {
+				count: 0
+			},
+			remote: {
+				count: 0
+			}
+		};
+	}
+
+	@autobind
+	public async update(user: IUser) {
+		const update: Obj = {
+			count: 1
+		};
+
+		await this.incIfUnique({
+			[isLocalUser(user) ? 'local' : 'remote']: update
+		}, 'users', user._id.toHexString());
+	}
+}
+
+export default new ActiveUsersChart();
diff --git a/src/client/app/admin/views/charts.vue b/src/client/app/admin/views/charts.vue
index 13e8b3671e..04cf9512cf 100644
--- a/src/client/app/admin/views/charts.vue
+++ b/src/client/app/admin/views/charts.vue
@@ -10,6 +10,7 @@
 			<optgroup :label="$t('users')">
 				<option value="users">{{ $t('charts.users') }}</option>
 				<option value="users-total">{{ $t('charts.users-total') }}</option>
+				<option value="active-users">{{ $t('charts.active-users') }}</option>
 			</optgroup>
 			<optgroup :label="$t('notes')">
 				<option value="notes">{{ $t('charts.notes') }}</option>
@@ -67,6 +68,7 @@ export default Vue.extend({
 				case 'federation-instances-total': return this.federationInstancesChart(true);
 				case 'users': return this.usersChart(false);
 				case 'users-total': return this.usersChart(true);
+				case 'active-users': return this.activeUsersChart();
 				case 'notes': return this.notesChart('combined');
 				case 'local-notes': return this.notesChart('local');
 				case 'remote-notes': return this.notesChart('remote');
@@ -107,12 +109,14 @@ export default Vue.extend({
 		const [perHour, perDay] = await Promise.all([Promise.all([
 			this.$root.api('charts/federation', { limit: limit, span: 'hour' }),
 			this.$root.api('charts/users', { limit: limit, span: 'hour' }),
+			this.$root.api('charts/active-users', { limit: limit, span: 'hour' }),
 			this.$root.api('charts/notes', { limit: limit, span: 'hour' }),
 			this.$root.api('charts/drive', { limit: limit, span: 'hour' }),
 			this.$root.api('charts/network', { limit: limit, span: 'hour' })
 		]), Promise.all([
 			this.$root.api('charts/federation', { limit: limit, span: 'day' }),
 			this.$root.api('charts/users', { limit: limit, span: 'day' }),
+			this.$root.api('charts/active-users', { limit: limit, span: 'day' }),
 			this.$root.api('charts/notes', { limit: limit, span: 'day' }),
 			this.$root.api('charts/drive', { limit: limit, span: 'day' }),
 			this.$root.api('charts/network', { limit: limit, span: 'day' })
@@ -122,16 +126,18 @@ export default Vue.extend({
 			perHour: {
 				federation: perHour[0],
 				users: perHour[1],
-				notes: perHour[2],
-				drive: perHour[3],
-				network: perHour[4]
+				activeUsers: perHour[2],
+				notes: perHour[3],
+				drive: perHour[4],
+				network: perHour[5]
 			},
 			perDay: {
 				federation: perDay[0],
 				users: perDay[1],
-				notes: perDay[2],
-				drive: perDay[3],
-				network: perDay[4]
+				activeUsers: perDay[2],
+				notes: perDay[3],
+				drive: perDay[4],
+				network: perDay[5]
 			}
 		};
 
@@ -321,6 +327,24 @@ export default Vue.extend({
 			};
 		},
 
+		activeUsersChart(): any {
+			return {
+				series: [{
+					name: 'Combined',
+					type: 'line',
+					data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count))
+				}, {
+					name: 'Local',
+					type: 'area',
+					data: this.format(this.stats.activeUsers.local.count)
+				}, {
+					name: 'Remote',
+					type: 'area',
+					data: this.format(this.stats.activeUsers.remote.count)
+				}]
+			};
+		},
+
 		driveChart(): any {
 			return {
 				bytes: true,
diff --git a/src/server/api/endpoints/charts/active-users.ts b/src/server/api/endpoints/charts/active-users.ts
new file mode 100644
index 0000000000..5187e5b353
--- /dev/null
+++ b/src/server/api/endpoints/charts/active-users.ts
@@ -0,0 +1,34 @@
+import $ from 'cafy';
+import define from '../../define';
+import activeUsersChart from '../../../../chart/active-users';
+
+export const meta = {
+	stability: 'stable',
+
+	desc: {
+		'ja-JP': 'アクティブユーザーのチャートを取得します。'
+	},
+
+	params: {
+		span: {
+			validator: $.str.or(['day', 'hour']),
+			desc: {
+				'ja-JP': '集計のスパン (day または hour)'
+			}
+		},
+
+		limit: {
+			validator: $.num.optional.range(1, 500),
+			default: 30,
+			desc: {
+				'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。'
+			}
+		},
+	}
+};
+
+export default define(meta, (ps) => new Promise(async (res, rej) => {
+	const stats = await activeUsersChart.getChart(ps.span as any, ps.limit);
+
+	res(stats);
+}));
diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts
index 20855e5139..919b0662aa 100644
--- a/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -6,6 +6,7 @@ import { packMany } from '../../../../models/note';
 import define from '../../define';
 import { countIf } from '../../../../prelude/array';
 import fetchMeta from '../../../../misc/fetch-meta';
+import activeUsersChart from '../../../../chart/active-users';
 
 export const meta = {
 	desc: {
@@ -272,4 +273,6 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => {
 		});
 
 	res(await packMany(timeline, user));
+
+	activeUsersChart.update(user);
 }));
diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts
index 30d2762ad5..fd624c10e6 100644
--- a/src/server/api/endpoints/notes/local-timeline.ts
+++ b/src/server/api/endpoints/notes/local-timeline.ts
@@ -5,6 +5,7 @@ import { packMany } from '../../../../models/note';
 import define from '../../define';
 import { countIf } from '../../../../prelude/array';
 import fetchMeta from '../../../../misc/fetch-meta';
+import activeUsersChart from '../../../../chart/active-users';
 
 export const meta = {
 	desc: {
@@ -161,4 +162,8 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => {
 		});
 
 	res(await packMany(timeline, user));
+
+	if (user) {
+		activeUsersChart.update(user);
+	}
 }));
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
index 5604cf291b..24ed222e9a 100644
--- a/src/server/api/endpoints/notes/timeline.ts
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -5,6 +5,7 @@ import { getFriends } from '../../common/get-friends';
 import { packMany } from '../../../../models/note';
 import define from '../../define';
 import { countIf } from '../../../../prelude/array';
+import activeUsersChart from '../../../../chart/active-users';
 
 export const meta = {
 	desc: {
@@ -266,4 +267,6 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => {
 
 	// Serialize
 	res(await packMany(timeline, user));
+
+	activeUsersChart.update(user);
 }));
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 18073f3aa6..8031746c89 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -23,6 +23,7 @@ import registerHashtag from '../register-hashtag';
 import isQuote from '../../misc/is-quote';
 import notesChart from '../../chart/notes';
 import perUserNotesChart from '../../chart/per-user-notes';
+import activeUsersChart from '../../chart/active-users';
 
 import { erase } from '../../prelude/array';
 import insertNoteUnread from './unread';
@@ -196,6 +197,8 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 	// 統計を更新
 	notesChart.update(note, true);
 	perUserNotesChart.update(user, note, true);
+	// ローカルユーザーのチャートはタイムライン取得時に更新しているのでリモートユーザーの場合だけでよい
+	if (isRemoteUser(user)) activeUsersChart.update(user);
 
 	// Register host
 	if (isRemoteUser(user)) {