Merge pull request #2329 from syuilo/improve-chart

Improve chart
This commit is contained in:
syuilo 2018-08-19 00:46:17 +09:00 committed by GitHub
commit 2f45ac3712
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 774 additions and 896 deletions

View File

@ -5,6 +5,15 @@ ChangeLog
This document describes breaking changes only.
7.0.0
-----
### Migration
起動する前に、`node cli/migration/7.0.0`してください。
Please run `node cli/migration/7.0.0` before launch.
6.0.0
-----

View File

@ -1,101 +0,0 @@
const chalk = require('chalk');
const log = require('single-line-log').stdout;
const sequential = require('promise-sequential');
const { default: DriveFile, DriveFileChunk } = require('../built/models/drive-file');
const { default: DriveFileThumbnail, DriveFileThumbnailChunk } = require('../built/models/drive-file-thumbnail');
const { default: User } = require('../built/models/user');
const q = {
'metadata._user.host': {
$ne: null
},
'metadata.withoutChunks': false
};
async function main() {
const promiseGens = [];
const count = await DriveFile.count(q);
let prev;
for (let i = 0; i < count; i++) {
promiseGens.push(() => {
const promise = new Promise(async (res, rej) => {
const file = await DriveFile.findOne(prev ? Object.assign({
_id: { $lt: prev._id }
}, q) : q, {
sort: {
_id: -1
}
});
prev = file;
function skip() {
res([i, file, false]);
}
if (file == null) return skip();
log(chalk`{gray ${i}} scanning {bold ${file._id}} ${file.filename} ...`);
const attachingUsersCount = await User.count({
$or: [{
avatarId: file._id
}, {
bannerId: file._id
}]
}, { limit: 1 });
if (attachingUsersCount !== 0) return skip();
Promise.all([
// チャンクをすべて削除
DriveFileChunk.remove({
files_id: file._id
}),
DriveFile.update({ _id: file._id }, {
$set: {
'metadata.withoutChunks': true
}
})
]).then(async () => {
res([i, file, true]);
//#region サムネイルもあれば削除
const thumbnail = await DriveFileThumbnail.findOne({
'metadata.originalId': file._id
});
if (thumbnail) {
DriveFileThumbnailChunk.remove({
files_id: thumbnail._id
});
DriveFileThumbnail.remove({ _id: thumbnail._id });
}
//#endregion
});
});
promise.then(([i, file, deleted]) => {
if (deleted) {
log(chalk`{gray ${i}} {red deleted: {bold ${file._id}} ${file.filename}}`);
} else {
log(chalk`{gray ${i}} {green skipped: {bold ${file._id}} ${file.filename}}`);
}
log.clear();
console.log();
});
return promise;
});
}
return await sequential(promiseGens);
}
main().then(() => {
console.log('ALL DONE');
}).catch(console.error);

View File

@ -1,80 +0,0 @@
const chalk = require('chalk');
const log = require('single-line-log').stdout;
const sequential = require('promise-sequential');
const { default: DriveFile, deleteDriveFile } = require('../built/models/drive-file');
const { default: Note } = require('../built/models/note');
const { default: MessagingMessage } = require('../built/models/messaging-message');
const { default: User } = require('../built/models/user');
async function main() {
const promiseGens = [];
const count = await DriveFile.count({});
let prev;
for (let i = 0; i < count; i++) {
promiseGens.push(() => {
const promise = new Promise(async (res, rej) => {
const file = await DriveFile.findOne(prev ? {
_id: { $lt: prev._id }
} : {}, {
sort: {
_id: -1
}
});
prev = file;
function skip() {
res([i, file, false]);
}
if (file == null) return skip();
log(chalk`{gray ${i}} scanning {bold ${file._id}} ${file.filename} ...`);
const attachingUsersCount = await User.count({
$or: [{
avatarId: file._id
}, {
bannerId: file._id
}]
}, { limit: 1 });
if (attachingUsersCount !== 0) return skip();
const attachingNotesCount = await Note.count({
mediaIds: file._id
}, { limit: 1 });
if (attachingNotesCount !== 0) return skip();
const attachingMessagesCount = await MessagingMessage.count({
fileId: file._id
}, { limit: 1 });
if (attachingMessagesCount !== 0) return skip();
deleteDriveFile(file).then(() => {
res([i, file, true]);
}).catch(rej);
});
promise.then(([i, file, deleted]) => {
if (deleted) {
log(chalk`{gray ${i}} {red deleted: {bold ${file._id}} ${file.filename}}`);
} else {
log(chalk`{gray ${i}} {green skipped: {bold ${file._id}} ${file.filename}}`);
}
log.clear();
console.log();
});
return promise;
});
}
return await sequential(promiseGens);
}
main().then(() => {
console.log('done');
}).catch(console.error);

134
cli/migration/7.0.0.js Normal file
View File

