Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
syuilo
2020-01-30 04:37:25 +09:00
committed by GitHub
parent a5955c1123
commit f6154dc0af
871 changed files with 26140 additions and 71950 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>