diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 119fe791b..a6148a62d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1399,11 +1399,31 @@ admin/views/federation.vue: followingDesc: "フォローが多い順" followersAsc: "フォロワーが少ない順" followersDesc: "フォロワーが多い順" + driveUsageAsc: "ドライブ使用量が少ない順" + driveUsageDesc: "ドライブ使用量が多い順" + driveFilesAsc: "ドライブのファイル数が少ない順" + driveFilesDesc: "ドライブのファイル数が多い順" state: "状態" states: all: "すべて" blocked: "ブロック" result-is-truncated: "上位{n}件を表示しています。" + charts: "チャート" + chart-srcs: + requests: "リクエスト" + users: "ユーザーの増減" + users-total: "ユーザーの積算" + notes: "投稿の増減" + notes-total: "投稿の積算" + ff: "フォロー/フォロワーの増減" + ff-total: "フォロー/フォロワーの積算" + drive-usage: "ドライブ使用量の増減" + drive-usage-total: "ドライブ使用量の増減" + drive-files: "ドライブファイル数の増減" + drive-files-total: "ドライブファイル数の増減" + chart-spans: + hour: "1時間ごと" + day: "1日ごと" desktop/views/pages/welcome.vue: about: "詳しく..." diff --git a/src/client/app/admin/views/federation.vue b/src/client/app/admin/views/federation.vue index dd8567243..8b0e9ba45 100644 --- a/src/client/app/admin/views/federation.vue +++ b/src/client/app/admin/views/federation.vue @@ -40,6 +40,29 @@ {{ $t('latest-request-received-at') }} {{ $t('block') }} +
+ {{ $t('charts') }} + + + + + + + + + + + + + + + + + + + +
+
{{ $t('remove-all-following') }} {{ $t('remove-all-following') }} @@ -50,7 +73,7 @@ -
{{ $t('instances') }}
+
{{ $t('instances') }}
@@ -65,6 +88,10 @@ + + + + {{ $t('state') }} @@ -101,7 +128,13 @@ diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index 62a544c21..f3e21f209 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -11,6 +11,7 @@ DriveFile.createIndex('md5'); DriveFile.createIndex('metadata.uri'); DriveFile.createIndex('metadata.userId'); DriveFile.createIndex('metadata.folderId'); +DriveFile.createIndex('metadata._user.host'); export default DriveFile; export const DriveFileChunk = monkDb.get('driveFiles.chunks'); diff --git a/src/models/instance.ts b/src/models/instance.ts index 242e80f30..985564f8d 100644 --- a/src/models/instance.ts +++ b/src/models/instance.ts @@ -43,6 +43,16 @@ export interface IInstance { */ followersCount: number; + /** + * ドライブ使用量 + */ + driveUsage: number; + + /** + * ドライブのファイル数 + */ + driveFiles: number; + /** * 直近のリクエスト送信日時 */ diff --git a/src/models/user.ts b/src/models/user.ts index 2453a2ed1..ce0d17a04 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -17,6 +17,7 @@ const User = db.get('users'); User.createIndex('username'); User.createIndex('usernameLower'); +User.createIndex('host'); User.createIndex(['username', 'host'], { unique: true }); User.createIndex(['usernameLower', 'host'], { unique: true }); User.createIndex('token', { sparse: true, unique: true }); diff --git a/src/queue/processors/http/deliver.ts b/src/queue/processors/http/deliver.ts index 82dcf06ad..6d24cd263 100644 --- a/src/queue/processors/http/deliver.ts +++ b/src/queue/processors/http/deliver.ts @@ -4,6 +4,7 @@ import request from '../../../remote/activitypub/request'; import { queueLogger } from '../../logger'; import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-instance-doc'; import Instance from '../../../models/instance'; +import instanceChart from '../../../services/chart/instance'; export default async (job: bq.Job, done: any): Promise => { const { host } = new URL(job.data.to); @@ -19,6 +20,8 @@ export default async (job: bq.Job, done: any): Promise => { latestStatus: 200 } }); + + instanceChart.requestSent(i.host, true); }); done(); @@ -31,6 +34,8 @@ export default async (job: bq.Job, done: any): Promise => { latestStatus: res != null && res.hasOwnProperty('statusCode') ? res.statusCode : null } }); + + instanceChart.requestSent(i.host, false); }); if (res != null && res.hasOwnProperty('statusCode')) { diff --git a/src/queue/processors/http/process-inbox.ts b/src/queue/processors/http/process-inbox.ts index 583e25513..07d4b5ba7 100644 --- a/src/queue/processors/http/process-inbox.ts +++ b/src/queue/processors/http/process-inbox.ts @@ -10,6 +10,7 @@ import { publishApLogStream } from '../../../services/stream'; import Logger from '../../../misc/logger'; import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-instance-doc'; import Instance from '../../../models/instance'; +import instanceChart from '../../../services/chart/instance'; const logger = new Logger('inbox'); @@ -128,6 +129,8 @@ export default async (job: bq.Job, done: any): Promise => { latestRequestReceivedAt: new Date() } }); + + instanceChart.requestReceived(i.host); }); // アクティビティを処理 diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index c3b26b657..7809314cd 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -10,6 +10,7 @@ import { IDriveFile } from '../../../models/drive-file'; import Meta from '../../../models/meta'; import { fromHtml } from '../../../mfm/fromHtml'; import usersChart from '../../../services/chart/users'; +import instanceChart from '../../../services/chart/instance'; import { URL } from 'url'; import { resolveNote, extractEmojis } from './note'; import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-instance-doc'; @@ -195,8 +196,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise new Promise(async (res, rej) => { + const stats = await instanceChart.getChart(ps.span as any, ps.limit, ps.host); + + res(stats); +})); diff --git a/src/server/api/endpoints/federation/instances.ts b/src/server/api/endpoints/federation/instances.ts index ce0d10af2..9b4efbaaf 100644 --- a/src/server/api/endpoints/federation/instances.ts +++ b/src/server/api/endpoints/federation/instances.ts @@ -70,6 +70,22 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { sort = { caughtAt: 1 }; + } else if (ps.sort == '+driveUsage') { + sort = { + driveUsage: -1 + }; + } else if (ps.sort == '-driveUsage') { + sort = { + driveUsage: 1 + }; + } else if (ps.sort == '+driveFiles') { + sort = { + driveFiles: -1 + }; + } else if (ps.sort == '-driveFiles') { + sort = { + driveFiles: 1 + }; } } else { sort = { diff --git a/src/services/chart/instance.ts b/src/services/chart/instance.ts new file mode 100644 index 000000000..5af398b90 --- /dev/null +++ b/src/services/chart/instance.ts @@ -0,0 +1,302 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from '.'; +import User from '../../models/user'; +import Note from '../../models/note'; +import Following from '../../models/following'; +import DriveFile, { IDriveFile } from '../../models/drive-file'; + +/** + * インスタンスごとのチャート + */ +type InstanceLog = { + requests: { + /** + * 失敗したリクエスト数 + */ + failed: number; + + /** + * 成功したリクエスト数 + */ + succeeded: number; + + /** + * 受信したリクエスト数 + */ + received: number; + }; + + notes: { + /** + * 集計期間時点での、全投稿数 + */ + total: number; + + /** + * 増加した投稿数 + */ + inc: number; + + /** + * 減少した投稿数 + */ + dec: number; + }; + + users: { + /** + * 集計期間時点での、全ユーザー数 + */ + total: number; + + /** + * 増加したユーザー数 + */ + inc: number; + + /** + * 減少したユーザー数 + */ + dec: number; + }; + + following: { + /** + * 集計期間時点での、全フォロー数 + */ + total: number; + + /** + * 増加したフォロー数 + */ + inc: number; + + /** + * 減少したフォロー数 + */ + dec: number; + }; + + followers: { + /** + * 集計期間時点での、全フォロワー数 + */ + total: number; + + /** + * 増加したフォロワー数 + */ + inc: number; + + /** + * 減少したフォロワー数 + */ + dec: number; + }; + + drive: { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalFiles: number; + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalUsage: number; + + /** + * 増加したドライブファイル数 + */ + incFiles: number; + + /** + * 増加したドライブ使用量 + */ + incUsage: number; + + /** + * 減少したドライブファイル数 + */ + decFiles: number; + + /** + * 減少したドライブ使用量 + */ + decUsage: number; + }; +}; + +class InstanceChart extends Chart { + constructor() { + super('instance', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: InstanceLog, group?: any): Promise { + const calcUsage = () => DriveFile + .aggregate([{ + $match: { + 'metadata._user.host': group, + 'metadata.deletedAt': { $exists: false } + } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then(res => res.length > 0 ? res[0].usage : 0); + + const [ + notesCount, + usersCount, + followingCount, + followersCount, + driveFiles, + driveUsage, + ] = init ? await Promise.all([ + Note.count({ '_user.host': group }), + User.count({ host: group }), + Following.count({ '_follower.host': group }), + Following.count({ '_followee.host': group }), + DriveFile.count({ 'metadata._user.host': group }), + calcUsage(), + ]) : [ + latest ? latest.notes.total : 0, + latest ? latest.users.total : 0, + latest ? latest.following.total : 0, + latest ? latest.followers.total : 0, + latest ? latest.drive.totalFiles : 0, + latest ? latest.drive.totalUsage : 0, + ]; + + return { + requests: { + failed: 0, + succeeded: 0, + received: 0 + }, + notes: { + total: notesCount, + inc: 0, + dec: 0 + }, + users: { + total: usersCount, + inc: 0, + dec: 0 + }, + following: { + total: followingCount, + inc: 0, + dec: 0 + }, + followers: { + total: followersCount, + inc: 0, + dec: 0 + }, + drive: { + totalFiles: driveFiles, + totalUsage: driveUsage, + incFiles: 0, + incUsage: 0, + decFiles: 0, + decUsage: 0 + } + }; + } + + @autobind + public async requestReceived(host: string) { + await this.inc({ + requests: { + received: 1 + } + }, host); + } + + @autobind + public async requestSent(host: string, isSucceeded: boolean) { + const update: Obj = {}; + + if (isSucceeded) { + update.succeeded = 1; + } else { + update.failed = 1; + } + + await this.inc({ + requests: update + }, host); + } + + @autobind + public async newUser(host: string) { + await this.inc({ + users: { + total: 1, + inc: 1 + } + }, host); + } + + @autobind + public async updateNote(host: string, isAdditional: boolean) { + await this.inc({ + notes: { + total: isAdditional ? 1 : -1, + inc: isAdditional ? 1 : 0, + dec: isAdditional ? 0 : 1, + } + }, host); + } + + @autobind + public async updateFollowing(host: string, isAdditional: boolean) { + await this.inc({ + following: { + total: isAdditional ? 1 : -1, + inc: isAdditional ? 1 : 0, + dec: isAdditional ? 0 : 1, + } + }, host); + } + + @autobind + public async updateFollowers(host: string, isAdditional: boolean) { + await this.inc({ + followers: { + total: isAdditional ? 1 : -1, + inc: isAdditional ? 1 : 0, + dec: isAdditional ? 0 : 1, + } + }, host); + } + + @autobind + public async updateDrive(file: IDriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalFiles = isAdditional ? 1 : -1; + update.totalUsage = isAdditional ? file.length : -file.length; + if (isAdditional) { + update.incFiles = 1; + update.incUsage = file.length; + } else { + update.decFiles = 1; + update.decUsage = file.length; + } + + await this.inc({ + drive: update + }, file.metadata._user.host); + } +} + +export default new InstanceChart(); diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 0e588d344..9f3805f94 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -13,17 +13,19 @@ import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile } from '../../mode import DriveFolder from '../../models/drive-folder'; import { pack } from '../../models/drive-file'; import { publishMainStream, publishDriveStream } from '../stream'; -import { isLocalUser, IUser, IRemoteUser } from '../../models/user'; +import { isLocalUser, IUser, IRemoteUser, isRemoteUser } from '../../models/user'; import delFile from './delete-file'; import config from '../../config'; import { getDriveFileWebpublicBucket } from '../../models/drive-file-webpublic'; import { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; import driveChart from '../../services/chart/drive'; import perUserDriveChart from '../../services/chart/per-user-drive'; +import instanceChart from '../../services/chart/instance'; import fetchMeta from '../../misc/fetch-meta'; import { GenerateVideoThumbnail } from './generate-video-thumbnail'; import { driveLogger } from './logger'; import { IImage, ConvertToJpeg, ConvertToWebp, ConvertToPng } from './image-processor'; +import Instance from '../../models/instance'; const logger = driveLogger.createSubLogger('register', 'yellow'); @@ -523,6 +525,15 @@ export default async function( // 統計を更新 driveChart.update(driveFile, true); perUserDriveChart.update(driveFile, true); + if (isRemoteUser(driveFile.metadata._user)) { + instanceChart.updateDrive(driveFile, true); + Instance.update({ host: driveFile.metadata._user.host }, { + $inc: { + driveUsage: driveFile.length, + driveFiles: 1 + } + }); + } return driveFile; } diff --git a/src/services/drive/delete-file.ts b/src/services/drive/delete-file.ts index 4211cd829..c5c15ca20 100644 --- a/src/services/drive/delete-file.ts +++ b/src/services/drive/delete-file.ts @@ -4,7 +4,10 @@ import DriveFileThumbnail, { DriveFileThumbnailChunk } from '../../models/drive- import config from '../../config'; import driveChart from '../../services/chart/drive'; import perUserDriveChart from '../../services/chart/per-user-drive'; +import instanceChart from '../../services/chart/instance'; import DriveFileWebpublic, { DriveFileWebpublicChunk } from '../../models/drive-file-webpublic'; +import Instance from '../../models/instance'; +import { isRemoteUser } from '../../models/user'; export default async function(file: IDriveFile, isExpired = false) { if (file.metadata.storage == 'minio') { @@ -84,4 +87,13 @@ export default async function(file: IDriveFile, isExpired = false) { // 統計を更新 driveChart.update(file, false); perUserDriveChart.update(file, false); + if (isRemoteUser(file.metadata._user)) { + instanceChart.updateDrive(file, false); + Instance.update({ host: file.metadata._user.host }, { + $inc: { + driveUsage: -file.length, + driveFiles: -1 + } + }); + } } diff --git a/src/services/following/create.ts b/src/services/following/create.ts index 65b80dcf8..05f463258 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -12,6 +12,7 @@ import createFollowRequest from './requests/create'; import perUserFollowingChart from '../../services/chart/per-user-following'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; import Instance from '../../models/instance'; +import instanceChart from '../../services/chart/instance'; export default async function(follower: IUser, followee: IUser, requestId?: string) { // check blocking @@ -108,8 +109,7 @@ export default async function(follower: IUser, followee: IUser, requestId?: stri } }); - // TODO - //perInstanceChart.newFollowing(); + instanceChart.updateFollowing(i.host, true); }); } else if (isLocalUser(follower) && isRemoteUser(followee)) { registerOrFetchInstanceDoc(followee.host).then(i => { @@ -119,8 +119,7 @@ export default async function(follower: IUser, followee: IUser, requestId?: stri } }); - // TODO - //perInstanceChart.newFollower(); + instanceChart.updateFollowers(i.host, true); }); } //#endregion diff --git a/src/services/following/delete.ts b/src/services/following/delete.ts index 87eaf826e..93f72b51d 100644 --- a/src/services/following/delete.ts +++ b/src/services/following/delete.ts @@ -7,6 +7,9 @@ import renderUndo from '../../remote/activitypub/renderer/undo'; import { deliver } from '../../queue'; import perUserFollowingChart from '../../services/chart/per-user-following'; import Logger from '../../misc/logger'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; +import Instance from '../../models/instance'; +import instanceChart from '../../services/chart/instance'; const logger = new Logger('following/delete'); @@ -41,6 +44,30 @@ export default async function(follower: IUser, followee: IUser) { }); //#endregion + //#region Update instance stats + if (isRemoteUser(follower) && isLocalUser(followee)) { + registerOrFetchInstanceDoc(follower.host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + followingCount: -1 + } + }); + + instanceChart.updateFollowing(i.host, false); + }); + } else if (isLocalUser(follower) && isRemoteUser(followee)) { + registerOrFetchInstanceDoc(followee.host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + followersCount: -1 + } + }); + + instanceChart.updateFollowers(i.host, false); + }); + } + //#endregion + perUserFollowingChart.update(follower, followee, false); // Publish unfollow event diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 0b71a9670..126d698b0 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -24,6 +24,7 @@ import isQuote from '../../misc/is-quote'; import notesChart from '../../services/chart/notes'; import perUserNotesChart from '../../services/chart/per-user-notes'; import activeUsersChart from '../../services/chart/active-users'; +import instanceChart from '../../services/chart/instance'; import { erase, concat } from '../../prelude/array'; import insertNoteUnread from './unread'; @@ -229,8 +230,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< } }); - // TODO - //perInstanceChart.newNote(); + instanceChart.updateNote(i.host, true); }); } diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts index 8e8c20bfc..2b797545e 100644 --- a/src/services/note/delete.ts +++ b/src/services/note/delete.ts @@ -1,5 +1,5 @@ import Note, { INote } from '../../models/note'; -import { IUser, isLocalUser } from '../../models/user'; +import { IUser, isLocalUser, isRemoteUser } from '../../models/user'; import { publishNoteStream } from '../stream'; import renderDelete from '../../remote/activitypub/renderer/delete'; import { renderActivity } from '../../remote/activitypub/renderer'; @@ -12,6 +12,9 @@ import config from '../../config'; import NoteUnread from '../../models/note-unread'; import read from './read'; import DriveFile from '../../models/drive-file'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; +import Instance from '../../models/instance'; +import instanceChart from '../../services/chart/instance'; /** * 投稿を削除します。 @@ -91,4 +94,16 @@ export default async function(user: IUser, note: INote) { // 統計を更新 notesChart.update(note, false); perUserNotesChart.update(user, note, false); + + if (isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + notesCount: -1 + } + }); + + instanceChart.updateNote(i.host, false); + }); + } }