@ -0,0 +1,134 @@
const { default: Stats } = require('../../built/models/stats');
const { default: User } = require('../../built/models/user');
const { default: Note } = require('../../built/models/note');
const { default: DriveFile } = require('../../built/models/drive-file');
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const today = new Date(y, m, d);
async function main() {
const localUsersCount = await User.count({
host: null
});
const remoteUsersCount = await User.count({
host: { $ne: null }
});
const localNotesCount = await Note.count({
'_user.host': null
});
const remoteNotesCount = await Note.count({
'_user.host': { $ne: null }
});
const localDriveFilesCount = await DriveFile.count({
'metadata._user.host': null
});
const remoteDriveFilesCount = await DriveFile.count({
'metadata._user.host': { $ne: null }
});
const localDriveFilesSize = await DriveFile
.aggregate([{
$match: {
'metadata._user.host': null,
'metadata.deletedAt': { $exists: false }
}
}, {
$project: {
length: true
}
}, {
$group: {
_id: null,
usage: { $sum: '$length' }
}
}])
.then(aggregates => {
if (aggregates.length > 0) {
return aggregates[0].usage;
}
return 0;
});
const remoteDriveFilesSize = await DriveFile
.aggregate([{
$match: {
'metadata._user.host': { $ne: null },
'metadata.deletedAt': { $exists: false }
}
}, {
$project: {
length: true
}
}, {
$group: {
_id: null,
usage: { $sum: '$length' }
}
}])
.then(aggregates => {
if (aggregates.length > 0) {
return aggregates[0].usage;
}
return 0;
});
await Stats.insert({
date: today,
users: {
local: {
total: localUsersCount,
diff: 0
},
remote: {
total: remoteUsersCount,
diff: 0
}
},
notes: {
local: {
total: localNotesCount,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
},
remote: {
total: remoteNotesCount,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
}
},
drive: {
local: {
totalCount: localDriveFilesCount,
totalSize: localDriveFilesSize,
diffCount: 0,
diffSize: 0
},
remote: {
totalCount: remoteDriveFilesCount,
totalSize: remoteDriveFilesSize,
diffCount: 0,
diffSize: 0
}
}
});
console.log('done');
}
main();

View File

@ -33,14 +33,3 @@ node cli/suspend @syuilo@misskey.xyz
``` shell
node cli/reset-password (User-ID or Username)
```
## Clean up cached remote files
``` shell
node cli/clean-cached-remote-files
```
## Clean up unused drive files
``` shell
node cli/clean-unused-drive-files
```
> We recommend that you announce a user that unused drive files will be deleted before performing this operation, as it may delete the user's important files.

View File

@ -33,14 +33,3 @@ node cli/suspend @syuilo@misskey.xyz
``` shell
node cli/reset-password (ユーザーID または ユーザー名)
```
## キャッシュされたリモートファイルをクリーンアップする
``` shell
node cli/clean-cached-remote-files
```
## 使われていないドライブのファイルをクリーンアップする
``` shell
node cli/clean-unused-drive-files
```
> ユーザーの大事なファイルを削除する可能性があるので、この操作を実行する前にユーザーに告知することをお勧めします。

View File

@ -946,6 +946,11 @@ desktop/views/pages/admin/admin.users-chart.vue:
local: "ローカル"
remote: "リモート"
desktop/views/pages/admin/admin.drive-chart.vue:
title: "ドライブ"
local: "ローカル"
remote: "リモート"
desktop/views/pages/deck/deck.tl-column.vue:
is-media-only: "メディア投稿のみ"
is-media-view: "メディアビュー"

View File

@ -1,7 +1,7 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
"version": "6.4.1",
"version": "7.0.0",
"clientVersion": "1.0.8520",
"codename": "nighthike",
"main": "./built/index.js",

View File

@ -0,0 +1,51 @@
<template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<polyline
:points="points"
fill="none"
stroke-width="1"
stroke="#555"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
chart: {
required: true
},
type: {
type: String,
required: true
}
},
data() {
return {
viewBoxX: 365,
viewBoxY: 70,
points: null
};
},
created() {
const peak = Math.max.apply(null, this.chart.map(d => this.type == 'local' ? d.drive.local.totalSize : d.drive.remote.totalSize));
if (peak != 0) {
const data = this.chart.slice().reverse().map(x => ({
size: this.type == 'local' ? x.drive.local.totalSize : x.drive.remote.totalSize
}));
this.points = data.map((d, i) => `${i},${(1 - (d.size / peak)) * this.viewBoxY}`).join(' ');
}
}
});
</script>
<style lang="stylus" scoped>
svg
display block
padding 10px
width 100%
</style>

View File

