diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-01-30 04:37:25 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-30 04:37:25 +0900 |
| commit | f6154dc0af1a0d65819e87240f4385f9573095cb (patch) | |
| tree | 699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/pages/instance | |
| parent | Add Event activity-type support (#5785) (diff) | |
| download | misskey-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.vue | 129 | ||||
| -rw-r--r-- | src/client/pages/instance/emojis.vue | 253 | ||||
| -rw-r--r-- | src/client/pages/instance/federation.instance.vue | 576 | ||||
| -rw-r--r-- | src/client/pages/instance/federation.vue | 165 | ||||
| -rw-r--r-- | src/client/pages/instance/files.vue | 54 | ||||
| -rw-r--r-- | src/client/pages/instance/index.vue | 393 | ||||
| -rw-r--r-- | src/client/pages/instance/monitor.vue | 381 | ||||
| -rw-r--r-- | src/client/pages/instance/queue.queue.vue | 204 | ||||
| -rw-r--r-- | src/client/pages/instance/queue.vue | 79 | ||||
| -rw-r--r-- | src/client/pages/instance/stats.vue | 491 | ||||
| -rw-r--r-- | src/client/pages/instance/users.vue | 203 |
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> |