summaryrefslogtreecommitdiff
path: root/src/client/pages/instance
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-01-30 04:37:25 +0900
committerGitHub <noreply@github.com>2020-01-30 04:37:25 +0900
commitf6154dc0af1a0d65819e87240f4385f9573095cb (patch)
tree699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/pages/instance
parentAdd Event activity-type support (#5785) (diff)
downloadmisskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz
misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2
misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client/pages/instance')
-rw-r--r--src/client/pages/instance/announcements.vue129
-rw-r--r--src/client/pages/instance/emojis.vue253
-rw-r--r--src/client/pages/instance/federation.instance.vue576
-rw-r--r--src/client/pages/instance/federation.vue165
-rw-r--r--src/client/pages/instance/files.vue54
-rw-r--r--src/client/pages/instance/index.vue393
-rw-r--r--src/client/pages/instance/monitor.vue381
-rw-r--r--src/client/pages/instance/queue.queue.vue204
-rw-r--r--src/client/pages/instance/queue.vue79
-rw-r--r--src/client/pages/instance/stats.vue491
-rw-r--r--src/client/pages/instance/users.vue203
11 files changed, 2928 insertions, 0 deletions
diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue
new file mode 100644
index 0000000000..71cec64c7b
--- /dev/null
+++ b/src/client/pages/instance/announcements.vue
@@ -0,0 +1,129 @@
+<template>
+<div class="ztgjmzrw">
+ <portal to="icon"><fa :icon="faBroadcastTower"/></portal>
+ <portal to="title">{{ $t('announcements') }}</portal>
+ <mk-button @click="add()" primary style="margin: 0 auto 16px auto;"><fa :icon="faPlus"/> {{ $t('add') }}</mk-button>
+ <section class="_section announcements">
+ <div class="_content announcement" v-for="announcement in announcements">
+ <mk-input v-model="announcement.title" style="margin-top: 8px;">
+ <span>{{ $t('title') }}</span>
+ </mk-input>
+ <mk-textarea v-model="announcement.text">
+ <span>{{ $t('text') }}</span>
+ </mk-textarea>
+ <mk-input v-model="announcement.imageUrl">
+ <span>{{ $t('imageUrl') }}</span>
+ </mk-input>
+ <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <mk-button class="button" inline @click="save(announcement)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ <mk-button class="button" inline @click="remove(announcement)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('announcements') as string
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ },
+
+ data() {
+ return {
+ announcements: [],
+ faBroadcastTower, faSave, faTrashAlt, faPlus
+ }
+ },
+
+ created() {
+ this.$root.api('admin/announcements/list').then(announcements => {
+ this.announcements = announcements;
+ });
+ },
+
+ methods: {
+ add() {
+ this.announcements.unshift({
+ id: null,
+ title: '',
+ text: '',
+ imageUrl: null
+ });
+ },
+
+ remove(announcement) {
+ this.$root.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: announcement.title }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.announcements = this.announcements.filter(x => x != announcement);
+ this.$root.api('admin/announcements/delete', announcement);
+ });
+ },
+
+ save(announcement) {
+ if (announcement.id == null) {
+ this.$root.api('admin/announcements/create', announcement).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('saved')
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ } else {
+ this.$root.api('admin/announcements/update', announcement).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('saved')
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+ > .announcements {
+ > .announcement {
+ > .buttons {
+ > .button:first-child {
+ margin-right: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue
new file mode 100644
index 0000000000..7a69a7efe6
--- /dev/null
+++ b/src/client/pages/instance/emojis.vue
@@ -0,0 +1,253 @@
+<template>
+<div class="mk-instance-emojis">
+ <portal to="icon"><fa :icon="faLaugh"/></portal>
+ <portal to="title">{{ $t('customEmojis') }}</portal>
+ <section class="_section local">
+ <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div>
+ <div class="_content">
+ <input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
+ <mk-pagination :pagination="pagination" class="emojis" ref="emojis">
+ <template #empty><span>{{ $t('noCustomEmojis') }}</span></template>
+ <template #default="{items}">
+ <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selected = emoji" :class="{ selected: selected && (selected.id === emoji.id) }">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <span class="name">{{ emoji.name }}</span>
+ </div>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary @click="add()"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button>
+ <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
+ </div>
+ </section>
+ <section class="_section remote">
+ <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div>
+ <div class="_content">
+ <mk-input v-model="host" :debounce="true" style="margin-top: 0;"><span>{{ $t('host') }}</span></mk-input>
+ <mk-pagination :pagination="remotePagination" class="emojis" ref="remoteEmojis">
+ <template #empty><span>{{ $t('noCustomEmojis') }}</span></template>
+ <template #default="{items}">
+ <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selectedRemote = emoji" :class="{ selected: selectedRemote && (selectedRemote.id === emoji.id) }">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <span class="name">{{ emoji.name }}</span>
+ <span class="host">{{ emoji.host }}</span>
+ </div>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary :disabled="selectedRemote == null" @click="im()"><fa :icon="faPlus"/> {{ $t('import') }}</mk-button>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt, faLaugh } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import { apiUrl } from '../../config';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: `${this.$t('customEmojis')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ name: null,
+ selected: null,
+ selectedRemote: null,
+ host: '',
+ pagination: {
+ endpoint: 'admin/emoji/list',
+ limit: 10,
+ },
+ remotePagination: {
+ endpoint: 'admin/emoji/list-remote',
+ limit: 10,
+ params: () => ({
+ host: this.host ? this.host : null
+ })
+ },
+ faTrashAlt, faPlus, faLaugh
+ }
+ },
+
+ watch: {
+ host() {
+ this.$refs.remoteEmojis.reload();
+ }
+ },
+
+ methods: {
+ async add() {
+ const { canceled: canceled, result: name } = await this.$root.dialog({
+ title: this.$t('emojiName'),
+ input: true
+ });
+ if (canceled) return;
+
+ this.name = name;
+
+ (this.$refs.file as any).click();
+ },
+
+ onChangeFile() {
+ const [file] = Array.from((this.$refs.file as any).files);
+ if (file == null) return;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('name', this.name);
+ data.append('i', this.$store.state.i.token);
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ text: this.$t('uploading') + '...',
+ showOkButton: false,
+ showCancelButton: false,
+ cancelableByBgClick: false
+ });
+
+ fetch(apiUrl + '/admin/emoji/add', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.$refs.emojis.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ })
+ .finally(() => {
+ dialog.close();
+ });
+ },
+
+ async del() {
+ const { canceled } = await this.$root.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.selected.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ this.$root.api('admin/emoji/remove', {
+ id: this.selected.id
+ }).then(() => {
+ this.$refs.emojis.reload();
+ });
+ },
+
+ im() {
+ this.$root.api('admin/emoji/copy', {
+ emojiId: this.selectedRemote.id,
+ }).then(() => {
+ this.$refs.emojis.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-emojis {
+ > .local {
+ > ._content {
+ max-height: 300px;
+ overflow: auto;
+
+ > .emojis {
+ > .emoji {
+ display: flex;
+ align-items: center;
+
+ &.selected {
+ background: var(--accent);
+ box-shadow: 0 0 0 8px var(--accent);
+ color: #fff;
+ }
+
+ > .img {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ padding: 8px;
+
+ > .name {
+ display: block;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ > .remote {
+ > ._content {
+ max-height: 300px;
+ overflow: auto;
+
+ > .emojis {
+ > .emoji {
+ display: flex;
+ align-items: center;
+
+ &.selected {
+ background: var(--accent);
+ box-shadow: 0 0 0 8px var(--accent);
+ color: #fff;
+ }
+
+ > .img {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .body {
+ padding: 0 8px;
+
+ > .name {
+ display: block;
+ }
+
+ > .host {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/federation.instance.vue b/src/client/pages/instance/federation.instance.vue
new file mode 100644
index 0000000000..a27556064a
--- /dev/null
+++ b/src/client/pages/instance/federation.instance.vue
@@ -0,0 +1,576 @@
+<template>
+<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true">
+ <template #header>{{ instance.host }}</template>
+ <div class="mk-instance-info">
+ <div class="table info">
+ <div class="row">
+ <div class="cell">
+ <div class="label">{{ $t('software') }}</div>
+ <div class="data">{{ instance.softwareName || '?' }}</div>
+ </div>
+ <div class="cell">
+ <div class="label">{{ $t('version') }}</div>
+ <div class="data">{{ instance.softwareVersion || '?' }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="table data">
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faCrosshairs" fixed-width class="icon"/>{{ $t('registeredAt') }}</div>
+ <div class="data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faCloudDownloadAlt" fixed-width class="icon"/>{{ $t('following') }}</div>
+ <div class="data clickable" @click="showFollowing()">{{ instance.followingCount | number }}</div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faCloudUploadAlt" fixed-width class="icon"/>{{ $t('followers') }}</div>
+ <div class="data clickable" @click="showFollowers()">{{ instance.followersCount | number }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faUsers" fixed-width class="icon"/>{{ $t('users') }}</div>
+ <div class="data clickable" @click="showUsers()">{{ instance.usersCount | number }}</div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faPencilAlt" fixed-width class="icon"/>{{ $t('notes') }}</div>
+ <div class="data">{{ instance.notesCount | number }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faFileImage" fixed-width class="icon"/>{{ $t('files') }}</div>
+ <div class="data">{{ instance.driveFiles | number }}</div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faDatabase" fixed-width class="icon"/>{{ $t('storageUsage') }}</div>
+ <div class="data">{{ instance.driveUsage | bytes }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faLongArrowAltUp" fixed-width class="icon"/>{{ $t('latestRequestSentAt') }}</div>
+ <div class="data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faTrafficLight" fixed-width class="icon"/>{{ $t('latestStatus') }}</div>
+ <div class="data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faLongArrowAltDown" fixed-width class="icon"/>{{ $t('latestRequestReceivedAt') }}</div>
+ <div class="data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ </div>
+ <div class="chart">
+ <div class="header">
+ <span class="label">{{ $t('charts') }}</span>
+ <div class="selects">
+ <mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="requests">{{ $t('_instanceCharts.requests') }}</option>
+ <option value="users">{{ $t('_instanceCharts.users') }}</option>
+ <option value="users-total">{{ $t('_instanceCharts.usersTotal') }}</option>
+ <option value="notes">{{ $t('_instanceCharts.notes') }}</option>
+ <option value="notes-total">{{ $t('_instanceCharts.notesTotal') }}</option>
+ <option value="ff">{{ $t('_instanceCharts.ff') }}</option>
+ <option value="ff-total">{{ $t('_instanceCharts.ffTotal') }}</option>
+ <option value="drive-usage">{{ $t('_instanceCharts.cacheSize') }}</option>
+ <option value="drive-usage-total">{{ $t('_instanceCharts.cacheSizeTotal') }}</option>
+ <option value="drive-files">{{ $t('_instanceCharts.files') }}</option>
+ <option value="drive-files-total">{{ $t('_instanceCharts.filesTotal') }}</option>
+ </mk-select>
+ <mk-select v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $t('perHour') }}</option>
+ <option value="day">{{ $t('perDay') }}</option>
+ </mk-select>
+ </div>
+ </div>
+ <div class="chart">
+ <canvas ref="chart"></canvas>
+ </div>
+ </div>
+ <div class="operations">
+ <span class="label">{{ $t('operations') }}</span>
+ <mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch>
+ <mk-switch v-model="isBlocked" class="switch">{{ $t('blockThisInstance') }}</mk-switch>
+ </div>
+ <details class="metadata">
+ <summary class="label">{{ $t('metadata') }}</summary>
+ <pre><code>{{ JSON.stringify(instance.metadata, null, 2) }}</code></pre>
+ </details>
+ </div>
+</x-window>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown } from '@fortawesome/free-solid-svg-icons';
+import XWindow from '../../components/window.vue';
+import MkUsersDialog from '../../components/users-dialog.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+
+const chartLimit = 90;
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+const alpha = hex => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, 0.1)`;
+};
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XWindow,
+ MkSelect,
+ MkSwitch,
+ },
+
+ props: {
+ instance: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ meta: null,
+ isSuspended: false,
+ isBlocked: false,
+ now: null,
+ chart: null,
+ chartInstance: null,
+ chartSrc: 'requests',
+ chartSpan: 'hour',
+ faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown
+ };
+ },
+
+ computed: {
+ data(): any {
+ if (this.chart == null) return null;
+ switch (this.chartSrc) {
+ case 'requests': return this.requestsChart();
+ case 'users': return this.usersChart(false);
+ case 'users-total': return this.usersChart(true);
+ case 'notes': return this.notesChart(false);
+ case 'notes-total': return this.notesChart(true);
+ case 'ff': return this.ffChart(false);
+ case 'ff-total': return this.ffChart(true);
+ case 'drive-usage': return this.driveUsageChart(false);
+ case 'drive-usage-total': return this.driveUsageChart(true);
+ case 'drive-files': return this.driveFilesChart(false);
+ case 'drive-files-total': return this.driveFilesChart(true);
+ }
+ },
+
+ stats(): any[] {
+ const stats =
+ this.chartSpan == 'day' ? this.chart.perDay :
+ this.chartSpan == 'hour' ? this.chart.perHour :
+ null;
+
+ return stats;
+ }
+ },
+
+ watch: {
+ isSuspended() {
+ this.$root.api('admin/federation/update-instance', {
+ host: this.instance.host,
+ isSuspended: this.isSuspended
+ });
+ },
+
+ isBlocked() {
+ this.$root.api('admin/update-meta', {
+ blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
+ });
+ },
+
+ chartSrc() {
+ this.renderChart();
+ },
+
+ chartSpan() {
+ this.renderChart();
+ }
+ },
+
+ async created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ this.isSuspended = this.instance.isSuspended;
+ this.isBlocked = this.meta.blockedHosts.includes(this.instance.host);
+ });
+
+ this.now = new Date();
+
+ const [perHour, perDay] = await Promise.all([
+ this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
+ ]);
+
+ const chart = {
+ perHour: perHour,
+ perDay: perDay
+ };
+
+ this.chart = chart;
+
+ this.renderChart();
+ },
+
+ methods: {
+ setSrc(src) {
+ this.chartSrc = src;
+ },
+
+ renderChart() {
+ if (this.chartInstance) {
+ this.chartInstance.destroy();
+ }
+
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+ this.chartInstance = new Chart(this.$refs.chart, {
+ type: 'line',
+ data: {
+ labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
+ datasets: this.data.series.map(x => ({
+ label: x.name,
+ data: x.data.slice().reverse(),
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: x.color,
+ backgroundColor: alpha(x.color),
+ }))
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+ },
+
+ getDate(ago: number) {
+ const y = this.now.getFullYear();
+ const m = this.now.getMonth();
+ const d = this.now.getDate();
+ const h = this.now.getHours();
+
+ return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+ },
+
+ format(arr) {
+ return arr;
+ },
+
+ requestsChart(): any {
+ return {
+ series: [{
+ name: 'In',
+ color: '#008FFB',
+ data: this.format(this.stats.requests.received)
+ }, {
+ name: 'Out (succ)',
+ color: '#00E396',
+ data: this.format(this.stats.requests.succeeded)
+ }, {
+ name: 'Out (fail)',
+ color: '#FEB019',
+ data: this.format(this.stats.requests.failed)
+ }]
+ };
+ },
+
+ usersChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Users',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.users.total
+ : sum(this.stats.users.inc, negate(this.stats.users.dec))
+ )
+ }]
+ };
+ },
+
+ notesChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Notes',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.notes.total
+ : sum(this.stats.notes.inc, negate(this.stats.notes.dec))
+ )
+ }]
+ };
+ },
+
+ ffChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Following',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.following.total
+ : sum(this.stats.following.inc, negate(this.stats.following.dec))
+ )
+ }, {
+ name: 'Followers',
+ color: '#00E396',
+ data: this.format(total
+ ? this.stats.followers.total
+ : sum(this.stats.followers.inc, negate(this.stats.followers.dec))
+ )
+ }]
+ };
+ },
+
+ driveUsageChart(total: boolean): any {
+ return {
+ bytes: true,
+ series: [{
+ name: 'Drive usage',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.drive.totalUsage
+ : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
+ )
+ }]
+ };
+ },
+
+ driveFilesChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Drive files',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.drive.totalFiles
+ : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
+ )
+ }]
+ };
+ },
+
+ showFollowing() {
+ this.$root.new(MkUsersDialog, {
+ title: this.$t('instanceFollowing'),
+ pagination: {
+ endpoint: 'federation/following',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ },
+ extract: item => item.follower
+ });
+ },
+
+ showFollowers() {
+ this.$root.new(MkUsersDialog, {
+ title: this.$t('instanceFollowers'),
+ pagination: {
+ endpoint: 'federation/followers',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ },
+ extract: item => item.followee
+ });
+ },
+
+ showUsers() {
+ this.$root.new(MkUsersDialog, {
+ title: this.$t('instanceUsers'),
+ pagination: {
+ endpoint: 'federation/users',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ }
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-info {
+ overflow: auto;
+
+ > .table {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .row {
+ display: flex;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > .cell {
+ flex: 1;
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+
+ > .icon {
+ margin-right: 4px;
+ display: none;
+ }
+ }
+
+ > .data.clickable {
+ color: var(--accent);
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ > .data {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ margin-top: 8px;
+ padding-top: 8px;
+ }
+ }
+
+ > .chart {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ margin-top: 8px;
+ padding-top: 8px;
+ }
+
+ > .header {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .selects {
+ display: flex;
+ }
+ }
+
+ > .chart {
+ padding: 0 16px;
+
+ @media (max-width: 500px) {
+ padding: 0;
+ }
+ }
+ }
+
+ > .operations {
+ padding: 16px 32px 16px 32px;
+ margin-top: 8px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ padding: 8px 16px 8px 16px;
+ margin-top: 0;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .switch {
+ margin: 16px 0;
+ }
+ }
+
+ > .metadata {
+ padding: 16px 32px 16px 32px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ padding: 8px 16px 8px 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > pre > code {
+ display: block;
+ max-height: 200px;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue
new file mode 100644
index 0000000000..224ff72a9f
--- /dev/null
+++ b/src/client/pages/instance/federation.vue
@@ -0,0 +1,165 @@
+<template>
+<div class="mk-federation">
+ <section class="_section instances">
+ <div class="_title"><fa :icon="faGlobe"/> {{ $t('instances') }}</div>
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <mk-input v-model="host" :debounce="true" style="margin: 0; flex: 1;"><span>{{ $t('host') }}</span></mk-input>
+ <mk-select v-model="state" style="margin: 0;">
+ <option value="all">{{ $t('all') }}</option>
+ <option value="federating">{{ $t('federating') }}</option>
+ <option value="subscribing">{{ $t('subscribing') }}</option>
+ <option value="publishing">{{ $t('publishing') }}</option>
+ <option value="suspended">{{ $t('suspended') }}</option>
+ <option value="blocked">{{ $t('blocked') }}</option>
+ <option value="notResponding">{{ $t('notResponding') }}</option>
+ </mk-select>
+ </div>
+ </div>
+ <div class="_content">
+ <mk-pagination :pagination="pagination" #default="{items}" class="instances" ref="instances" :key="host + state">
+ <div class="instance" v-for="(instance, i) in items" :key="instance.id" :data-index="i" @click="info(instance)">
+ <div class="host"><fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div>
+ <div class="status">
+ <span class="sub" v-if="instance.followersCount > 0"><fa :icon="faCaretDown" class="icon"/>Sub</span>
+ <span class="sub" v-else><fa :icon="faCaretDown" class="icon"/>-</span>
+ <span class="pub" v-if="instance.followingCount > 0"><fa :icon="faCaretUp" class="icon"/>Pub</span>
+ <span class="pub" v-else><fa :icon="faCaretUp" class="icon"/>-</span>
+ <span class="lastCommunicatedAt"><fa :icon="faExchangeAlt" class="icon"/><mk-time :time="instance.lastCommunicatedAt"/></span>
+ <span class="latestStatus"><fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span>
+ </div>
+ </div>
+ </mk-pagination>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import MkInstanceInfo from './federation.instance.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('federation') as string
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ host: '',
+ state: 'federating',
+ sort: '+pubSub',
+ pagination: {
+ endpoint: 'federation/instances',
+ limit: 10,
+ offsetMode: true,
+ params: () => ({
+ sort: this.sort,
+ host: this.host != '' ? this.host : null,
+ ...(
+ this.state === 'federating' ? { federating: true } :
+ this.state === 'subscribing' ? { subscribing: true } :
+ this.state === 'publishing' ? { publishing: true } :
+ this.state === 'suspended' ? { suspended: true } :
+ this.state === 'blocked' ? { blocked: true } :
+ this.state === 'notResponding' ? { notResponding: true } :
+ {})
+ })
+ },
+ faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight
+ }
+ },
+
+ watch: {
+ host() {
+ this.$refs.instances.reload();
+ },
+ state() {
+ this.$refs.instances.reload();
+ }
+ },
+
+ methods: {
+ getStatus(instance) {
+ if (instance.isSuspended) return 'off';
+ if (instance.isNotResponding) return 'red';
+ return 'green';
+ },
+
+ info(instance) {
+ this.$root.new(MkInstanceInfo, {
+ instance: instance
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-federation {
+ > .instances {
+ > ._content {
+ > .instances {
+ > .instance {
+ cursor: pointer;
+
+ > .host {
+ > .indicator {
+ font-size: 70%;
+ vertical-align: baseline;
+ margin-right: 4px;
+
+ &.green {
+ color: #49c5ba;
+ }
+
+ &.yellow {
+ color: #c5a549;
+ }
+
+ &.red {
+ color: #c54949;
+ }
+
+ &.off {
+ color: rgba(0, 0, 0, 0.5);
+ }
+ }
+ }
+
+ > .status {
+ display: flex;
+ align-items: center;
+ font-size: 90%;
+
+ > span {
+ flex: 1;
+
+ > .icon {
+ margin-right: 6px;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue
new file mode 100644
index 0000000000..e7475e94c1
--- /dev/null
+++ b/src/client/pages/instance/files.vue
@@ -0,0 +1,54 @@
+<template>
+<section class="_section">
+ <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
+ <div class="_content">
+ <mk-button primary @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCloud } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: `${this.$t('files')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ faTrashAlt, faCloud
+ }
+ },
+
+ methods: {
+ clear() {
+ this.$root.dialog({
+ type: 'warning',
+ text: this.$t('clearCachedFilesConfirm'),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ this.$root.api('admin/drive/clean-remote-files', {}).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue
new file mode 100644
index 0000000000..5301fc7e01
--- /dev/null
+++ b/src/client/pages/instance/index.vue
@@ -0,0 +1,393 @@
+<template>
+<div v-if="meta" class="mk-instance-page">
+ <portal to="icon"><fa :icon="faServer"/></portal>
+ <portal to="title">{{ $t('instance') }}</portal>
+
+ <section class="_section info">
+ <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div>
+ <div class="_content">
+ <mk-input v-model="name" style="margin-top: 8px;">{{ $t('instanceName') }}</mk-input>
+ <mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea>
+ <mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input>
+ <mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input>
+ <mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input>
+ <mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input>
+ <mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section info">
+ <div class="_content">
+ <mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch>
+ <mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch>
+ <mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info>
+ </div>
+ </section>
+
+ <section class="_section info">
+ <div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div>
+ <div class="_content">
+ <mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch>
+ <mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
+ <div class="_content">
+ <mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
+ <template v-if="enableRecaptcha">
+ <mk-info>{{ $t('recaptcha-info') }}</mk-info>
+ <mk-info warn>{{ $t('recaptcha-info2') }}</mk-info>
+ <mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
+ <mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
+ </template>
+ </div>
+ <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
+ <header>{{ $t('preview') }}</header>
+ <div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div>
+ <div class="_content">
+ <mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworker-info') }}</template></mk-switch>
+ <template v-if="enableServiceWorker">
+ <mk-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></mk-info>
+ <mk-horizon-group inputs class="fit-bottom">
+ <mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-publickey') }}</mk-input>
+ <mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-privatekey') }}</mk-input>
+ </mk-horizon-group>
+ </template>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div>
+ <div class="_content">
+ <mk-textarea v-model="pinnedUsers" style="margin-top: 0;">
+ <template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template>
+ </mk-textarea>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
+ <div class="_content">
+ <mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch>
+ <mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch>
+ <mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
+ <mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div>
+ <div class="_content">
+ <mk-input v-model="proxyAccount" style="margin: 0;"><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div>
+ <div class="_content">
+ <mk-textarea v-model="blockedHosts" style="margin-top: 0;">
+ <template #desc>{{ $t('blockedInstancesDescription') }}</template>
+ </mk-textarea>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
+ <div class="_content">
+ <header><fa :icon="faTwitter"/> {{ $t('twitter-integration-config') }}</header>
+ <mk-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</mk-switch>
+ <template v-if="enableTwitterIntegration">
+ <mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-key') }}</mk-input>
+ <mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-secret') }}</mk-input>
+ <mk-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</mk-info>
+ </template>
+ </div>
+ <div class="_content">
+ <header><fa :icon="faGithub"/> {{ $t('github-integration-config') }}</header>
+ <mk-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</mk-switch>
+ <template v-if="enableGithubIntegration">
+ <mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-id') }}</mk-input>
+ <mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-secret') }}</mk-input>
+ <mk-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</mk-info>
+ </template>
+ </div>
+ <div class="_content">
+ <header><fa :icon="faDiscord"/> {{ $t('discord-integration-config') }}</header>
+ <mk-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</mk-switch>
+ <template v-if="enableDiscordIntegration">
+ <mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-id') }}</mk-input>
+ <mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-secret') }}</mk-input>
+ <mk-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</mk-info>
+ </template>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section info">
+ <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div>
+ <div class="_content table" v-if="stats">
+ <div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
+ <div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
+ </div>
+ <div class="_content table">
+ <div><b>Misskey</b><span>v{{ version }}</span></div>
+ </div>
+ <div class="_content table" v-if="serverInfo">
+ <div><b>Node.js</b><span>{{ serverInfo.node }}</span></div>
+ <div><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div>
+ <div><b>Redis</b><span>v{{ serverInfo.redis }}</span></div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons';
+import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import MkInfo from '../../components/ui/info.vue';
+import MkUserSelect from '../../components/user-select.vue';
+import { version } from '../../config';
+import i18n from '../../i18n';
+import getAcct from '../../../misc/acct/render';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('instance') as string
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ MkInfo,
+ },
+
+ data() {
+ return {
+ version,
+ meta: null,
+ stats: null,
+ serverInfo: null,
+ proxyAccount: null,
+ cacheRemoteFiles: false,
+ proxyRemoteFiles: false,
+ localDriveCapacityMb: 0,
+ remoteDriveCapacityMb: 0,
+ blockedHosts: '',
+ pinnedUsers: '',
+ maintainerName: null,
+ maintainerEmail: null,
+ name: null,
+ description: null,
+ tosUrl: null,
+ bannerUrl: null,
+ iconUrl: null,
+ enableRegistration: false,
+ enableLocalTimeline: false,
+ enableGlobalTimeline: false,
+ enableRecaptcha: false,
+ recaptchaSiteKey: null,
+ recaptchaSecretKey: null,
+ enableServiceWorker: false,
+ swPublicKey: null,
+ swPrivateKey: null,
+ enableTwitterIntegration: false,
+ twitterConsumerKey: null,
+ twitterConsumerSecret: null,
+ enableGithubIntegration: false,
+ githubClientId: null,
+ githubClientSecret: null,
+ enableDiscordIntegration: false,
+ discordClientId: null,
+ discordClientSecret: null,
+ faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt
+ }
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ this.name = this.meta.name;
+ this.description = this.meta.description;
+ this.tosUrl = this.meta.tosUrl;
+ this.bannerUrl = this.meta.bannerUrl;
+ this.iconUrl = this.meta.iconUrl;
+ this.maintainerName = this.meta.maintainerName;
+ this.maintainerEmail = this.meta.maintainerEmail;
+ this.enableRegistration = !this.meta.disableRegistration;
+ this.enableLocalTimeline = !this.meta.disableLocalTimeline;
+ this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
+ this.enableRecaptcha = this.meta.enableRecaptcha;
+ this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
+ this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
+ this.proxyAccount = this.meta.proxyAccount;
+ this.cacheRemoteFiles = this.meta.cacheRemoteFiles;
+ this.proxyRemoteFiles = this.meta.proxyRemoteFiles;
+ this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb;
+ this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb;
+ this.blockedHosts = this.meta.blockedHosts.join('\n');
+ this.pinnedUsers = this.meta.pinnedUsers.join('\n');
+ this.enableServiceWorker = this.meta.enableServiceWorker;
+ this.swPublicKey = this.meta.swPublickey;
+ this.swPrivateKey = this.meta.swPrivateKey;
+ this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
+ this.twitterConsumerKey = this.meta.twitterConsumerKey;
+ this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
+ this.enableGithubIntegration = this.meta.enableGithubIntegration;
+ this.githubClientId = this.meta.githubClientId;
+ this.githubClientSecret = this.meta.githubClientSecret;
+ this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
+ this.discordClientId = this.meta.discordClientId;
+ this.discordClientSecret = this.meta.discordClientSecret;
+ });
+
+ this.$root.api('admin/server-info').then(res => {
+ this.serverInfo = res;
+ });
+
+ this.$root.api('stats').then(res => {
+ this.stats = res;
+ });
+ },
+
+ mounted() {
+ const renderRecaptchaPreview = () => {
+ if (!(window as any).grecaptcha) return;
+ if (!this.$refs.recaptcha) return;
+ if (!this.recaptchaSiteKey) return;
+ (window as any).grecaptcha.render(this.$refs.recaptcha, {
+ sitekey: this.recaptchaSiteKey
+ });
+ };
+ window.onRecaotchaLoad = () => {
+ renderRecaptchaPreview();
+ };
+ const head = document.getElementsByTagName('head')[0];
+ const script = document.createElement('script');
+ script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
+ head.appendChild(script);
+ this.$watch('enableRecaptcha', () => {
+ renderRecaptchaPreview();
+ });
+ this.$watch('recaptchaSiteKey', () => {
+ renderRecaptchaPreview();
+ });
+ },
+
+ methods: {
+ addPinUser() {
+ this.$root.new(MkUserSelect, {}).$once('selected', user => {
+ this.pinnedUsers = this.pinnedUsers.trim();
+ this.pinnedUsers += '\n@' + getAcct(user);
+ this.pinnedUsers = this.pinnedUsers.trim();
+ });
+ },
+
+ save(withDialog = false) {
+ this.$root.api('admin/update-meta', {
+ name: this.name,
+ description: this.description,
+ tosUrl: this.tosUrl,
+ bannerUrl: this.bannerUrl,
+ iconUrl: this.iconUrl,
+ maintainerName: this.maintainerName,
+ maintainerEmail: this.maintainerEmail,
+ disableRegistration: !this.enableRegistration,
+ disableLocalTimeline: !this.enableLocalTimeline,
+ disableGlobalTimeline: !this.enableGlobalTimeline,
+ enableRecaptcha: this.enableRecaptcha,
+ recaptchaSiteKey: this.recaptchaSiteKey,
+ recaptchaSecretKey: this.recaptchaSecretKey,
+ proxyAccount: this.proxyAccount,
+ cacheRemoteFiles: this.cacheRemoteFiles,
+ proxyRemoteFiles: this.proxyRemoteFiles,
+ localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
+ remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
+ blockedHosts: this.blockedHosts.split('\n') || [],
+ pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
+ enableServiceWorker: this.enableServiceWorker,
+ swPublicKey: this.swPublicKey,
+ swPrivateKey: this.swPrivateKey,
+ enableTwitterIntegration: this.enableTwitterIntegration,
+ twitterConsumerKey: this.twitterConsumerKey,
+ twitterConsumerSecret: this.twitterConsumerSecret,
+ enableGithubIntegration: this.enableGithubIntegration,
+ githubClientId: this.githubClientId,
+ githubClientSecret: this.githubClientSecret,
+ enableDiscordIntegration: this.enableDiscordIntegration,
+ discordClientId: this.discordClientId,
+ discordClientSecret: this.discordClientSecret,
+ }).then(() => {
+ if (withDialog) {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-page {
+ > .info {
+ > .table {
+ > div {
+ display: flex;
+
+ > * {
+ flex: 1;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/monitor.vue b/src/client/pages/instance/monitor.vue
new file mode 100644
index 0000000000..3f3ce6d73a
--- /dev/null
+++ b/src/client/pages/instance/monitor.vue
@@ -0,0 +1,381 @@
+<template>
+<div class="mk-instance-monitor">
+ <section class="_section">
+ <div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <canvas ref="cpumem"></canvas>
+ </div>
+ <div class="_content" v-if="serverInfo">
+ <div class="table">
+ <div class="row">
+ <div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div>
+ </div>
+ <div class="row">
+ <div class="cell"><div class="label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div>
+ <div class="cell"><div class="label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ <div class="cell"><div class="label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section class="_section">
+ <div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <canvas ref="disk"></canvas>
+ </div>
+ <div class="_content" v-if="serverInfo">
+ <div class="table">
+ <div class="row">
+ <div class="cell"><div class="label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div>
+ <div class="cell"><div class="label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ <div class="cell"><div class="label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section class="_section">
+ <div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <canvas ref="net"></canvas>
+ </div>
+ <div class="_content" v-if="serverInfo">
+ <div class="table">
+ <div class="row">
+ <div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div>
+ </div>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faTachometerAlt, faExchangeAlt, faMicrochip, faHdd } from '@fortawesome/free-solid-svg-icons';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: `${this.$t('monitor')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ },
+
+ data() {
+ return {
+ connection: null,
+ serverInfo: null,
+ memUsage: 0,
+ chartCpuMem: null,
+ chartNet: null,
+ faTachometerAlt, faExchangeAlt, faMicrochip, faHdd
+ }
+ },
+
+ mounted() {
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ this.chartCpuMem = new Chart(this.$refs.cpumem, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'CPU',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#86b300',
+ backgroundColor: alpha('#86b300', 0.1),
+ data: []
+ }, {
+ label: 'MEM (active)',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ backgroundColor: alpha('#935dbf', 0.02),
+ data: []
+ }, {
+ label: 'MEM (used)',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ max: 100
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.chartNet = new Chart(this.$refs.net, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'In',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Out',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.chartDisk = new Chart(this.$refs.disk, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Read',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Write',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.$root.api('admin/server-info', {}).then(res => {
+ this.serverInfo = res;
+
+ this.connection = this.$root.stream.useSharedConnection('serverStats');
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 150
+ });
+ });
+ },
+
+ beforeDestroy() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ },
+
+ methods: {
+ onStats(stats) {
+ const cpu = (stats.cpu * 100).toFixed(0);
+ const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+ const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+ this.memUsage = stats.mem.active;
+
+ this.chartCpuMem.data.labels.push('');
+ this.chartCpuMem.data.datasets[0].data.push(cpu);
+ this.chartCpuMem.data.datasets[1].data.push(memActive);
+ this.chartCpuMem.data.datasets[2].data.push(memUsed);
+ this.chartNet.data.labels.push('');
+ this.chartNet.data.datasets[0].data.push(stats.net.rx);
+ this.chartNet.data.datasets[1].data.push(stats.net.tx);
+ this.chartDisk.data.labels.push('');
+ this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+ this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+ if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+ this.chartCpuMem.data.labels.shift();
+ this.chartCpuMem.data.datasets[0].data.shift();
+ this.chartCpuMem.data.datasets[1].data.shift();
+ this.chartCpuMem.data.datasets[2].data.shift();
+ this.chartNet.data.labels.shift();
+ this.chartNet.data.datasets[0].data.shift();
+ this.chartNet.data.datasets[1].data.shift();
+ this.chartDisk.data.labels.shift();
+ this.chartDisk.data.datasets[0].data.shift();
+ this.chartDisk.data.datasets[1].data.shift();
+ }
+ this.chartCpuMem.update();
+ this.chartNet.update();
+ this.chartDisk.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of statsLog.reverse()) {
+ this.onStats(stats);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-monitor {
+ > section {
+ > ._content {
+ > .table {
+ > .row {
+ display: flex;
+
+ &:not(:last-child) {
+ margin-bottom: 16px;
+
+ @media (max-width: 500px) {
+ margin-bottom: 8px;
+ }
+ }
+
+ > .cell {
+ flex: 1;
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+
+ > .icon {
+ margin-right: 4px;
+ display: none;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/queue.queue.vue b/src/client/pages/instance/queue.queue.vue
new file mode 100644
index 0000000000..cc542b176f
--- /dev/null
+++ b/src/client/pages/instance/queue.queue.vue
@@ -0,0 +1,204 @@
+<template>
+<section class="_section mk-queue-queue">
+ <div class="_title"><slot name="title"></slot></div>
+ <div class="_content status">
+ <div class="cell"><div class="label">Process</div>{{ activeSincePrevTick | number }}</div>
+ <div class="cell"><div class="label">Active</div>{{ active | number }}</div>
+ <div class="cell"><div class="label">Waiting</div>{{ waiting | number }}</div>
+ <div class="cell"><div class="label">Delayed</div>{{ delayed | number }}</div>
+ </div>
+ <div class="_content" style="margin-bottom: -8px;">
+ <canvas ref="chart"></canvas>
+ </div>
+ <div class="_content" style="max-height: 180px; overflow: auto;">
+ <sequential-entrance :delay="15" v-if="jobs.length > 0">
+ <div v-for="(job, i) in jobs" :key="job[0]" :data-index="i">
+ <span>{{ job[0] }}</span>
+ <span style="margin-left: 8px; opacity: 0.7;">({{ job[1] | number }} jobs)</span>
+ </div>
+ </sequential-entrance>
+ <span v-else style="opacity: 0.5;">{{ $t('noJobs') }}</span>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+export default Vue.extend({
+ i18n,
+
+ props: {
+ domain: {
+ required: true
+ },
+ connection: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ chart: null,
+ jobs: [],
+ activeSincePrevTick: 0,
+ active: 0,
+ waiting: 0,
+ delayed: 0,
+ }
+ },
+
+ mounted() {
+ this.fetchJobs();
+
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ this.chart = new Chart(this.$refs.chart, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Process',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#00E396',
+ backgroundColor: alpha('#00E396', 0.1),
+ data: []
+ }, {
+ label: 'Active',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#00BCD4',
+ backgroundColor: alpha('#00BCD4', 0.1),
+ data: []
+ }, {
+ label: 'Waiting',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#FFB300',
+ backgroundColor: alpha('#FFB300', 0.1),
+ data: []
+ }, {
+ label: 'Delayed',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#E53935',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ },
+
+ beforeDestroy() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ },
+
+ methods: {
+ onStats(stats) {
+ this.activeSincePrevTick = stats[this.domain].activeSincePrevTick;
+ this.active = stats[this.domain].active;
+ this.waiting = stats[this.domain].waiting;
+ this.delayed = stats[this.domain].delayed;
+ this.chart.data.labels.push('');
+ this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick);
+ this.chart.data.datasets[1].data.push(stats[this.domain].active);
+ this.chart.data.datasets[2].data.push(stats[this.domain].waiting);
+ this.chart.data.datasets[3].data.push(stats[this.domain].delayed);
+ if (this.chart.data.datasets[0].data.length > 200) {
+ this.chart.data.labels.shift();
+ this.chart.data.datasets[0].data.shift();
+ this.chart.data.datasets[1].data.shift();
+ this.chart.data.datasets[2].data.shift();
+ this.chart.data.datasets[3].data.shift();
+ }
+ this.chart.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of statsLog.reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ fetchJobs() {
+ this.$root.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => {
+ this.jobs = jobs;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-queue-queue {
+ > .status {
+ display: flex;
+
+ > .cell {
+ flex: 1;
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue
new file mode 100644
index 0000000000..b7e633081f
--- /dev/null
+++ b/src/client/pages/instance/queue.vue
@@ -0,0 +1,79 @@
+<template>
+<div>
+ <x-queue :connection="connection" domain="inbox">
+ <template #title><fa :icon="faExchangeAlt"/> In</template>
+ </x-queue>
+ <x-queue :connection="connection" domain="deliver">
+ <template #title><fa :icon="faExchangeAlt"/> Out</template>
+ </x-queue>
+ <section class="_section">
+ <div class="_content">
+ <mk-button @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</mk-button>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import XQueue from './queue.queue.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: `${this.$t('jobQueue')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ XQueue,
+ },
+
+ data() {
+ return {
+ connection: this.$root.stream.useSharedConnection('queueStats'),
+ faExchangeAlt, faTrashAlt
+ }
+ },
+
+ mounted() {
+ this.$nextTick(() => {
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeDestroy() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ clear() {
+ this.$root.dialog({
+ type: 'warning',
+ title: this.$t('clearQueueConfirmTitle'),
+ text: this.$t('clearQueueConfirmText'),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ this.$root.api('admin/queue/clear', {}).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/instance/stats.vue b/src/client/pages/instance/stats.vue
new file mode 100644
index 0000000000..595ad2cc3c
--- /dev/null
+++ b/src/client/pages/instance/stats.vue
@@ -0,0 +1,491 @@
+<template>
+<div class="mk-instance-stats">
+ <section class="_section">
+ <div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <div class="selects" style="display: flex;">
+ <mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
+ <optgroup :label="$t('federation')">
+ <option value="federation-instances">{{ $t('_charts.federationInstancesIncDec') }}</option>
+ <option value="federation-instances-total">{{ $t('_charts.federationInstancesTotal') }}</option>
+ </optgroup>
+ <optgroup :label="$t('users')">
+ <option value="users">{{ $t('_charts.usersIncDec') }}</option>
+ <option value="users-total">{{ $t('_charts.usersTotal') }}</option>
+ <option value="active-users">{{ $t('_charts.activeUsers') }}</option>
+ </optgroup>
+ <optgroup :label="$t('notes')">
+ <option value="notes">{{ $t('_charts.notesIncDec') }}</option>
+ <option value="local-notes">{{ $t('_charts.localNotesIncDec') }}</option>
+ <option value="remote-notes">{{ $t('_charts.remoteNotesIncDec') }}</option>
+ <option value="notes-total">{{ $t('_charts.notesTotal') }}</option>
+ </optgroup>
+ <optgroup :label="$t('drive')">
+ <option value="drive-files">{{ $t('_charts.filesIncDec') }}</option>
+ <option value="drive-files-total">{{ $t('_charts.filesTotal') }}</option>
+ <option value="drive">{{ $t('_charts.storageUsageIncDec') }}</option>
+ <option value="drive-total">{{ $t('_charts.storageUsageTotal') }}</option>
+ </optgroup>
+ </mk-select>
+ <mk-select v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $t('perHour') }}</option>
+ <option value="day">{{ $t('perDay') }}</option>
+ </mk-select>
+ </div>
+ <canvas ref="chart"></canvas>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faChartBar } from '@fortawesome/free-solid-svg-icons';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+import MkSelect from '../../components/ui/select.vue';
+
+const chartLimit = 90;
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: `${this.$t('statistics')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkSelect
+ },
+
+ data() {
+ return {
+ now: null,
+ chart: null,
+ chartInstance: null,
+ chartSrc: 'notes',
+ chartSpan: 'hour',
+ faChartBar
+ }
+ },
+
+ computed: {
+ data(): any {
+ if (this.chart == null) return null;
+ switch (this.chartSrc) {
+ case 'federation-instances': return this.federationInstancesChart(false);
+ 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');
+ case 'notes-total': return this.notesTotalChart();
+ case 'drive': return this.driveChart();
+ case 'drive-total': return this.driveTotalChart();
+ case 'drive-files': return this.driveFilesChart();
+ case 'drive-files-total': return this.driveFilesTotalChart();
+ }
+ },
+
+ stats(): any[] {
+ const stats =
+ this.chartSpan == 'day' ? this.chart.perDay :
+ this.chartSpan == 'hour' ? this.chart.perHour :
+ null;
+
+ return stats;
+ }
+ },
+
+ watch: {
+ chartSrc() {
+ this.renderChart();
+ },
+
+ chartSpan() {
+ this.renderChart();
+ }
+ },
+
+ async created() {
+ this.now = new Date();
+
+ const [perHour, perDay] = await Promise.all([Promise.all([
+ this.$root.api('charts/federation', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/users', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/active-users', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/notes', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/drive', { limit: chartLimit, span: 'hour' }),
+ ]), Promise.all([
+ this.$root.api('charts/federation', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/users', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/active-users', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/notes', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/drive', { limit: chartLimit, span: 'day' }),
+ ])]);
+
+ const chart = {
+ perHour: {
+ federation: perHour[0],
+ users: perHour[1],
+ activeUsers: perHour[2],
+ notes: perHour[3],
+ drive: perHour[4],
+ },
+ perDay: {
+ federation: perDay[0],
+ users: perDay[1],
+ activeUsers: perDay[2],
+ notes: perDay[3],
+ drive: perDay[4],
+ }
+ };
+
+ this.chart = chart;
+
+ this.renderChart();
+ },
+
+ methods: {
+ renderChart() {
+ if (this.chartInstance) {
+ this.chartInstance.destroy();
+ }
+
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+ this.chartInstance = new Chart(this.$refs.chart, {
+ type: 'line',
+ data: {
+ labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
+ datasets: this.data.series.map(x => ({
+ label: x.name,
+ data: x.data.slice().reverse(),
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: x.color,
+ backgroundColor: alpha(x.color, 0.1),
+ hidden: !!x.hidden
+ }))
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+ },
+
+ getDate(ago: number) {
+ const y = this.now.getFullYear();
+ const m = this.now.getMonth();
+ const d = this.now.getDate();
+ const h = this.now.getHours();
+
+ return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+ },
+
+ format(arr) {
+ return arr;
+ },
+
+ federationInstancesChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Instances',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.federation.instance.total
+ : sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec))
+ )
+ }]
+ };
+ },
+
+ notesChart(type: string): any {
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec))
+ : sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec))
+ )
+ }, {
+ name: 'Renotes',
+ type: 'area',
+ color: '#00E396',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote)
+ : this.stats.notes[type].diffs.renote
+ )
+ }, {
+ name: 'Replies',
+ type: 'area',
+ color: '#FEB019',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply)
+ : this.stats.notes[type].diffs.reply
+ )
+ }, {
+ name: 'Normal',
+ type: 'area',
+ color: '#FF4560',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal)
+ : this.stats.notes[type].diffs.normal
+ )
+ }]
+ };
+ },
+
+ notesTotalChart(): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.notes.local.total)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.notes.remote.total)
+ }]
+ };
+ },
+
+ usersChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(total
+ ? sum(this.stats.users.local.total, this.stats.users.remote.total)
+ : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
+ )
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(total
+ ? this.stats.users.local.total
+ : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec))
+ )
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(total
+ ? this.stats.users.remote.total
+ : sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
+ )
+ }]
+ };
+ },
+
+ activeUsersChart(): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.activeUsers.local.count)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.activeUsers.remote.count)
+ }]
+ };
+ },
+
+ driveChart(): any {
+ return {
+ bytes: true,
+ series: [{
+ name: 'All',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(
+ sum(
+ this.stats.drive.local.incSize,
+ negate(this.stats.drive.local.decSize),
+ this.stats.drive.remote.incSize,
+ negate(this.stats.drive.remote.decSize)
+ )
+ )
+ }, {
+ name: 'Local +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.local.incSize)
+ }, {
+ name: 'Local -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.local.decSize))
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.remote.incSize)
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.remote.decSize))
+ }]
+ };
+ },
+
+ driveTotalChart(): any {
+ return {
+ bytes: true,
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.local.totalSize)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.remote.totalSize)
+ }]
+ };
+ },
+
+ driveFilesChart(): any {
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(
+ sum(
+ this.stats.drive.local.incCount,
+ negate(this.stats.drive.local.decCount),
+ this.stats.drive.remote.incCount,
+ negate(this.stats.drive.remote.decCount)
+ )
+ )
+ }, {
+ name: 'Local +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.local.incCount)
+ }, {
+ name: 'Local -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.local.decCount))
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.remote.incCount)
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.remote.decCount))
+ }]
+ };
+ },
+
+ driveFilesTotalChart(): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.local.totalCount)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.remote.totalCount)
+ }]
+ };
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue
new file mode 100644
index 0000000000..da59d8ce24
--- /dev/null
+++ b/src/client/pages/instance/users.vue
@@ -0,0 +1,203 @@
+<template>
+<div class="mk-instance-users">
+ <portal to="icon"><fa :icon="faUsers"/></portal>
+ <portal to="title">{{ $t('users') }}</portal>
+
+ <section class="_section lookup">
+ <div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div>
+ <div class="_content">
+ <mk-input class="target" v-model="target" type="text" @enter="showUser()" style="margin-top: 0;">
+ <span>{{ $t('usernameOrUserId') }}</span>
+ </mk-input>
+ <mk-button @click="showUser()" primary><fa :icon="faSearch"/> {{ $t('lookup') }}</mk-button>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary @click="search()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section users">
+ <div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div>
+ <div class="_content _list">
+ <mk-pagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false">
+ <button class="user _button _listItem" v-for="(user, i) in items" :key="user.id" :data-index="i" @click="show(user)">
+ <mk-avatar :user="user" class="avatar"/>
+ <div class="body">
+ <mk-user-name :user="user" class="name"/>
+ <mk-acct :user="user" class="acct"/>
+ </div>
+ </button>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary @click="addUser()"><fa :icon="faPlus"/> {{ $t('addUser') }}</mk-button>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faUsers, faSearch } from '@fortawesome/free-solid-svg-icons';
+import parseAcct from '../../../misc/acct/parse';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import MkUserModerateDialog from '../../components/user-moderate-dialog.vue';
+import MkUserSelect from '../../components/user-select.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: `${this.$t('users')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'admin/show-users',
+ limit: 10,
+ offsetMode: true
+ },
+ target: '',
+ faPlus, faUsers, faSearch
+ }
+ },
+
+ methods: {
+ /** テキストエリアのユーザーを解決する */
+ fetchUser() {
+ return new Promise((res) => {
+ const usernamePromise = this.$root.api('users/show', parseAcct(this.target));
+ const idPromise = this.$root.api('users/show', { userId: this.target });
+ let _notFound = false;
+ const notFound = () => {
+ if (_notFound) {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('noSuchUser')
+ });
+ } else {
+ _notFound = true;
+ }
+ };
+ usernamePromise.then(res).catch(e => {
+ if (e.code === 'NO_SUCH_USER') {
+ notFound();
+ }
+ });
+ idPromise.then(res).catch(e => {
+ notFound();
+ });
+ });
+ },
+
+ /** テキストエリアから処理対象ユーザーを設定する */
+ async showUser() {
+ const user = await this.fetchUser();
+ this.$root.api('admin/show-user', { userId: user.id }).then(info => {
+ this.show(user, info);
+ });
+ this.target = '';
+ },
+
+ async addUser() {
+ const { canceled: canceled1, result: username } = await this.$root.dialog({
+ title: this.$t('username'),
+ input: true
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: password } = await this.$root.dialog({
+ title: this.$t('password'),
+ input: { type: 'password' }
+ });
+ if (canceled2) return;
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ iconOnly: true
+ });
+
+ this.$root.api('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ this.$refs.users.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e.id
+ });
+ }).finally(() => {
+ dialog.close();
+ });
+ },
+
+ async show(user, info) {
+ if (info == null) info = await this.$root.api('admin/show-user', { userId: user.id });
+ this.$root.new(MkUserModerateDialog, {
+ user: { ...user, ...info }
+ });
+ },
+
+ search() {
+ this.$root.new(MkUserSelect, {}).$once('selected', user => {
+ this.$root.api('admin/show-user', { userId: user.id }).then(info => {
+ this.show(user, info);
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-users {
+ > .users {
+ > ._content {
+ max-height: 300px;
+ overflow: auto;
+
+ > .users {
+ > .user {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>