@ -0,0 +1,34 @@
<template>
<div class="card">
<header>%i18n:@title%</header>
<div class="card">
<header>%i18n:@local%</header>
<x-chart v-if="chart" :chart="chart" type="local"/>
</div>
<div class="card">
<header>%i18n:@remote%</header>
<x-chart v-if="chart" :chart="chart" type="remote"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import XChart from "./admin.drive-chart.chart.vue";
export default Vue.extend({
components: {
XChart
},
props: {
chart: {
required: true
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
</style>

View File

@ -29,7 +29,7 @@ import Vue from 'vue';
export default Vue.extend({
props: {
data: {
chart: {
required: true
},
type: {
@ -39,7 +39,6 @@ export default Vue.extend({
},
data() {
return {
chart: this.data,
viewBoxX: 365,
viewBoxY: 70,
pointsNote: null,
@ -49,23 +48,19 @@ export default Vue.extend({
};
},
created() {
this.chart.forEach(d => {
d.notes = this.type == 'local' ? d.localNotes : d.remoteNotes;
d.replies = this.type == 'local' ? d.localReplies : d.remoteReplies;
d.renotes = this.type == 'local' ? d.localRenotes : d.remoteRenotes;
});
this.chart.forEach(d => {
d.total = d.notes + d.replies + d.renotes;
});
const peak = Math.max.apply(null, this.chart.map(d => d.total));
const peak = Math.max.apply(null, this.chart.map(d => this.type == 'local' ? d.notes.local.diff : d.notes.remote.diff));
if (peak != 0) {
const data = this.chart.slice().reverse();
this.pointsNote = data.map((d, i) => `${i},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' ');
this.pointsReply = data.map((d, i) => `${i},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' ');
this.pointsRenote = data.map((d, i) => `${i},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' ');
const data = this.chart.slice().reverse().map(x => ({
normal: this.type == 'local' ? x.notes.local.diffs.normal : x.notes.remote.diffs.normal,
reply: this.type == 'local' ? x.notes.local.diffs.reply : x.notes.remote.diffs.reply,
renote: this.type == 'local' ? x.notes.local.diffs.renote : x.notes.remote.diffs.renote,
total: this.type == 'local' ? x.notes.local.diff : x.notes.remote.diff
}));
this.pointsNote = data.map((d, i) => `${i},${(1 - (d.normal / peak)) * this.viewBoxY}`).join(' ');
this.pointsReply = data.map((d, i) => `${i},${(1 - (d.reply / peak)) * this.viewBoxY}`).join(' ');
this.pointsRenote = data.map((d, i) => `${i},${(1 - (d.renote / peak)) * this.viewBoxY}`).join(' ');
this.pointsTotal = data.map((d, i) => `${i},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ');
}
}

View File

@ -3,11 +3,11 @@
<header>%i18n:@title%</header>
<div class="card">
<header>%i18n:@local%</header>
<x-chart v-if="data" :data="data" type="local"/>
<x-chart v-if="chart" :chart="chart" type="local"/>
</div>
<div class="card">
<header>%i18n:@remote%</header>
<x-chart v-if="data" :data="data" type="remote"/>
<x-chart v-if="chart" :chart="chart" type="remote"/>
</div>
</div>
</template>
@ -20,15 +20,10 @@ export default Vue.extend({
components: {
XChart
},
data() {
return {
data: null
};
},
created() {
(this as any).api('aggregation/notes').then(res => {
this.data = res;
});
props: {
chart: {
required: true
}
}
});
</script>

View File

@ -13,7 +13,7 @@ import Vue from 'vue';
export default Vue.extend({
props: {
data: {
chart: {
required: true
},
type: {
@ -23,21 +23,19 @@ export default Vue.extend({
},
data() {
return {
chart: this.data,
viewBoxX: 365,
viewBoxY: 70,
points: null
};
},
created() {
this.chart.forEach(d => {
d.count = this.type == 'local' ? d.local : d.remote;
});
const peak = Math.max.apply(null, this.chart.map(d => d.count));
const peak = Math.max.apply(null, this.chart.map(d => this.type == 'local' ? d.users.local.diff : d.users.remote.diff));
if (peak != 0) {
const data = this.chart.slice().reverse();
const data = this.chart.slice().reverse().map(x => ({
count: this.type == 'local' ? x.users.local.diff : x.users.remote.diff
}));
this.points = data.map((d, i) => `${i},${(1 - (d.count / peak)) * this.viewBoxY}`).join(' ');
}
}

View File

@ -3,11 +3,11 @@
<header>%i18n:@title%</header>
<div class="card">
<header>%i18n:@local%</header>
<x-chart v-if="data" :data="data" type="local"/>
<x-chart v-if="chart" :chart="chart" type="local"/>
</div>
<div class="card">
<header>%i18n:@remote%</header>
<x-chart v-if="data" :data="data" type="remote"/>
<x-chart v-if="chart" :chart="chart" type="remote"/>
</div>
</div>
</template>
@ -20,15 +20,10 @@ export default Vue.extend({
components: {
XChart
},
data() {
return {
data: null
};
},
created() {
(this as any).api('aggregation/users').then(res => {
this.data = res;
});
props: {
chart: {
required: true
}
}
});
</script>

View File

@ -11,8 +11,9 @@
<main>
<div v-show="page == 'dashboard'">
<x-dashboard/>
<x-users-chart/>
<x-notes-chart/>
<x-users-chart :chart="chart"/>
<x-notes-chart :chart="chart"/>
<x-drive-chart :chart="chart"/>
</div>
<div v-if="page == 'users'">
<x-suspend-user/>
@ -35,6 +36,7 @@ import XVerifyUser from "./admin.verify-user.vue";
import XUnverifyUser from "./admin.unverify-user.vue";
import XUsersChart from "./admin.users-chart.vue";
import XNotesChart from "./admin.notes-chart.vue";
import XDriveChart from "./admin.drive-chart.vue";
export default Vue.extend({
components: {
@ -44,13 +46,20 @@ export default Vue.extend({
XVerifyUser,
XUnverifyUser,
XUsersChart,
XNotesChart
XNotesChart,
XDriveChart
},
data() {
return {
page: 'dashboard'
page: 'dashboard',
chart: null
};
},
created() {
(this as any).api('admin/chart').then(chart => {
this.chart = chart;
});
},
methods: {
nav(page: string) {
this.page = page;

View File

@ -1,10 +0,0 @@
@import "../app"
@import "../reset"
html
color #456267
background #fff
body
margin 0
padding 0

View File

@ -1,209 +0,0 @@
<mk-index>
<h1>Misskey<i>Statistics</i></h1>
<main v-if="!initializing">
<mk-users stats={ stats }/>
<mk-notes stats={ stats }/>
</main>
<footer><a href={ _URL_ }>{ _HOST_ }</a></footer>
<style lang="stylus" scoped>
:scope
display block
margin 0 auto
padding 0 16px
max-width 700px
> h1
margin 0
padding 24px 0 0 0
font-size 24px
font-weight normal
> i
font-style normal
color #f43b16
> main
> *
margin 24px 0
padding-top 24px
border-top solid 1px #eee
> h2
margin 0 0 12px 0
font-size 18px
font-weight normal
> footer
margin 24px 0
text-align center
> a
color #546567
</style>
<script lang="typescript">
this.mixin('api');
this.initializing = true;
this.on('mount', () => {
this.$root.$data.os.api('stats').then(stats => {
this.update({
initializing: false,
stats
});
});
});
</script>
</mk-index>
<mk-notes>
<h2>%i18n:stats.notes-count% <b>{ stats.notesCount }</b></h2>
<mk-notes-chart v-if="!initializing" data={ data }/>
<style lang="stylus" scoped>
:scope
display block
</style>
<script lang="typescript">
this.mixin('api');
this.initializing = true;
this.stats = this.opts.stats;
this.on('mount', () => {
this.$root.$data.os.api('aggregation/notes', {
limit: 365
}).then(data => {
this.update({
initializing: false,
data
});
});
});
</script>
</mk-notes>
<mk-users>
<h2>%i18n:stats.users-count% <b>{ stats.usersCount }</b></h2>
<mk-users-chart v-if="!initializing" data={ data }/>
<style lang="stylus" scoped>
:scope
display block
</style>
<script lang="typescript">
this.mixin('api');
this.initializing = true;
this.stats = this.opts.stats;
this.on('mount', () => {
this.$root.$data.os.api('aggregation/users', {
limit: 365
}).then(data => {
this.update({
initializing: false,
data
});
});
});
</script>
</mk-users>
<mk-notes-chart>
<svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none">
<title>Black ... Total<br/>Blue ... Notes<br/>Red ... Replies<br/>Green ... Renotes</title>
<polyline
riot-points={ pointsNote }
fill="none"
stroke-width="1"
stroke="#41ddde"/>
<polyline
riot-points={ pointsReply }
fill="none"
stroke-width="1"
stroke="#f7796c"/>
<polyline
riot-points={ pointsRenote }
fill="none"
stroke-width="1"
stroke="#a1de41"/>
<polyline
riot-points={ pointsTotal }
fill="none"
stroke-width="1"
stroke="#555"
stroke-dasharray="2 2"/>
</svg>
<style lang="stylus" scoped>
:scope
display block
> svg
display block
padding 1px
width 100%
</style>
<script lang="typescript">
this.viewBoxX = 365;
this.viewBoxY = 80;
this.data = this.opts.data.reverse();
this.data.forEach(d => d.total = d.notes + d.replies + d.renotes);
const peak = Math.max.apply(null, this.data.map(d => d.total));
this.on('mount', () => {
this.render();
});
this.render = () => {
this.update({
pointsNote: this.data.map((d, i) => `${i},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' '),
pointsReply: this.data.map((d, i) => `${i},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '),
pointsRenote: this.data.map((d, i) => `${i},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' '),
pointsTotal: this.data.map((d, i) => `${i},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ')
});
};
</script>
</mk-notes-chart>
<mk-users-chart>
<svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none">
<polyline
riot-points={ createdPoints }
fill="none"
stroke-width="1"
stroke="#1cde84"/>
<polyline
riot-points={ totalPoints }
fill="none"
stroke-width="1"
stroke="#555"/>
</svg>
<style lang="stylus" scoped>
:scope
display block
> svg
display block
padding 1px
width 100%
</style>
<script lang="typescript">
this.viewBoxX = 365;
this.viewBoxY = 80;
this.data = this.opts.data.reverse();
const totalPeak = Math.max.apply(null, this.data.map(d => d.total));
const createdPeak = Math.max.apply(null, this.data.map(d => d.created));
this.on('mount', () => {
this.render();
});
this.render = () => {
this.update({
totalPoints: this.data.map((d, i) => `${i},${(1 - (d.total / totalPeak)) * this.viewBoxY}`).join(' '),
createdPoints: this.data.map((d, i) => `${i},${(1 - (d.created / createdPeak)) * this.viewBoxY}`).join(' ')
});
};
</script>
</mk-users-chart>

View File

@ -1 +0,0 @@
require('./index.tag');

View File

@ -1,10 +0,0 @@
@import "../app"
@import "../reset"
html
color #456267
background #fff
body
margin 0
padding 0

View File

@ -1,201 +0,0 @@
<mk-index>
<h1>Misskey<i>Status</i></h1>
<p>%fa:info-circle%%i18n:status.all-systems-maybe-operational%</p>
<main>
<mk-cpu-usage connection={ connection }/>
<mk-mem-usage connection={ connection }/>
</main>
<footer><a href={ _URL_ }>{ _HOST_ }</a></footer>
<style lang="stylus" scoped>
:scope
display block
margin 0 auto
padding 0 16px
max-width 700px
> h1
margin 0
padding 24px 0 16px 0
font-size 24px
font-weight normal
> [data-fa]
font-style normal
color #f43b16
> p
display block
margin 0
padding 12px 16px
background #eaf4ef
//border solid 1px #99ccb2
border-radius 4px
> [data-fa]
margin-right 5px
> main
> *
margin 24px 0
> h2
margin 0 0 12px 0
font-size 18px
font-weight normal
> footer
margin 24px 0
text-align center
> a
color #546567
</style>
<script lang="typescript">
import Connection from '../../common/scripts/streaming/server-stream';
this.mixin('api');
this.initializing = true;
this.connection = new Connection();
this.on('mount', () => {
this.$root.$data.os.api('meta').then(meta => {
this.update({
initializing: false,
meta
});
});
});
this.on('unmount', () => {
this.connection.close();
});
</script>
</mk-index>
<mk-cpu-usage>
<h2>CPU <b>{ percentage }%</b></h2>
<mk-line-chart ref="chart"/>
<style lang="stylus" scoped>
:scope
display block
</style>
<script lang="typescript">
this.connection = this.opts.connection;
this.on('mount', () => {
this.connection.on('stats', this.onStats);
});
this.on('unmount', () => {
this.connection.off('stats', this.onStats);
});
this.onStats = stats => {
this.$refs.chart.addData(1 - stats.cpu_usage);
const percentage = (stats.cpu_usage * 100).toFixed(0);
this.update({
percentage
});
};
</script>
</mk-cpu-usage>
<mk-mem-usage>
<h2>MEM <b>{ percentage }%</b></h2>
<mk-line-chart ref="chart"/>
<style lang="stylus" scoped>
:scope
display block
</style>
<script lang="typescript">
this.connection = this.opts.connection;
this.on('mount', () => {
this.connection.on('stats', this.onStats);
});
this.on('unmount', () => {
this.connection.off('stats', this.onStats);
});
this.onStats = stats => {
stats.mem.used = stats.mem.total - stats.mem.free;
this.$refs.chart.addData(1 - (stats.mem.used / stats.mem.total));
const percentage = (stats.mem.used / stats.mem.total * 100).toFixed(0);
this.update({
percentage
});
};
</script>
</mk-mem-usage>
<mk-line-chart>
<svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none">
<defs>
<linearGradient id={ gradientId } x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stop-color="rgba(244, 59, 22, 0)"></stop>
<stop offset="100%" stop-color="#f43b16"></stop>
</linearGradient>
<mask id={ maskId } x="0" y="0" riot-width={ viewBoxX } riot-height={ viewBoxY }>
<polygon
riot-points={ polygonPoints }
fill="#fff"
fill-opacity="0.5"/>
</mask>
</defs>
<line x1="0" y1="0" riot-x2={ viewBoxX } y2="0" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/>
<line x1="0" y1="25%" riot-x2={ viewBoxX } y2="25%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/>
<line x1="0" y1="50%" riot-x2={ viewBoxX } y2="50%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/>
<line x1="0" y1="75%" riot-x2={ viewBoxX } y2="75%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/>
<line x1="0" y1="100%" riot-x2={ viewBoxX } y2="100%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/>
<rect
x="-1" y="-1"
riot-width={ viewBoxX + 2 } riot-height={ viewBoxY + 2 }
style="stroke: none; fill: url(#{ gradientId }); mask: url(#{ maskId })"/>
<polyline
riot-points={ polylinePoints }
fill="none"
stroke="#f43b16"
stroke-width="0.5"/>
</svg>
<style lang="stylus" scoped>
:scope
display block
padding 16px
border-radius 8px
background #1c2531
> svg
display block
padding 1px
width 100%
</style>
<script lang="typescript">
import uuid from 'uuid';
this.viewBoxX = 100;
this.viewBoxY = 30;
this.data = [];
this.gradientId = uuid();
this.maskId = uuid();
this.addData = data => {
this.data.push(data);
if (this.data.length > 100) this.data.shift();
const polylinePoints = this.data.map((d, i) => `${this.viewBoxX - ((this.data.length - 1) - i)},${d * this.viewBoxY}`).join(' ');
const polygonPoints = `${this.viewBoxX - (this.data.length - 1)},${ this.viewBoxY } ${ polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`;
this.update({
polylinePoints,
polygonPoints
});
};
</script>
</mk-line-chart>

View File

@ -1 +0,0 @@
require('./index.tag');

View File

@ -52,6 +52,11 @@ export type IDriveFile = {
filename: string;
contentType: string;
metadata: IMetadata;
/**
*
*/
length: number;
};
export function validateFileName(name: string): boolean {

153
src/models/stats.ts Normal file
View File

@ -0,0 +1,153 @@
import * as mongo from 'mongodb';
import db from '../db/mongodb';
const Stats = db.get<IStats>('stats');
Stats.createIndex({ date: -1 }, { unique: true });
export default Stats;
export interface IStats {
_id: mongo.ObjectID;
date: Date;
/**
*
*/
users: {
local: {
/**
*
*/
total: number;
/**
*
*/
diff: number;
};
remote: {
/**
*
*/
total: number;
/**
*
*/
diff: number;
};
};
/**
* 稿
*/
notes: {
local: {
/**
* 稿
*/
total: number;
/**
* 稿
*/
diff: number;
diffs: {
/**
* 稿
*/
normal: number;
/**
* 稿
*/
reply: number;
/**
* Renoteの投稿数の前日比
*/
renote: number;
};
};
remote: {
/**
* 稿
*/
total: number;
/**
* 稿
*/
diff: number;
diffs: {
/**
* 稿
*/
normal: number;
/**
* 稿
*/
reply: number;
/**
* Renoteの投稿数の前日比
*/
renote: number;
};
};
};
/**
* ()
*/
drive: {
local: {
/**
*
*/
totalCount: number;
/**
*
*/
totalSize: number;
/**
*
*/
diffCount: number;
/**
*
*/
diffSize: number;
};
remote: {
/**
*
*/
totalCount: number;
/**
*
*/
totalSize: number;
/**
*
*/
diffCount: number;
/**
*
*/
diffSize: number;
};
};
}

View File

@ -0,0 +1,101 @@
import Stats, { IStats } from '../../../../models/stats';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export const meta = {
requireCredential: true,
requireAdmin: true
};
export default (params: any) => new Promise(async (res, rej) => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const stats = await Stats.find({
date: {
$gt: new Date(y - 1, m, d)
}
}, {
sort: {
date: -1
},
fields: {
_id: 0
}
});
const chart: Array<Omit<IStats, '_id'>> = [];
for (let i = 364; i >= 0; i--) {
const day = new Date(y, m, d - i);
const stat = stats.find(s => s.date.getTime() == day.getTime());
if (stat) {
chart.unshift(stat);
} else { // 隙間埋め
const mostRecent = stats.find(s => s.date.getTime() < day.getTime());
if (mostRecent) {
chart.unshift(Object.assign({}, mostRecent, {
date: day
}));
} else {
chart.unshift({
date: day,
users: {
local: {
total: 0,
diff: 0
},
remote: {
total: 0,
diff: 0
}
},
notes: {
local: {
total: 0,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
},
remote: {
total: 0,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
}
},
drive: {
local: {
totalCount: 0,
totalSize: 0,
diffCount: 0,
diffSize: 0
},
remote: {
totalCount: 0,
totalSize: 0,
diffCount: 0,
diffSize: 0
}
}
});
}
}
}
chart.forEach(x => {
delete x.date;
});
res(chart);
});

View File

@ -1,116 +0,0 @@
import Note from '../../../../models/note';
export const meta = {
requireCredential: true,
requireAdmin: true
};
/**
* Aggregate notes
*/
export default (params: any) => new Promise(async (res, rej) => {
const query = [{
$match: {
createdAt: {
$gt: new Date(new Date().setFullYear(new Date().getFullYear() - 1))
}
}
}, {
$project: {
renoteId: '$renoteId',
replyId: '$replyId',
user: '$_user',
createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST
}
}, {
$project: {
date: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' }
},
type: {
$cond: {
if: { $ne: ['$renoteId', null] },
then: 'renote',
else: {
$cond: {
if: { $ne: ['$replyId', null] },
then: 'reply',
else: 'note'
}
}
}
},
origin: {
$cond: {
if: { $eq: ['$user.host', null] },
then: 'local',
else: 'remote'
}
}
}
}, {
$group: {
_id: {
date: '$date',
type: '$type',
origin: '$origin'
},
count: { $sum: 1 }
}
}, {
$group: {
_id: '$_id.date',
data: {
$addToSet: {
type: '$_id.type',
origin: '$_id.origin',
count: '$count'
}
}
}
}] as any;
const datas = await Note.aggregate(query);
datas.forEach((data: any) => {
data.date = data._id;
delete data._id;
data.localNotes = (data.data.filter((x: any) => x.type == 'note' && x.origin == 'local')[0] || { count: 0 }).count;
data.localRenotes = (data.data.filter((x: any) => x.type == 'renote' && x.origin == 'local')[0] || { count: 0 }).count;
data.localReplies = (data.data.filter((x: any) => x.type == 'reply' && x.origin == 'local')[0] || { count: 0 }).count;
data.remoteNotes = (data.data.filter((x: any) => x.type == 'note' && x.origin == 'remote')[0] || { count: 0 }).count;
data.remoteRenotes = (data.data.filter((x: any) => x.type == 'renote' && x.origin == 'remote')[0] || { count: 0 }).count;
data.remoteReplies = (data.data.filter((x: any) => x.type == 'reply' && x.origin == 'remote')[0] || { count: 0 }).count;
delete data.data;
});
const graph = [];
for (let i = 0; i < 365; i++) {
const day = new Date(new Date().setDate(new Date().getDate() - i));
const data = datas.filter((d: any) =>
d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
)[0];
if (data) {
graph.push(data);
} else {
graph.push({
date: { year: day.getFullYear(), month: day.getMonth() + 1, day: day.getDate() },
localNotes: 0,
localRenotes: 0,
localReplies: 0,
remoteNotes: 0,
remoteRenotes: 0,
remoteReplies: 0
});
}
}
res(graph);
});

View File

@ -1,92 +0,0 @@
import User from '../../../../models/user';
export const meta = {
requireCredential: true,
requireAdmin: true
};
/**
* Aggregate users
*/
export default (params: any) => new Promise(async (res, rej) => {
const query = [{
$match: {
createdAt: {
$gt: new Date(new Date().setFullYear(new Date().getFullYear() - 1))
}
}
}, {
$project: {
host: '$host',
createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST
}
}, {
$project: {
date: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' }
},
origin: {
$cond: {
if: { $eq: ['$host', null] },
then: 'local',
else: 'remote'
}
}
}
}, {
$group: {
_id: {
date: '$date',
origin: '$origin'
},
count: { $sum: 1 }
}
}, {
$group: {
_id: '$_id.date',
data: {
$addToSet: {
type: '$_id.type',
origin: '$_id.origin',
count: '$count'
}
}
}
}] as any;
const datas = await User.aggregate(query);
datas.forEach((data: any) => {
data.date = data._id;
delete data._id;
data.local = (data.data.filter((x: any) => x.origin == 'local')[0] || { count: 0 }).count;
data.remote = (data.data.filter((x: any) => x.origin == 'remote')[0] || { count: 0 }).count;
delete data.data;
});
const graph = [];
for (let i = 0; i < 365; i++) {
const day = new Date(new Date().setDate(new Date().getDate() - i));
const data = datas.filter((d: any) =>
d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate()
)[0];
if (data) {
graph.push(data);
} else {
graph.push({
date: { year: day.getFullYear(), month: day.getMonth() + 1, day: day.getDate() },
local: 0,
remote: 0
});
}
}
res(graph);
});

View File

@ -17,6 +17,7 @@ import { isLocalUser, IUser, IRemoteUser } from '../../models/user';
import delFile from './delete-file';
import config from '../../config';
import { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail';
import { updateDriveStats } from '../update-chart';
const log = debug('misskey:drive:add-file');
@ -377,7 +378,8 @@ export default async function(
publishDriveStream(user._id, 'file_created', packedFile);
});
// TODO: サムネイル生成
// 統計を更新
updateDriveStats(driveFile, true);
return driveFile;
}

View File

@ -2,6 +2,7 @@ import * as Minio from 'minio';
import DriveFile, { DriveFileChunk, IDriveFile } from '../../models/drive-file';
import DriveFileThumbnail, { DriveFileThumbnailChunk } from '../../models/drive-file-thumbnail';
import config from '../../config';
import { updateDriveStats } from '../update-chart';
export default async function(file: IDriveFile, isExpired = false) {
if (file.metadata.storage == 'minio') {
@ -45,4 +46,7 @@ export default async function(file: IDriveFile, isExpired = false) {
await DriveFileThumbnail.remove({ _id: thumbnail._id });
}
//#endregion
// 統計を更新
updateDriveStats(file, false);
}

View File

@ -23,6 +23,7 @@ import registerHashtag from '../register-hashtag';
import isQuote from '../../misc/is-quote';
import { TextElementMention } from '../../mfm/parse/elements/mention';
import { TextElementHashtag } from '../../mfm/parse/elements/hashtag';
import { updateNoteStats } from '../update-chart';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -142,6 +143,9 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
return;
}
// 統計を更新
updateNoteStats(note, true);
// ハッシュタグ登録
tags.map(tag => registerHashtag(user, tag));

View File

@ -6,6 +6,7 @@ import pack from '../../remote/activitypub/renderer';
import { deliver } from '../../queue';
import Following from '../../models/following';
import renderNote from '../../remote/activitypub/renderer/note';
import { updateNoteStats } from '../update-chart';
/**
* 稿
@ -43,4 +44,7 @@ export default async function(user: IUser, note: INote) {
});
}
//#endregion
// 統計を更新
updateNoteStats(note, false);
}

View File

@ -0,0 +1,223 @@
import { INote } from '../models/note';
import Stats, { IStats } from '../models/stats';
import { isLocalUser, IUser } from '../models/user';
import { IDriveFile } from '../models/drive-file';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
async function getTodayStats(): Promise<IStats> {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const today = new Date(y, m, d);
// 今日の統計
const todayStats = await Stats.findOne({
date: today
});
// 日付が変わってから、初めてのチャート更新なら
if (todayStats == null) {
// 最も最近の統計を持ってくる
// * 昨日何もチャートを更新するような出来事がなかった場合は、
// 統計がそもそも作られずドキュメントが存在しないということがあり得るため、
// 「昨日の」と決め打ちせずに「もっとも最近の」とします
const mostRecentStats = await Stats.findOne({}, {
sort: {
date: -1
}
});
// 統計が存在しなかったら
// * Misskeyインスタンスを建てて初めてのチャート更新時など
if (mostRecentStats == null) {
// 空の統計を作成
const chart: Omit<IStats, '_id'> = {
date: today,
users: {
local: {
total: 0,
diff: 0
},
remote: {
total: 0,
diff: 0
}
},
notes: {
local: {
total: 0,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
},
remote: {
total: 0,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
}
},
drive: {
local: {
totalCount: 0,
totalSize: 0,
diffCount: 0,
diffSize: 0
},
remote: {
totalCount: 0,
totalSize: 0,
diffCount: 0,
diffSize: 0
}
}
};
const stats = await Stats.insert(chart);
return stats;
} else {
// 今日の統計を初期挿入
const chart: Omit<IStats, '_id'> = {
date: today,
users: {
local: {
total: mostRecentStats.users.local.total,
diff: 0
},
remote: {
total: mostRecentStats.users.remote.total,
diff: 0
}
},
notes: {
local: {
total: mostRecentStats.notes.local.total,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
},
remote: {
total: mostRecentStats.notes.remote.total,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
}
},
drive: {
local: {
totalCount: mostRecentStats.drive.local.totalCount,
totalSize: mostRecentStats.drive.local.totalSize,
diffCount: 0,
diffSize: 0
},
remote: {
totalCount: mostRecentStats.drive.remote.totalCount,
totalSize: mostRecentStats.drive.remote.totalSize,
diffCount: 0,
diffSize: 0
}
}
};
const stats = await Stats.insert(chart);
return stats;
}
} else {
return todayStats;
}
}
async function update(inc: any) {
const stats = await getTodayStats();
await Stats.findOneAndUpdate({
_id: stats._id
}, {
$inc: inc
});
}
export async function updateUserStats(user: IUser, isAdditional: boolean) {
const inc = {} as any;
const amount = isAdditional ? 1 : -1;
if (isLocalUser(user)) {
inc['users.local.total'] = amount;
inc['users.local.diff'] = amount;
} else {
inc['users.remote.total'] = amount;
inc['users.remote.diff'] = amount;
}
await update(inc);
}
export async function updateNoteStats(note: INote, isAdditional: boolean) {
const inc = {} as any;
const amount = isAdditional ? 1 : -1;
if (isLocalUser(note._user)) {
inc['notes.local.total'] = amount;
inc['notes.local.diff'] = amount;
if (note.replyId != null) {
inc['notes.local.diffs.reply'] = amount;
} else if (note.renoteId != null) {
inc['notes.local.diffs.renote'] = amount;
} else {
inc['notes.local.diffs.normal'] = amount;
}
} else {
inc['notes.remote.total'] = amount;
inc['notes.remote.diff'] = amount;
if (note.replyId != null) {
inc['notes.remote.diffs.reply'] = amount;
} else if (note.renoteId != null) {
inc['notes.remote.diffs.renote'] = amount;
} else {
inc['notes.remote.diffs.normal'] = amount;
}
}
await update(inc);
}
export async function updateDriveStats(file: IDriveFile, isAdditional: boolean) {
const inc = {} as any;
const amount = isAdditional ? 1 : -1;
const size = isAdditional ? file.length : -file.length;
if (isLocalUser(file.metadata._user)) {
inc['drive.local.totalCount'] = amount;
inc['drive.local.diffCount'] = amount;
inc['drive.local.totalSize'] = size;
inc['drive.local.diffSize'] = size;
} else {
inc['drive.remote.total'] = amount;
inc['drive.remote.diff'] = amount;
inc['drive.remote.totalSize'] = size;
inc['drive.remote.diffSize'] = size;
}
await update(inc);
}