refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View File

@ -0,0 +1,170 @@
<template>
<div class="lcixvhis">
<div class="_section reports">
<div class="_content">
<div class="inputs" style="display: flex;">
<MkSelect v-model="state" style="margin: 0; flex: 1;">
<template #label>{{ $ts.state }}</template>
<option value="all">{{ $ts.all }}</option>
<option value="unresolved">{{ $ts.unresolved }}</option>
<option value="resolved">{{ $ts.resolved }}</option>
</MkSelect>
<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
<template #label>{{ $ts.reporteeOrigin }}</template>
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkSelect>
<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
<template #label>{{ $ts.reporterOrigin }}</template>
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkSelect>
</div>
<!-- TODO
<div class="inputs" style="display: flex; padding-top: 1.2em;">
<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()">
<span>{{ $ts.username }}</span>
</MkInput>
<MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'">
<span>{{ $ts.host }}</span>
</MkInput>
</div>
-->
<MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);">
<div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id">
<div class="_content target">
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/>
<div class="info">
<MkUserName class="name" :user="report.targetUser"/>
<div class="acct">@{{ acct(report.targetUser) }}</div>
</div>
</div>
<div class="_content">
<div>
<Mfm :text="report.comment"/>
</div>
<hr>
<div>Reporter: <MkAcct :user="report.reporter"/></div>
<div><MkTime :time="report.createdAt"/></div>
</div>
<div class="_footer">
<div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div>
<MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $ts.abuseMarkAsResolved }}</MkButton>
</div>
</div>
</MkPagination>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { acct } from '@/filters/user';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkPagination,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.abuseReports,
icon: 'fas fa-exclamation-circle',
bg: 'var(--bg)',
},
searchUsername: '',
searchHost: '',
state: 'unresolved',
reporterOrigin: 'combined',
targetUserOrigin: 'combined',
pagination: {
endpoint: 'admin/abuse-user-reports',
limit: 10,
params: () => ({
state: this.state,
reporterOrigin: this.reporterOrigin,
targetUserOrigin: this.targetUserOrigin,
}),
},
}
},
watch: {
state() {
this.$refs.reports.reload();
},
reporterOrigin() {
this.$refs.reports.reload();
},
targetUserOrigin() {
this.$refs.reports.reload();
},
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
acct,
resolve(report) {
os.apiWithDialog('admin/resolve-abuse-user-report', {
reportId: report.id,
}).then(() => {
this.$refs.reports.removeItem(item => item.id === report.id);
});
},
}
});
</script>
<style lang="scss" scoped>
.lcixvhis {
margin: var(--margin);
}
.bcekxzvu {
> .target {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
> .avatar {
width: 42px;
height: 42px;
}
> .info {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
> .name {
font-weight: bold;
}
}
}
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<div class="uqshojas">
<section class="_card _gap ads" v-for="ad in ads">
<div class="_content ad">
<MkAd v-if="ad.url" :specify="ad"/>
<MkInput v-model="ad.url" type="url">
<template #label>URL</template>
</MkInput>
<MkInput v-model="ad.imageUrl">
<template #label>{{ $ts.imageUrl }}</template>
</MkInput>
<div style="margin: 32px 0;">
<MkRadio v-model="ad.place" value="square">square</MkRadio>
<MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
<MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio>
</div>
<!--
<div style="margin: 32px 0;">
{{ $ts.priority }}
<MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
<MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
<MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
</div>
-->
<MkInput v-model="ad.ratio" type="number">
<template #label>{{ $ts.ratio }}</template>
</MkInput>
<MkInput v-model="ad.expiresAt" type="date">
<template #label>{{ $ts.expiration }}</template>
</MkInput>
<MkTextarea v-model="ad.memo">
<template #label>{{ $ts.memo }}</template>
</MkTextarea>
<div class="buttons">
<MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
<MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkRadio from '@/components/form/radio.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
MkButton,
MkInput,
MkTextarea,
MkRadio,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.ads,
icon: 'fas fa-audio-description',
bg: 'var(--bg)',
actions: [{
asFullButton: true,
icon: 'fas fa-plus',
text: this.$ts.add,
handler: this.add,
}],
},
ads: [],
}
},
created() {
os.api('admin/ad/list').then(ads => {
this.ads = ads;
});
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
add() {
this.ads.unshift({
id: null,
memo: '',
place: 'square',
priority: 'middle',
ratio: 1,
url: '',
imageUrl: null,
expiresAt: null,
});
},
remove(ad) {
os.dialog({
type: 'warning',
text: this.$t('removeAreYouSure', { x: ad.url }),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.ads = this.ads.filter(x => x != ad);
os.apiWithDialog('admin/ad/delete', {
id: ad.id
});
});
},
save(ad) {
if (ad.id == null) {
os.apiWithDialog('admin/ad/create', {
...ad,
expiresAt: new Date(ad.expiresAt).getTime()
});
} else {
os.apiWithDialog('admin/ad/update', {
...ad,
expiresAt: new Date(ad.expiresAt).getTime()
});
}
}
}
});
</script>
<style lang="scss" scoped>
.uqshojas {
margin: var(--margin);
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<div class="ztgjmzrw">
<section class="_card _gap announcements" v-for="announcement in announcements">
<div class="_content announcement">
<MkInput v-model="announcement.title">
<template #label>{{ $ts.title }}</template>
</MkInput>
<MkTextarea v-model="announcement.text">
<template #label>{{ $ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl">
<template #label>{{ $ts.imageUrl }}</template>
</MkInput>
<p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
<div class="buttons">
<MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
MkButton,
MkInput,
MkTextarea,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.announcements,
icon: 'fas fa-broadcast-tower',
bg: 'var(--bg)',
actions: [{
asFullButton: true,
icon: 'fas fa-plus',
text: this.$ts.add,
handler: this.add,
}],
},
announcements: [],
}
},
created() {
os.api('admin/announcements/list').then(announcements => {
this.announcements = announcements;
});
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
add() {
this.announcements.unshift({
id: null,
title: '',
text: '',
imageUrl: null
});
},
remove(announcement) {
os.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);
os.api('admin/announcements/delete', announcement);
});
},
save(announcement) {
if (announcement.id == null) {
os.api('admin/announcements/create', announcement).then(() => {
os.dialog({
type: 'success',
text: this.$ts.saved
});
}).catch(e => {
os.dialog({
type: 'error',
text: e
});
});
} else {
os.api('admin/announcements/update', announcement).then(() => {
os.dialog({
type: 'success',
text: this.$ts.saved
});
}).catch(e => {
os.dialog({
type: 'error',
text: e
});
});
}
}
}
});
</script>
<style lang="scss" scoped>
.ztgjmzrw {
margin: var(--margin);
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormRadios v-model="provider">
<template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template>
<option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
</FormRadios>
<template v-if="provider === 'hcaptcha'">
<div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
<div class="_debobigegoLabel">hCaptcha</div>
<div class="main">
<FormInput v-model="hcaptchaSiteKey">
<template #prefix><i class="fas fa-key"></i></template>
<span>{{ $ts.hcaptchaSiteKey }}</span>
</FormInput>
<FormInput v-model="hcaptchaSecretKey">
<template #prefix><i class="fas fa-key"></i></template>
<span>{{ $ts.hcaptchaSecretKey }}</span>
</FormInput>
</div>
</div>
<div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
<div class="_debobigegoLabel">{{ $ts.preview }}</div>
<div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
<MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
</div>
</div>
</template>
<template v-else-if="provider === 'recaptcha'">
<div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
<div class="_debobigegoLabel">reCAPTCHA</div>
<div class="main">
<FormInput v-model="recaptchaSiteKey">
<template #prefix><i class="fas fa-key"></i></template>
<span>{{ $ts.recaptchaSiteKey }}</span>
</FormInput>
<FormInput v-model="recaptchaSecretKey">
<template #prefix><i class="fas fa-key"></i></template>
<span>{{ $ts.recaptchaSecretKey }}</span>
</FormInput>
</div>
</div>
<div v-if="recaptchaSiteKey" class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
<div class="_debobigegoLabel">{{ $ts.preview }}</div>
<div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
</div>
</div>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
import FormRadios from '@/components/debobigego/radios.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormRadios,
FormInput,
FormBase,
FormGroup,
FormButton,
FormInfo,
FormSuspense,
MkCaptcha: defineAsyncComponent(() => import('@/components/captcha.vue')),
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.botProtection,
icon: 'fas fa-shield-alt'
},
provider: null,
enableHcaptcha: false,
hcaptchaSiteKey: null,
hcaptchaSecretKey: null,
enableRecaptcha: false,
recaptchaSiteKey: null,
recaptchaSecretKey: null,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableHcaptcha = meta.enableHcaptcha;
this.hcaptchaSiteKey = meta.hcaptchaSiteKey;
this.hcaptchaSecretKey = meta.hcaptchaSecretKey;
this.enableRecaptcha = meta.enableRecaptcha;
this.recaptchaSiteKey = meta.recaptchaSiteKey;
this.recaptchaSecretKey = meta.recaptchaSecretKey;
this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null;
this.$watch(() => this.provider, () => {
this.enableHcaptcha = this.provider === 'hcaptcha';
this.enableRecaptcha = this.provider === 'recaptcha';
});
},
save() {
os.apiWithDialog('admin/update-meta', {
enableHcaptcha: this.enableHcaptcha,
hcaptchaSiteKey: this.hcaptchaSiteKey,
hcaptchaSecretKey: this.hcaptchaSecretKey,
enableRecaptcha: this.enableRecaptcha,
recaptchaSiteKey: this.recaptchaSiteKey,
recaptchaSecretKey: this.recaptchaSecretKey,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,61 @@
<template>
<FormBase>
<FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }">
<FormGroup v-for="table in database" :key="table[0]">
<template #label>{{ table[0] }}</template>
<FormKeyValueView>
<template #key>Size</template>
<template #value>{{ bytes(table[1].size) }}</template>
</FormKeyValueView>
<FormKeyValueView>
<template #key>Records</template>
<template #value>{{ number(table[1].count) }}</template>
</FormKeyValueView>
</FormGroup>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
import FormLink from '@/components/debobigego/link.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
export default defineComponent({
components: {
FormSuspense,
FormKeyValueView,
FormBase,
FormGroup,
FormLink,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.database,
icon: 'fas fa-database',
bg: 'var(--bg)',
},
databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)),
}
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
bytes, number,
}
});
</script>

View File

@ -0,0 +1,128 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch>
<template v-if="enableEmail">
<FormInput v-model="email" type="email">
<span>{{ $ts.emailAddress }}</span>
</FormInput>
<div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
<div class="_debobigegoLabel">{{ $ts.smtpConfig }}</div>
<div class="main">
<FormInput v-model="smtpHost">
<span>{{ $ts.smtpHost }}</span>
</FormInput>
<FormInput v-model="smtpPort" type="number">
<span>{{ $ts.smtpPort }}</span>
</FormInput>
<FormInput v-model="smtpUser">
<span>{{ $ts.smtpUser }}</span>
</FormInput>
<FormInput v-model="smtpPass" type="password">
<span>{{ $ts.smtpPass }}</span>
</FormInput>
<FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo>
<FormSwitch v-model="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch>
</div>
</div>
<FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormInfo,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.emailServer,
icon: 'fas fa-envelope',
bg: 'var(--bg)',
},
enableEmail: false,
email: null,
smtpSecure: false,
smtpHost: '',
smtpPort: 0,
smtpUser: '',
smtpPass: '',
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableEmail = meta.enableEmail;
this.email = meta.email;
this.smtpSecure = meta.smtpSecure;
this.smtpHost = meta.smtpHost;
this.smtpPort = meta.smtpPort;
this.smtpUser = meta.smtpUser;
this.smtpPass = meta.smtpPass;
},
async testEmail() {
const { canceled, result: destination } = await os.dialog({
title: this.$ts.destination,
input: {
placeholder: this.$instance.maintainerEmail
}
});
if (canceled) return;
os.apiWithDialog('admin/send-email', {
to: destination,
subject: 'Test email',
text: 'Yo'
});
},
save() {
os.apiWithDialog('admin/update-meta', {
enableEmail: this.enableEmail,
email: this.email,
smtpSecure: this.smtpSecure,
smtpHost: this.smtpHost,
smtpPort: this.smtpPort,
smtpUser: this.smtpUser,
smtpPass: this.smtpPass,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,120 @@
<template>
<XModalWindow ref="dialog"
:width="370"
:with-ok-button="true"
@close="$refs.dialog.close()"
@closed="$emit('closed')"
@ok="ok()"
>
<template #header>:{{ emoji.name }}:</template>
<div class="_monolithic_">
<div class="yigymqpb _section">
<img :src="emoji.url" class="img"/>
<MkInput class="_formBlock" v-model="name">
<template #label>{{ $ts.name }}</template>
</MkInput>
<MkInput class="_formBlock" v-model="category" :datalist="categories">
<template #label>{{ $ts.category }}</template>
</MkInput>
<MkInput class="_formBlock" v-model="aliases">
<template #label>{{ $ts.tags }}</template>
<template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template>
</MkInput>
<MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
</div>
</div>
</XModalWindow>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import * as os from '@/os';
import { unique } from '@/scripts/array';
export default defineComponent({
components: {
XModalWindow,
MkButton,
MkInput,
},
props: {
emoji: {
required: true,
}
},
emits: ['done', 'closed'],
data() {
return {
name: this.emoji.name,
category: this.emoji.category,
aliases: this.emoji.aliases?.join(' '),
categories: [],
}
},
created() {
os.api('meta', { detail: false }).then(({ emojis }) => {
this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
});
},
methods: {
ok() {
this.update();
},
async update() {
await os.apiWithDialog('admin/emoji/update', {
id: this.emoji.id,
name: this.name,
category: this.category,
aliases: this.aliases.split(' '),
});
this.$emit('done', {
updated: {
name: this.name,
category: this.category,
aliases: this.aliases.split(' '),
}
});
this.$refs.dialog.close();
},
async del() {
const { canceled } = await os.dialog({
type: 'warning',
text: this.$t('removeAreYouSure', { x: this.emoji.name }),
showCancelButton: true
});
if (canceled) return;
os.api('admin/emoji/remove', {
id: this.emoji.id
}).then(() => {
this.$emit('done', {
deleted: true
});
this.$refs.dialog.close();
});
},
}
});
</script>
<style lang="scss" scoped>
.yigymqpb {
> .img {
display: block;
height: 64px;
margin: 0 auto;
}
}
</style>

View File

@ -0,0 +1,263 @@
<template>
<div class="ogwlenmc">
<div class="local" v-if="tab === 'local'">
<MkInput v-model="query" :debounce="true" type="search" style="margin: var(--margin);">
<template #prefix><i class="fas fa-search"></i></template>
<template #label>{{ $ts.search }}</template>
</MkInput>
<MkPagination :pagination="pagination" ref="emojis">
<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)">
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
</div>
</template>
</MkPagination>
</div>
<div class="remote" v-else-if="tab === 'remote'">
<MkInput v-model="queryRemote" :debounce="true" type="search" style="margin: var(--margin);">
<template #prefix><i class="fas fa-search"></i></template>
<template #label>{{ $ts.search }}</template>
</MkInput>
<MkInput v-model="host" :debounce="true" style="margin: var(--margin);">
<template #label>{{ $ts.host }}</template>
</MkInput>
<MkPagination :pagination="remotePagination" ref="remoteEmojis">
<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)">
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
</div>
</div>
</div>
</template>
</MkPagination>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkTab from '@/components/tab.vue';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
MkTab,
MkButton,
MkInput,
MkPagination,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: computed(() => ({
title: this.$ts.customEmojis,
icon: 'fas fa-laugh',
bg: 'var(--bg)',
actions: [{
asFullButton: true,
icon: 'fas fa-plus',
text: this.$ts.addEmoji,
handler: this.add,
}],
tabs: [{
active: this.tab === 'local',
title: this.$ts.local,
onClick: () => { this.tab = 'local'; },
}, {
active: this.tab === 'remote',
title: this.$ts.remote,
onClick: () => { this.tab = 'remote'; },
},]
})),
tab: 'local',
query: null,
queryRemote: null,
host: '',
pagination: {
endpoint: 'admin/emoji/list',
limit: 30,
params: computed(() => ({
query: (this.query && this.query !== '') ? this.query : null
}))
},
remotePagination: {
endpoint: 'admin/emoji/list-remote',
limit: 30,
params: computed(() => ({
query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null,
host: (this.host && this.host !== '') ? this.host : null
}))
},
}
},
async mounted() {
this.$emit('info', toRef(this, symbols.PAGE_INFO));
},
methods: {
async add(e) {
const files = await selectFile(e.currentTarget || e.target, null, true);
const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
fileId: file.id,
})));
promise.then(() => {
this.$refs.emojis.reload();
});
os.promiseDialog(promise);
},
edit(emoji) {
os.popup(import('./emoji-edit-dialog.vue'), {
emoji: emoji
}, {
done: result => {
if (result.updated) {
this.$refs.emojis.replaceItem(item => item.id === emoji.id, {
...emoji,
...result.updated
});
} else if (result.deleted) {
this.$refs.emojis.removeItem(item => item.id === emoji.id);
}
},
}, 'closed');
},
im(emoji) {
os.apiWithDialog('admin/emoji/copy', {
emojiId: emoji.id,
});
},
remoteMenu(emoji, ev) {
os.popupMenu([{
type: 'label',
text: ':' + emoji.name + ':',
}, {
text: this.$ts.import,
icon: 'fas fa-plus',
action: () => { this.im(emoji) }
}], ev.currentTarget || ev.target);
}
}
});
</script>
<style lang="scss" scoped>
.ogwlenmc {
> .local {
.empty {
margin: var(--margin);
}
.ldhfsamy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin);
> .emoji {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
&:hover {
color: var(--accent);
}
> .img {
width: 42px;
height: 42px;
}
> .body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
> .name {
text-overflow: ellipsis;
overflow: hidden;
}
> .info {
opacity: 0.5;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
}
> .remote {
.empty {
margin: var(--margin);
}
.ldhfsamy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin);
> .emoji {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
&:hover {
color: var(--accent);
}
> .img {
width: 32px;
height: 32px;
}
> .body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
> .name {
text-overflow: ellipsis;
overflow: hidden;
}
> .info {
opacity: 0.5;
font-size: 90%;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,129 @@
<template>
<XModalWindow ref="dialog"
:width="370"
@close="$refs.dialog.close()"
@closed="$emit('closed')"
>
<template #header v-if="file">{{ file.name }}</template>
<div class="cxqhhsmd" v-if="file">
<div class="_section">
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
<div class="info">
<span style="margin-right: 1em;">{{ file.type }}</span>
<span>{{ bytes(file.size) }}</span>
<MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
</div>
</div>
<div class="_section">
<div class="_content">
<MkSwitch @update:modelValue="toggleIsSensitive" v-model="isSensitive">NSFW</MkSwitch>
</div>
</div>
<div class="_section">
<div class="_content">
<MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
<MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
</div>
</div>
<div class="_section" v-if="info">
<details class="_content rawdata">
<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
</details>
</div>
</div>
</XModalWindow>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkSwitch from '@/components/form/switch.vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import Progress from '@/scripts/loading';
import bytes from '@/filters/bytes';
import * as os from '@/os';
export default defineComponent({
components: {
MkButton,
MkSwitch,
XModalWindow,
MkDriveFileThumbnail,
},
props: {
fileId: {
required: true,
}
},
emits: ['closed'],
data() {
return {
file: null,
info: null,
isSensitive: false,
};
},
created() {
this.fetch();
},
methods: {
async fetch() {
Progress.start();
this.file = await os.api('drive/files/show', { fileId: this.fileId });
this.info = await os.api('admin/drive/show-file', { fileId: this.fileId });
this.isSensitive = this.file.isSensitive;
Progress.done();
},
showUser() {
os.pageWindow(`/user-info/${this.file.userId}`);
},
async del() {
const { canceled } = await os.dialog({
type: 'warning',
text: this.$t('removeAreYouSure', { x: this.file.name }),
showCancelButton: true
});
if (canceled) return;
os.apiWithDialog('drive/files/delete', {
fileId: this.file.id
});
},
async toggleIsSensitive(v) {
await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v });
this.isSensitive = v;
},
bytes
}
});
</script>
<style lang="scss" scoped>
.cxqhhsmd {
> ._section {
> .thumbnail {
height: 150px;
max-width: 100%;
}
> .info {
text-align: center;
margin-top: 8px;
}
> .rawdata {
overflow: auto;
}
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="cacheRemoteFiles">
{{ $ts.cacheRemoteFiles }}
<template #desc>{{ $ts.cacheRemoteFilesDescription }}</template>
</FormSwitch>
<FormSwitch v-model="proxyRemoteFiles">
{{ $ts.proxyRemoteFiles }}
<template #desc>{{ $ts.proxyRemoteFilesDescription }}</template>
</FormSwitch>
<FormInput v-model="localDriveCapacityMb" type="number">
<span>{{ $ts.driveCapacityPerLocalAccount }}</span>
<template #suffix>MB</template>
<template #desc>{{ $ts.inMb }}</template>
</FormInput>
<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">
<span>{{ $ts.driveCapacityPerRemoteAccount }}</span>
<template #suffix>MB</template>
<template #desc>{{ $ts.inMb }}</template>
</FormInput>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.files,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
},
cacheRemoteFiles: false,
proxyRemoteFiles: false,
localDriveCapacityMb: 0,
remoteDriveCapacityMb: 0,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.cacheRemoteFiles = meta.cacheRemoteFiles;
this.proxyRemoteFiles = meta.proxyRemoteFiles;
this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
},
save() {
os.apiWithDialog('admin/update-meta', {
cacheRemoteFiles: this.cacheRemoteFiles,
proxyRemoteFiles: this.proxyRemoteFiles,
localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,209 @@
<template>
<div class="xrmjdkdw">
<MkContainer :foldable="true" class="lookup">
<template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template>
<div class="xrmjdkdw-lookup">
<MkInput class="item" v-model="q" type="text" @enter="find()">
<template #label>{{ $ts.fileIdOrUrl }}</template>
</MkInput>
<MkButton @click="find()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
</div>
</MkContainer>
<div class="_section">
<div class="_content">
<div class="inputs" style="display: flex;">
<MkSelect v-model="origin" style="margin: 0; flex: 1;">
<template #label>{{ $ts.instance }}</template>
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkSelect>
<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'">
<template #label>{{ $ts.host }}</template>
</MkInput>
</div>
<div class="inputs" style="display: flex; padding-top: 1.2em;">
<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
<template #label>MIME type</template>
</MkInput>
</div>
<MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files">
<button class="file _panel _button _gap" v-for="file in items" :key="file.id" @click="show(file, $event)">
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
<div class="body">
<div>
<small style="opacity: 0.7;">{{ file.name }}</small>
</div>
<div>
<MkAcct v-if="file.user" :user="file.user"/>
<div v-else>{{ $ts.system }}</div>
</div>
<div>
<span style="margin-right: 1em;">{{ file.type }}</span>
<span>{{ bytes(file.size) }}</span>
</div>
<div>
<span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
</div>
</div>
</button>
</MkPagination>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkContainer from '@/components/ui/container.vue';
import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkPagination,
MkContainer,
MkDriveFileThumbnail,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.files,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
actions: [{
text: this.$ts.clearCachedFiles,
icon: 'fas fa-trash-alt',
handler: this.clear
}]
},
q: null,
origin: 'local',
type: null,
searchHost: '',
pagination: {
endpoint: 'admin/drive/files',
limit: 10,
params: () => ({
type: (this.type && this.type !== '') ? this.type : null,
origin: this.origin,
hostname: (this.hostname && this.hostname !== '') ? this.hostname : null,
}),
},
}
},
watch: {
type() {
this.$refs.files.reload();
},
origin() {
this.$refs.files.reload();
},
searchHost() {
this.$refs.files.reload();
},
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
clear() {
os.dialog({
type: 'warning',
text: this.$ts.clearCachedFilesConfirm,
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
os.apiWithDialog('admin/drive/clean-remote-files', {});
});
},
show(file, ev) {
os.popup(import('./file-dialog.vue'), {
fileId: file.id
}, {}, 'closed');
},
find() {
os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => {
this.show(file);
}).catch(e => {
if (e.code === 'NO_SUCH_FILE') {
os.dialog({
type: 'error',
text: this.$ts.notFound
});
}
});
},
bytes
}
});
</script>
<style lang="scss" scoped>
.xrmjdkdw {
margin: var(--margin);
> .lookup {
margin-bottom: 16px;
}
.urempief {
margin-top: var(--margin);
> .file {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
&:hover {
color: var(--accent);
}
> .thumbnail {
width: 128px;
height: 128px;
}
> .body {
margin-left: 0.3em;
padding: 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
}
}
}
}
}
.xrmjdkdw-lookup {
padding: 16px;
> .item {
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,388 @@
<template>
<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el">
<div class="nav" v-if="!narrow || page == null">
<MkHeader :info="header"></MkHeader>
<MkSpacer :content-max="700">
<div class="lxpfedzu">
<div class="banner">
<img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
</div>
<MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo>
<MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu>
</div>
</MkSpacer>
</div>
<div class="main">
<MkStickyContainer>
<template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
<component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
</MkStickyContainer>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineAsyncComponent, defineComponent, isRef, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { i18n } from '@/i18n';
import MkSuperMenu from '@/components/ui/super-menu.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormButton from '@/components/debobigego/button.vue';
import MkInfo from '@/components/ui/info.vue';
import { scroll } from '@/scripts/scroll';
import { instance } from '@/instance';
import * as symbols from '@/symbols';
import * as os from '@/os';
import { lookupUser } from '@/scripts/lookup-user';
export default defineComponent({
components: {
FormBase,
MkSuperMenu,
FormGroup,
FormButton,
MkInfo,
},
provide: {
shouldOmitHeaderTitle: false,
},
props: {
initialPage: {
type: String,
required: false
}
},
setup(props, context) {
const indexInfo = {
title: i18n.locale.controlPanel,
icon: 'fas fa-cog',
bg: 'var(--bg)',
hideHeader: true,
};
const INFO = ref(indexInfo);
const childInfo = ref(null);
const page = ref(props.initialPage);
const narrow = ref(false);
const view = ref(null);
const el = ref(null);
const onInfo = (viewInfo) => {
if (isRef(viewInfo)) {
watch(viewInfo, () => {
childInfo.value = viewInfo.value;
}, { immediate: true });
} else {
childInfo.value = viewInfo;
}
};
const pageProps = ref({});
const isEmpty = (x: any) => x == null || x == '';
const noMaintainerInformation = ref(false);
const noBotProtection = ref(false);
os.api('meta', { detail: true }).then(meta => {
// TODO: 設定が完了しても残ったままになるので、ストリーミングでmeta更新イベントを受け取ってよしなに更新する
noMaintainerInformation.value = isEmpty(meta.maintainerName) || isEmpty(meta.maintainerEmail);
noBotProtection.value = !meta.enableHcaptcha && !meta.enableRecaptcha;
});
const menuDef = computed(() => [{
title: i18n.locale.quickAction,
items: [{
type: 'button',
icon: 'fas fa-search',
text: i18n.locale.lookup,
action: lookup,
}, ...(instance.disableRegistration ? [{
type: 'button',
icon: 'fas fa-user',
text: i18n.locale.invite,
action: invite,
}] : [])],
}, {
title: i18n.locale.administration,
items: [{
icon: 'fas fa-tachometer-alt',
text: i18n.locale.dashboard,
to: '/admin/overview',
active: page.value === 'overview',
}, {
icon: 'fas fa-users',
text: i18n.locale.users,
to: '/admin/users',
active: page.value === 'users',
}, {
icon: 'fas fa-laugh',
text: i18n.locale.customEmojis,
to: '/admin/emojis',
active: page.value === 'emojis',
}, {
icon: 'fas fa-globe',
text: i18n.locale.federation,
to: '/admin/federation',
active: page.value === 'federation',
}, {
icon: 'fas fa-clipboard-list',
text: i18n.locale.jobQueue,
to: '/admin/queue',
active: page.value === 'queue',
}, {
icon: 'fas fa-cloud',
text: i18n.locale.files,
to: '/admin/files',
active: page.value === 'files',
}, {
icon: 'fas fa-broadcast-tower',
text: i18n.locale.announcements,
to: '/admin/announcements',
active: page.value === 'announcements',
}, {
icon: 'fas fa-audio-description',
text: i18n.locale.ads,
to: '/admin/ads',
active: page.value === 'ads',
}, {
icon: 'fas fa-exclamation-circle',
text: i18n.locale.abuseReports,
to: '/admin/abuses',
active: page.value === 'abuses',
}],
}, {
title: i18n.locale.settings,
items: [{
icon: 'fas fa-cog',
text: i18n.locale.general,
to: '/admin/settings',
active: page.value === 'settings',
}, {
icon: 'fas fa-cloud',
text: i18n.locale.files,
to: '/admin/files-settings',
active: page.value === 'files-settings',
}, {
icon: 'fas fa-envelope',
text: i18n.locale.emailServer,
to: '/admin/email-settings',
active: page.value === 'email-settings',
}, {
icon: 'fas fa-cloud',
text: i18n.locale.objectStorage,
to: '/admin/object-storage',
active: page.value === 'object-storage',
}, {
icon: 'fas fa-lock',
text: i18n.locale.security,
to: '/admin/security',
active: page.value === 'security',
}, {
icon: 'fas fa-bolt',
text: 'ServiceWorker',
to: '/admin/service-worker',
active: page.value === 'service-worker',
}, {
icon: 'fas fa-globe',
text: i18n.locale.relays,
to: '/admin/relays',
active: page.value === 'relays',
}, {
icon: 'fas fa-share-alt',
text: i18n.locale.integration,
to: '/admin/integrations',
active: page.value === 'integrations',
}, {
icon: 'fas fa-ban',
text: i18n.locale.instanceBlocking,
to: '/admin/instance-block',
active: page.value === 'instance-block',
}, {
icon: 'fas fa-ghost',
text: i18n.locale.proxyAccount,
to: '/admin/proxy-account',
active: page.value === 'proxy-account',
}, {
icon: 'fas fa-cogs',
text: i18n.locale.other,
to: '/admin/other-settings',
active: page.value === 'other-settings',
}],
}, {
title: i18n.locale.info,
items: [{
icon: 'fas fa-database',
text: i18n.locale.database,
to: '/admin/database',
active: page.value === 'database',
}],
}]);
const component = computed(() => {
if (page.value == null) return null;
switch (page.value) {
case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
case 'users': return defineAsyncComponent(() => import('./users.vue'));
case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
case 'files': return defineAsyncComponent(() => import('./files.vue'));
case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
case 'database': return defineAsyncComponent(() => import('./database.vue'));
case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue'));
case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue'));
case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
case 'security': return defineAsyncComponent(() => import('./security.vue'));
case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue'));
case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue'));
case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue'));
case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue'));
case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue'));
case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue'));
}
});
watch(component, () => {
pageProps.value = {};
nextTick(() => {
scroll(el.value, { top: 0 });
});
}, { immediate: true });
watch(() => props.initialPage, () => {
if (props.initialPage == null && !narrow.value) {
page.value = 'overview';
} else {
page.value = props.initialPage;
if (props.initialPage == null) {
INFO.value = indexInfo;
}
}
});
onMounted(() => {
narrow.value = el.value.offsetWidth < 800;
if (!narrow.value) {
page.value = 'overview';
}
});
const invite = () => {
os.api('admin/invite').then(x => {
os.dialog({
type: 'info',
text: x.code
});
}).catch(e => {
os.dialog({
type: 'error',
text: e
});
});
};
const lookup = (ev) => {
os.popupMenu([{
text: i18n.locale.user,
icon: 'fas fa-user',
action: () => {
lookupUser();
}
}, {
text: i18n.locale.note,
icon: 'fas fa-pencil-alt',
action: () => {
alert('TODO');
}
}, {
text: i18n.locale.file,
icon: 'fas fa-cloud',
action: () => {
alert('TODO');
}
}, {
text: i18n.locale.instance,
icon: 'fas fa-globe',
action: () => {
alert('TODO');
}
}], ev.currentTarget || ev.target);
};
return {
[symbols.PAGE_INFO]: INFO,
menuDef,
header: {
title: i18n.locale.controlPanel,
},
noMaintainerInformation,
noBotProtection,
page,
narrow,
view,
el,
onInfo,
childInfo,
pageProps,
component,
invite,
lookup,
};
},
});
</script>
<style lang="scss" scoped>
.hiyeyicy {
&.wide {
display: flex;
margin: 0 auto;
height: 100%;
> .nav {
width: 32%;
max-width: 280px;
box-sizing: border-box;
border-right: solid 0.5px var(--divider);
overflow: auto;
height: 100%;
}
> .main {
flex: 1;
min-width: 0;
}
}
> .nav {
.lxpfedzu {
> .info {
margin: 16px 0;
}
> .banner {
margin: 16px;
> .icon {
display: block;
margin: auto;
height: 42px;
border-radius: 8px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormTextarea v-model="blockedHosts">
<span>{{ $ts.blockedInstances }}</span>
<template #desc>{{ $ts.blockedInstancesDescription }}</template>
</FormTextarea>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormTextarea from '@/components/debobigego/textarea.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormTextarea,
FormInfo,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.instanceBlocking,
icon: 'fas fa-ban',
bg: 'var(--bg)',
},
blockedHosts: '',
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.blockedHosts = meta.blockedHosts.join('\n');
},
save() {
os.apiWithDialog('admin/update-meta', {
blockedHosts: this.blockedHosts.split('\n') || [],
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,321 @@
<template>
<XModalWindow ref="dialog"
:width="520"
:height="500"
@close="$refs.dialog.close()"
@closed="$emit('closed')"
>
<template #header>{{ instance.host }}</template>
<div class="mk-instance-info">
<div class="_table section">
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.software }}</div>
<div class="_data">{{ instance.softwareName || '?' }}</div>
</div>
<div class="_cell">
<div class="_label">{{ $ts.version }}</div>
<div class="_data">{{ instance.softwareVersion || '?' }}</div>
</div>
</div>
</div>
<div class="_table data section">
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.registeredAt }}</div>
<div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div>
</div>
</div>
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.following }}</div>
<button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button>
</div>
<div class="_cell">
<div class="_label">{{ $ts.followers }}</div>
<button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button>
</div>
</div>
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.users }}</div>
<button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button>
</div>
<div class="_cell">
<div class="_label">{{ $ts.notes }}</div>
<div class="_data">{{ number(instance.notesCount) }}</div>
</div>
</div>
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.files }}</div>
<div class="_data">{{ number(instance.driveFiles) }}</div>
</div>
<div class="_cell">
<div class="_label">{{ $ts.storageUsage }}</div>
<div class="_data">{{ bytes(instance.driveUsage) }}</div>
</div>
</div>
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.latestRequestSentAt }}</div>
<div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
</div>
<div class="_cell">
<div class="_label">{{ $ts.latestStatus }}</div>
<div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
</div>
</div>
<div class="_row">
<div class="_cell">
<div class="_label">{{ $ts.latestRequestReceivedAt }}</div>
<div class="_data"><MkTime 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">{{ $ts.charts }}</span>
<div class="selects">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
<option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
<option value="instance-users">{{ $ts._instanceCharts.users }}</option>
<option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
<option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
<option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
<option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
<option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
<option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
<option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
<option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
<option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
</MkSelect>
<MkSelect v-model="chartSpan" style="margin: 0;">
<option value="hour">{{ $ts.perHour }}</option>
<option value="day">{{ $ts.perDay }}</option>
</MkSelect>
</div>
</div>
<div class="chart">
<MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
</div>
</div>
<div class="operations section">
<span class="label">{{ $ts.operations }}</span>
<MkSwitch v-model="isSuspended" class="switch">{{ $ts.stopActivityDelivery }}</MkSwitch>
<MkSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</MkSwitch>
<details>
<summary>{{ $ts.deleteAllFiles }}</summary>
<MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton>
</details>
<details>
<summary>{{ $ts.removeAllFollowing }}</summary>
<MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-minus-circle"></i> {{ $ts.removeAllFollowing }}</MkButton>
<MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo>
</details>
</div>
<details class="metadata section">
<summary class="label">{{ $ts.metadata }}</summary>
<pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre>
</details>
</div>
</XModalWindow>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import MkUsersDialog from '@/components/users-dialog.vue';
import MkSelect from '@/components/form/select.vue';
import MkButton from '@/components/ui/button.vue';
import MkSwitch from '@/components/form/switch.vue';
import MkInfo from '@/components/ui/info.vue';
import MkChart from '@/components/chart.vue';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import * as os from '@/os';
export default defineComponent({
components: {
XModalWindow,
MkSelect,
MkButton,
MkSwitch,
MkInfo,
MkChart,
},
props: {
instance: {
type: Object,
required: true
}
},
emits: ['closed'],
data() {
return {
isSuspended: this.instance.isSuspended,
chartSrc: 'requests',
chartSpan: 'hour',
};
},
computed: {
meta() {
return this.$instance;
},
isBlocked() {
return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host);
}
},
watch: {
isSuspended() {
os.api('admin/federation/update-instance', {
host: this.instance.host,
isSuspended: this.isSuspended
});
},
},
methods: {
changeBlock(e) {
os.api('admin/update-meta', {
blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
});
},
removeAllFollowing() {
os.apiWithDialog('admin/federation/remove-all-following', {
host: this.instance.host
});
},
deleteAllFiles() {
os.apiWithDialog('admin/federation/delete-all-files', {
host: this.instance.host
});
},
showFollowing() {
os.modal(MkUsersDialog, {
title: this.$ts.instanceFollowing,
pagination: {
endpoint: 'federation/following',
limit: 10,
params: {
host: this.instance.host
}
},
extract: item => item.follower
});
},
showFollowers() {
os.modal(MkUsersDialog, {
title: this.$ts.instanceFollowers,
pagination: {
endpoint: 'federation/followers',
limit: 10,
params: {
host: this.instance.host
}
},
extract: item => item.followee
});
},
showUsers() {
os.modal(MkUsersDialog, {
title: this.$ts.instanceUsers,
pagination: {
endpoint: 'federation/users',
limit: 10,
params: {
host: this.instance.host
}
}
});
},
bytes,
number
}
});
</script>
<style lang="scss" scoped>
.mk-instance-info {
overflow: auto;
> .section {
padding: 16px 32px;
@media (max-width: 500px) {
padding: 8px 16px;
}
&:not(:first-child) {
border-top: solid 0.5px var(--divider);
}
}
> .chart {
border-top: solid 0.5px var(--divider);
padding: 16px 0 12px 0;
> .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 {
> .label {
font-size: 80%;
opacity: 0.7;
}
> .switch {
margin: 16px 0;
}
}
> .metadata {
> .label {
font-size: 80%;
opacity: 0.7;
}
> pre > code {
display: block;
max-height: 200px;
overflow: auto;
}
}
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="enableDiscordIntegration">
{{ $ts.enable }}
</FormSwitch>
<template v-if="enableDiscordIntegration">
<FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo>
<FormInput v-model="discordClientId">
<template #prefix><i class="fas fa-key"></i></template>
Client ID
</FormInput>
<FormInput v-model="discordClientSecret">
<template #prefix><i class="fas fa-key"></i></template>
Client Secret
</FormInput>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormInfo,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: 'Discord',
icon: 'fab fa-discord'
},
enableDiscordIntegration: false,
discordClientId: null,
discordClientSecret: null,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableDiscordIntegration = meta.enableDiscordIntegration;
this.discordClientId = meta.discordClientId;
this.discordClientSecret = meta.discordClientSecret;
},
save() {
os.apiWithDialog('admin/update-meta', {
enableDiscordIntegration: this.enableDiscordIntegration,
discordClientId: this.discordClientId,
discordClientSecret: this.discordClientSecret,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,85 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="enableGithubIntegration">
{{ $ts.enable }}
</FormSwitch>
<template v-if="enableGithubIntegration">
<FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo>
<FormInput v-model="githubClientId">
<template #prefix><i class="fas fa-key"></i></template>
Client ID
</FormInput>
<FormInput v-model="githubClientSecret">
<template #prefix><i class="fas fa-key"></i></template>
Client Secret
</FormInput>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormInfo,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: 'GitHub',
icon: 'fab fa-github'
},
enableGithubIntegration: false,
githubClientId: null,
githubClientSecret: null,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableGithubIntegration = meta.enableGithubIntegration;
this.githubClientId = meta.githubClientId;
this.githubClientSecret = meta.githubClientSecret;
},
save() {
os.apiWithDialog('admin/update-meta', {
enableGithubIntegration: this.enableGithubIntegration,
githubClientId: this.githubClientId,
githubClientSecret: this.githubClientSecret,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,85 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="enableTwitterIntegration">
{{ $ts.enable }}
</FormSwitch>
<template v-if="enableTwitterIntegration">
<FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo>
<FormInput v-model="twitterConsumerKey">
<template #prefix><i class="fas fa-key"></i></template>
Consumer Key
</FormInput>
<FormInput v-model="twitterConsumerSecret">
<template #prefix><i class="fas fa-key"></i></template>
Consumer Secret
</FormInput>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormInfo,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: 'Twitter',
icon: 'fab fa-twitter'
},
enableTwitterIntegration: false,
twitterConsumerKey: null,
twitterConsumerSecret: null,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableTwitterIntegration = meta.enableTwitterIntegration;
this.twitterConsumerKey = meta.twitterConsumerKey;
this.twitterConsumerSecret = meta.twitterConsumerSecret;
},
save() {
os.apiWithDialog('admin/update-meta', {
enableTwitterIntegration: this.enableTwitterIntegration,
twitterConsumerKey: this.twitterConsumerKey,
twitterConsumerSecret: this.twitterConsumerSecret,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,74 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormLink to="/admin/integrations/twitter">
<i class="fab fa-twitter"></i> Twitter
<template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template>
</FormLink>
<FormLink to="/admin/integrations/github">
<i class="fab fa-github"></i> GitHub
<template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template>
</FormLink>
<FormLink to="/admin/integrations/discord">
<i class="fab fa-discord"></i> Discord
<template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template>
</FormLink>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormLink from '@/components/debobigego/link.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormTextarea from '@/components/debobigego/textarea.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormLink,
FormInput,
FormBase,
FormGroup,
FormButton,
FormTextarea,
FormInfo,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.integration,
icon: 'fas fa-share-alt',
bg: 'var(--bg)',
},
enableTwitterIntegration: false,
enableGithubIntegration: false,
enableDiscordIntegration: false,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableTwitterIntegration = meta.enableTwitterIntegration;
this.enableGithubIntegration = meta.enableGithubIntegration;
this.enableDiscordIntegration = meta.enableDiscordIntegration;
},
}
});
</script>

View File

@ -0,0 +1,472 @@
<template>
<div class="_debobigegoItem">
<div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
<div class="_debobigegoPanel xhexznfu">
<div>
<canvas :ref="cpumem"></canvas>
</div>
<div v-if="serverInfo">
<div class="_table">
<div class="_row">
<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
</div>
</div>
</div>
</div>
</div>
<div class="_debobigegoItem">
<div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
<div class="_debobigegoPanel xhexznfu">
<div>
<canvas :ref="disk"></canvas>
</div>
<div v-if="serverInfo">
<div class="_table">
<div class="_row">
<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
</div>
</div>
</div>
</div>
</div>
<div class="_debobigegoItem">
<div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
<div class="_debobigegoPanel xhexznfu">
<div>
<canvas :ref="net"></canvas>
</div>
<div v-if="serverInfo">
<div class="_table">
<div class="_row">
<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
import {
Chart,
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
CategoryScale,
LinearScale,
Legend,
Title,
Tooltip,
SubTitle
} from 'chart.js';
import MkButton from '@/components/ui/button.vue';
import MkSelect from '@/components/form/select.vue';
import MkInput from '@/components/form/input.vue';
import MkContainer from '@/components/ui/container.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkwFederation from '../../widgets/federation.vue';
import { version, url } from '@/config';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import MkInstanceInfo from './instance.vue';
Chart.register(
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
CategoryScale,
LinearScale,
Legend,
Title,
Tooltip,
SubTitle
);
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})`;
};
import * as os from '@/os';
export default defineComponent({
components: {
MkButton,
MkSelect,
MkInput,
MkContainer,
MkFolder,
MkwFederation,
},
data() {
return {
version,
url,
stats: null,
serverInfo: null,
connection: null,
queueConnection: markRaw(os.stream.useChannel('queueStats')),
memUsage: 0,
chartCpuMem: null,
chartNet: null,
jobs: [],
logs: [],
logLevel: 'all',
logDomain: '',
modLogs: [],
dbInfo: null,
overviewHeight: '1fr',
queueHeight: '1fr',
paused: false,
}
},
computed: {
gridColor() {
// TODO: var(--panel)の色が暗いか明るいかで判定する
return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
},
},
mounted() {
this.fetchJobs();
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
os.api('admin/server-info', {}).then(res => {
this.serverInfo = res;
this.connection = markRaw(os.stream.useChannel('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
});
this.$nextTick(() => {
this.queueConnection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 200
});
});
});
},
beforeUnmount() {
if (this.connection) {
this.connection.off('stats', this.onStats);
this.connection.off('statsLog', this.onStatsLog);
this.connection.dispose();
}
this.queueConnection.dispose();
},
methods: {
cpumem(el) {
if (this.chartCpuMem != null) return;
this.chartCpuMem = markRaw(new Chart(el, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'CPU',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#86b300',
backgroundColor: alpha('#86b300', 0.1),
data: []
}, {
label: 'MEM (active)',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#935dbf',
backgroundColor: alpha('#935dbf', 0.02),
data: []
}, {
label: 'MEM (used)',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#935dbf',
borderDash: [5, 5],
fill: false,
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 16,
right: 16,
top: 16,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
x: {
gridLines: {
display: false,
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
display: false,
}
},
y: {
position: 'right',
gridLines: {
display: true,
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
display: false,
max: 100
}
}
},
tooltips: {
intersect: false,
mode: 'index',
}
}
}));
},
net(el) {
if (this.chartNet != null) return;
this.chartNet = markRaw(new Chart(el, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'In',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#94a029',
backgroundColor: alpha('#94a029', 0.1),
data: []
}, {
label: 'Out',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#ff9156',
backgroundColor: alpha('#ff9156', 0.1),
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 16,
right: 16,
top: 16,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
x: {
gridLines: {
display: false,
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
display: false
}
},
y: {
position: 'right',
gridLines: {
display: true,
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
display: false,
}
}
},
tooltips: {
intersect: false,
mode: 'index',
}
}
}));
},
disk(el) {
if (this.chartDisk != null) return;
this.chartDisk = markRaw(new Chart(el, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Read',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#94a029',
backgroundColor: alpha('#94a029', 0.1),
data: []
}, {
label: 'Write',
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: '#ff9156',
backgroundColor: alpha('#ff9156', 0.1),
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 16,
right: 16,
top: 16,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
x: {
gridLines: {
display: false,
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
display: false
}
},
y: {
position: 'right',
gridLines: {
display: true,
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
display: false,
}
}
},
tooltips: {
intersect: false,
mode: 'index',
}
}
}));
},
fetchJobs() {
os.api('admin/queue/deliver-delayed', {}).then(jobs => {
this.jobs = jobs;
});
},
onStats(stats) {
if (this.paused) return;
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);
}
},
bytes,
number,
pause() {
this.paused = true;
},
resume() {
this.paused = false;
},
}
});
</script>
<style lang="scss" scoped>
.xhexznfu {
> div:nth-child(2) {
padding: 16px;
border-top: solid 0.5px var(--divider);
}
}
</style>

View File

@ -0,0 +1,155 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch>
<template v-if="useObjectStorage">
<FormInput v-model="objectStorageBaseUrl">
<span>{{ $ts.objectStorageBaseUrl }}</span>
<template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template>
</FormInput>
<FormInput v-model="objectStorageBucket">
<span>{{ $ts.objectStorageBucket }}</span>
<template #desc>{{ $ts.objectStorageBucketDesc }}</template>
</FormInput>
<FormInput v-model="objectStoragePrefix">
<span>{{ $ts.objectStoragePrefix }}</span>
<template #desc>{{ $ts.objectStoragePrefixDesc }}</template>
</FormInput>
<FormInput v-model="objectStorageEndpoint">
<span>{{ $ts.objectStorageEndpoint }}</span>
<template #desc>{{ $ts.objectStorageEndpointDesc }}</template>
</FormInput>
<FormInput v-model="objectStorageRegion">
<span>{{ $ts.objectStorageRegion }}</span>
<template #desc>{{ $ts.objectStorageRegionDesc }}</template>
</FormInput>
<FormInput v-model="objectStorageAccessKey">
<template #prefix><i class="fas fa-key"></i></template>
<span>Access key</span>
</FormInput>
<FormInput v-model="objectStorageSecretKey">
<template #prefix><i class="fas fa-key"></i></template>
<span>Secret key</span>
</FormInput>
<FormSwitch v-model="objectStorageUseSSL">
{{ $ts.objectStorageUseSSL }}
<template #desc>{{ $ts.objectStorageUseSSLDesc }}</template>
</FormSwitch>
<FormSwitch v-model="objectStorageUseProxy">
{{ $ts.objectStorageUseProxy }}
<template #desc>{{ $ts.objectStorageUseProxyDesc }}</template>
</FormSwitch>
<FormSwitch v-model="objectStorageSetPublicRead">
{{ $ts.objectStorageSetPublicRead }}
</FormSwitch>
<FormSwitch v-model="objectStorageS3ForcePathStyle">
s3ForcePathStyle
</FormSwitch>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.objectStorage,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
},
useObjectStorage: false,
objectStorageBaseUrl: null,
objectStorageBucket: null,
objectStoragePrefix: null,
objectStorageEndpoint: null,
objectStorageRegion: null,
objectStoragePort: null,
objectStorageAccessKey: null,
objectStorageSecretKey: null,
objectStorageUseSSL: false,
objectStorageUseProxy: false,
objectStorageSetPublicRead: false,
objectStorageS3ForcePathStyle: true,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.useObjectStorage = meta.useObjectStorage;
this.objectStorageBaseUrl = meta.objectStorageBaseUrl;
this.objectStorageBucket = meta.objectStorageBucket;
this.objectStoragePrefix = meta.objectStoragePrefix;
this.objectStorageEndpoint = meta.objectStorageEndpoint;
this.objectStorageRegion = meta.objectStorageRegion;
this.objectStoragePort = meta.objectStoragePort;
this.objectStorageAccessKey = meta.objectStorageAccessKey;
this.objectStorageSecretKey = meta.objectStorageSecretKey;
this.objectStorageUseSSL = meta.objectStorageUseSSL;
this.objectStorageUseProxy = meta.objectStorageUseProxy;
this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
},
save() {
os.apiWithDialog('admin/update-meta', {
useObjectStorage: this.useObjectStorage,
objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
objectStorageUseSSL: this.objectStorageUseSSL,
objectStorageUseProxy: this.objectStorageUseProxy,
objectStorageSetPublicRead: this.objectStorageSetPublicRead,
objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,83 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormGroup>
<FormInput v-model="summalyProxy">
<template #prefix><i class="fas fa-link"></i></template>
Summaly Proxy URL
</FormInput>
</FormGroup>
<FormGroup>
<FormInput v-model="deeplAuthKey">
<template #prefix><i class="fas fa-key"></i></template>
DeepL Auth Key
</FormInput>
<FormSwitch v-model="deeplIsPro">
Pro account
</FormSwitch>
</FormGroup>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.other,
icon: 'fas fa-cogs',
bg: 'var(--bg)',
},
summalyProxy: '',
deeplAuthKey: '',
deeplIsPro: false,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.summalyProxy = meta.summalyProxy;
this.deeplAuthKey = meta.deeplAuthKey;
this.deeplIsPro = meta.deeplIsPro;
},
save() {
os.apiWithDialog('admin/update-meta', {
summalyProxy: this.summalyProxy,
deeplAuthKey: this.deeplAuthKey,
deeplIsPro: this.deeplIsPro,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,236 @@
<template>
<div class="edbbcaef" v-size="{ max: [740] }">
<div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
<div class="number _panel">
<div class="label">Users</div>
<div class="value _monospace">
{{ number(stats.originalUsersCount) }}
<MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
</div>
</div>
<div class="number _panel">
<div class="label">Notes</div>
<div class="value _monospace">
{{ number(stats.originalNotesCount) }}
<MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
</div>
</div>
</div>
<MkContainer :foldable="true" class="charts">
<template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template>
<div style="padding-top: 12px;">
<MkInstanceStats :chart-limit="500" :detailed="true"/>
</div>
</MkContainer>
<div class="queue">
<MkContainer :foldable="true" :thin="true" class="deliver">
<template #header>Queue: deliver</template>
<MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
</MkContainer>
<MkContainer :foldable="true" :thin="true" class="inbox">
<template #header>Queue: inbox</template>
<MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
</MkContainer>
</div>
<!--<XMetrics/>-->
<MkFolder style="margin: var(--margin)">
<template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template>
<div class="cfcdecdf">
<div class="number _panel">
<div class="label">Misskey</div>
<div class="value _monospace">{{ version }}</div>
</div>
<div class="number _panel" v-if="serverInfo">
<div class="label">Node.js</div>
<div class="value _monospace">{{ serverInfo.node }}</div>
</div>
<div class="number _panel" v-if="serverInfo">
<div class="label">PostgreSQL</div>
<div class="value _monospace">{{ serverInfo.psql }}</div>
</div>
<div class="number _panel" v-if="serverInfo">
<div class="label">Redis</div>
<div class="value _monospace">{{ serverInfo.redis }}</div>
</div>
<div class="number _panel">
<div class="label">Vue</div>
<div class="value _monospace">{{ vueVersion }}</div>
</div>
</div>
</MkFolder>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, markRaw, version as vueVersion } from 'vue';
import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
import MkInstanceStats from '@/components/instance-stats.vue';
import MkButton from '@/components/ui/button.vue';
import MkSelect from '@/components/form/select.vue';
import MkNumberDiff from '@/components/number-diff.vue';
import MkContainer from '@/components/ui/container.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkQueueChart from '@/components/queue-chart.vue';
import { version, url } from '@/config';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import MkInstanceInfo from './instance.vue';
import XMetrics from './metrics.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
MkNumberDiff,
FormKeyValueView,
MkInstanceStats,
MkContainer,
MkFolder,
MkQueueChart,
XMetrics,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.dashboard,
icon: 'fas fa-tachometer-alt',
bg: 'var(--bg)',
},
version,
vueVersion,
url,
stats: null,
meta: null,
serverInfo: null,
usersComparedToThePrevDay: null,
notesComparedToThePrevDay: null,
fetchJobs: () => os.api('admin/queue/deliver-delayed', {}),
fetchModLogs: () => os.api('admin/show-moderation-logs', {}),
queueStatsConnection: markRaw(os.stream.useChannel('queueStats')),
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
os.api('meta', { detail: true }).then(meta => {
this.meta = meta;
});
os.api('stats', {}).then(stats => {
this.stats = stats;
os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1];
});
os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1];
});
});
os.api('admin/server-info', {}).then(serverInfo => {
this.serverInfo = serverInfo;
});
this.$nextTick(() => {
this.queueStatsConnection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 200
});
});
},
beforeUnmount() {
this.queueStatsConnection.dispose();
},
methods: {
async showInstanceInfo(q) {
let instance = q;
if (typeof q === 'string') {
instance = await os.api('federation/show-instance', {
host: q
});
}
os.popup(MkInstanceInfo, {
instance: instance
}, {}, 'closed');
},
bytes,
number,
}
});
</script>
<style lang="scss" scoped>
.edbbcaef {
.cfcdecdf {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
> .number {
padding: 12px 16px;
> .label {
opacity: 0.7;
font-size: 0.8em;
}
> .value {
font-weight: bold;
font-size: 1.2em;
> .diff {
font-size: 0.8em;
}
}
}
}
> .charts {
margin: var(--margin);
}
> .queue {
margin: var(--margin);
display: flex;
> .deliver,
> .inbox {
flex: 1;
width: 50%;
&:not(:first-child) {
margin-left: var(--margin);
}
}
}
&.max-width_740px {
> .queue {
display: block;
> .deliver,
> .inbox {
width: 100%;
&:not(:first-child) {
margin-top: var(--margin);
margin-left: 0;
}
}
}
}
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormGroup>
<FormKeyValueView>
<template #key>{{ $ts.proxyAccount }}</template>
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template>
</FormKeyValueView>
<template #caption>{{ $ts.proxyAccountDescription }}</template>
</FormGroup>
<FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormTextarea from '@/components/debobigego/textarea.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormKeyValueView,
FormInput,
FormBase,
FormGroup,
FormButton,
FormTextarea,
FormInfo,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.proxyAccount,
icon: 'fas fa-ghost',
bg: 'var(--bg)',
},
proxyAccount: null,
proxyAccountId: null,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.proxyAccountId = meta.proxyAccountId;
if (this.proxyAccountId) {
this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId });
}
},
chooseProxyAccount() {
os.selectUser().then(user => {
this.proxyAccount = user;
this.proxyAccountId = user.id;
this.save();
});
},
save() {
os.apiWithDialog('admin/update-meta', {
proxyAccountId: this.proxyAccountId,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,102 @@
<template>
<div class="_debobigegoItem">
<div class="_debobigegoLabel"><slot name="title"></slot></div>
<div class="_debobigegoPanel pumxzjhg">
<div class="_table status">
<div class="_row">
<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
</div>
</div>
<div class="">
<MkQueueChart :domain="domain" :connection="connection"/>
</div>
<div class="jobs">
<div v-if="jobs.length > 0">
<div v-for="job in jobs" :key="job[0]">
<span>{{ job[0] }}</span>
<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
</div>
</div>
<span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue';
import number from '@/filters/number';
import MkQueueChart from '@/components/queue-chart.vue';
import * as os from '@/os';
export default defineComponent({
components: {
MkQueueChart
},
props: {
domain: {
type: String,
required: true,
},
connection: {
required: true,
},
},
setup(props) {
const activeSincePrevTick = ref(0);
const active = ref(0);
const waiting = ref(0);
const delayed = ref(0);
const jobs = ref([]);
onMounted(() => {
os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
jobs.value = result;
});
const onStats = (stats) => {
activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
active.value = stats[props.domain].active;
waiting.value = stats[props.domain].waiting;
delayed.value = stats[props.domain].delayed;
};
props.connection.on('stats', onStats);
onUnmounted(() => {
props.connection.off('stats', onStats);
});
});
return {
jobs,
activeSincePrevTick,
active,
waiting,
delayed,
number,
};
},
});
</script>
<style lang="scss" scoped>
.pumxzjhg {
> .status {
padding: 16px;
border-bottom: solid 0.5px var(--divider);
}
> .jobs {
padding: 16px;
border-top: solid 0.5px var(--divider);
max-height: 180px;
overflow: auto;
}
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<FormBase>
<XQueue :connection="connection" domain="inbox">
<template #title>In</template>
</XQueue>
<XQueue :connection="connection" domain="deliver">
<template #title>Out</template>
</XQueue>
<FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton>
</FormBase>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
import MkButton from '@/components/ui/button.vue';
import XQueue from './queue.chart.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormButton from '@/components/debobigego/button.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
FormBase,
FormButton,
MkButton,
XQueue,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.jobQueue,
icon: 'fas fa-clipboard-list',
bg: 'var(--bg)',
},
connection: markRaw(os.stream.useChannel('queueStats')),
}
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
this.$nextTick(() => {
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 200
});
});
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
clear() {
os.dialog({
type: 'warning',
title: this.$ts.clearQueueConfirmTitle,
text: this.$ts.clearQueueConfirmText,
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
os.apiWithDialog('admin/queue/clear', {});
});
}
}
});
</script>

View File

@ -0,0 +1,99 @@
<template>
<FormBase class="relaycxt">
<FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton>
<div class="_debobigegoItem" v-for="relay in relays" :key="relay.inbox">
<div class="_debobigegoPanel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
<div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
</div>
</div>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormButton from '@/components/debobigego/button.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
FormBase,
FormButton,
MkButton,
MkInput,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.relays,
icon: 'fas fa-globe',
bg: 'var(--bg)',
},
relays: [],
inbox: '',
}
},
created() {
this.refresh();
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async addRelay() {
const { canceled, result: inbox } = await os.dialog({
title: this.$ts.addRelay,
input: {
placeholder: this.$ts.inboxUrl
}
});
if (canceled) return;
os.api('admin/relays/add', {
inbox
}).then((relay: any) => {
this.refresh();
}).catch((e: any) => {
os.dialog({
type: 'error',
text: e.message || e
});
});
},
remove(inbox: string) {
os.api('admin/relays/remove', {
inbox
}).then(() => {
this.refresh();
}).catch((e: any) => {
os.dialog({
type: 'error',
text: e.message || e
});
});
},
refresh() {
os.api('admin/relays/list').then((relays: any) => {
this.relays = relays;
});
}
}
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,83 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormLink to="/admin/bot-protection">
<i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}
<template #suffix v-if="enableHcaptcha">hCaptcha</template>
<template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template>
<template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template>
</FormLink>
<FormSwitch v-model="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch>
<FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</FormSwitch>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
import FormLink from '@/components/debobigego/link.vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormLink,
FormSwitch,
FormBase,
FormGroup,
FormButton,
FormInfo,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.security,
icon: 'fas fa-lock',
bg: 'var(--bg)',
},
enableHcaptcha: false,
enableRecaptcha: false,
enableRegistration: false,
emailRequiredForSignup: false,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableHcaptcha = meta.enableHcaptcha;
this.enableRecaptcha = meta.enableRecaptcha;
this.enableRegistration = !meta.disableRegistration;
this.emailRequiredForSignup = meta.emailRequiredForSignup;
},
save() {
os.apiWithDialog('admin/update-meta', {
disableRegistration: !this.enableRegistration,
emailRequiredForSignup: this.emailRequiredForSignup,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,85 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormSwitch v-model="enableServiceWorker">
{{ $ts.enableServiceworker }}
<template #desc>{{ $ts.serviceworkerInfo }}</template>
</FormSwitch>
<template v-if="enableServiceWorker">
<FormInput v-model="swPublicKey">
<template #prefix><i class="fas fa-key"></i></template>
Public key
</FormInput>
<FormInput v-model="swPrivateKey">
<template #prefix><i class="fas fa-key"></i></template>
Private key
</FormInput>
</template>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: 'ServiceWorker',
icon: 'fas fa-bolt',
bg: 'var(--bg)',
},
enableServiceWorker: false,
swPublicKey: null,
swPrivateKey: null,
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.enableServiceWorker = meta.enableServiceWorker;
this.swPublicKey = meta.swPublickey;
this.swPrivateKey = meta.swPrivateKey;
},
save() {
os.apiWithDialog('admin/update-meta', {
enableServiceWorker: this.enableServiceWorker,
swPublicKey: this.swPublicKey,
swPrivateKey: this.swPrivateKey,
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,151 @@
<template>
<FormBase>
<FormSuspense :p="init">
<FormInput v-model="name">
<span>{{ $ts.instanceName }}</span>
</FormInput>
<FormTextarea v-model="description">
<span>{{ $ts.instanceDescription }}</span>
</FormTextarea>
<FormInput v-model="iconUrl">
<template #prefix><i class="fas fa-link"></i></template>
<span>{{ $ts.iconUrl }}</span>
</FormInput>
<FormInput v-model="bannerUrl">
<template #prefix><i class="fas fa-link"></i></template>
<span>{{ $ts.bannerUrl }}</span>
</FormInput>
<FormInput v-model="backgroundImageUrl">
<template #prefix><i class="fas fa-link"></i></template>
<span>{{ $ts.backgroundImageUrl }}</span>
</FormInput>
<FormInput v-model="tosUrl">
<template #prefix><i class="fas fa-link"></i></template>
<span>{{ $ts.tosUrl }}</span>
</FormInput>
<FormInput v-model="maintainerName">
<span>{{ $ts.maintainerName }}</span>
</FormInput>
<FormInput v-model="maintainerEmail" type="email">
<template #prefix><i class="fas fa-envelope"></i></template>
<span>{{ $ts.maintainerEmail }}</span>
</FormInput>
<FormTextarea v-model="pinnedUsers">
<span>{{ $ts.pinnedUsers }}</span>
<template #desc>{{ $ts.pinnedUsersDescription }}</template>
</FormTextarea>
<FormInput v-model="maxNoteTextLength" type="number">
<template #prefix><i class="fas fa-pencil-alt"></i></template>
<span>{{ $ts.maxNoteTextLength }}</span>
</FormInput>
<FormSwitch v-model="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch>
<FormSwitch v-model="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch>
<FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormSwitch from '@/components/debobigego/switch.vue';
import FormInput from '@/components/debobigego/input.vue';
import FormButton from '@/components/debobigego/button.vue';
import FormBase from '@/components/debobigego/base.vue';
import FormGroup from '@/components/debobigego/group.vue';
import FormTextarea from '@/components/debobigego/textarea.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormSuspense from '@/components/debobigego/suspense.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
export default defineComponent({
components: {
FormSwitch,
FormInput,
FormBase,
FormGroup,
FormButton,
FormTextarea,
FormInfo,
FormSuspense,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.general,
icon: 'fas fa-cog',
bg: 'var(--bg)',
},
name: null,
description: null,
tosUrl: null as string | null,
maintainerName: null,
maintainerEmail: null,
iconUrl: null,
bannerUrl: null,
backgroundImageUrl: null,
maxNoteTextLength: 0,
enableLocalTimeline: false,
enableGlobalTimeline: false,
pinnedUsers: '',
}
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
async init() {
const meta = await os.api('meta', { detail: true });
this.name = meta.name;
this.description = meta.description;
this.tosUrl = meta.tosUrl;
this.iconUrl = meta.iconUrl;
this.bannerUrl = meta.bannerUrl;
this.backgroundImageUrl = meta.backgroundImageUrl;
this.maintainerName = meta.maintainerName;
this.maintainerEmail = meta.maintainerEmail;
this.maxNoteTextLength = meta.maxNoteTextLength;
this.enableLocalTimeline = !meta.disableLocalTimeline;
this.enableGlobalTimeline = !meta.disableGlobalTimeline;
this.pinnedUsers = meta.pinnedUsers.join('\n');
},
save() {
os.apiWithDialog('admin/update-meta', {
name: this.name,
description: this.description,
tosUrl: this.tosUrl,
iconUrl: this.iconUrl,
bannerUrl: this.bannerUrl,
backgroundImageUrl: this.backgroundImageUrl,
maintainerName: this.maintainerName,
maintainerEmail: this.maintainerEmail,
maxNoteTextLength: this.maxNoteTextLength,
disableLocalTimeline: !this.enableLocalTimeline,
disableGlobalTimeline: !this.enableGlobalTimeline,
pinnedUsers: this.pinnedUsers.split('\n'),
}).then(() => {
fetchInstance();
});
}
}
});
</script>

View File

@ -0,0 +1,254 @@
<template>
<div class="lknzcolw">
<div class="users">
<div class="inputs">
<MkSelect v-model="sort" style="flex: 1;">
<template #label>{{ $ts.sort }}</template>
<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
</MkSelect>
<MkSelect v-model="state" style="flex: 1;">
<template #label>{{ $ts.state }}</template>
<option value="all">{{ $ts.all }}</option>
<option value="available">{{ $ts.normal }}</option>
<option value="admin">{{ $ts.administrator }}</option>
<option value="moderator">{{ $ts.moderator }}</option>
<option value="silenced">{{ $ts.silence }}</option>
<option value="suspended">{{ $ts.suspend }}</option>
</MkSelect>
<MkSelect v-model="origin" style="flex: 1;">
<template #label>{{ $ts.instance }}</template>
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkSelect>
</div>
<div class="inputs">
<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ $ts.username }}</template>
</MkInput>
<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
<template #prefix>@</template>
<template #label>{{ $ts.host }}</template>
</MkInput>
</div>
<MkPagination :pagination="pagination" #default="{items}" class="users" ref="users">
<button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)">
<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<div class="body">
<header>
<MkUserName class="name" :user="user"/>
<span class="acct">@{{ acct(user) }}</span>
<span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
<span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
<span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
<span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
</header>
<div>
<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
</div>
<div>
<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
</div>
</div>
</button>
</MkPagination>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { acct } from '@/filters/user';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { lookupUser } from '@/scripts/lookup-user';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkPagination,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.users,
icon: 'fas fa-users',
bg: 'var(--bg)',
actions: [{
icon: 'fas fa-search',
text: this.$ts.search,
handler: this.searchUser
}, {
asFullButton: true,
icon: 'fas fa-plus',
text: this.$ts.addUser,
handler: this.addUser
}, {
asFullButton: true,
icon: 'fas fa-search',
text: this.$ts.lookup,
handler: this.lookupUser
}],
},
sort: '+createdAt',
state: 'all',
origin: 'local',
searchUsername: '',
searchHost: '',
pagination: {
endpoint: 'admin/show-users',
limit: 10,
params: () => ({
sort: this.sort,
state: this.state,
origin: this.origin,
username: this.searchUsername,
hostname: this.searchHost,
}),
offsetMode: true
},
}
},
watch: {
sort() {
this.$refs.users.reload();
},
state() {
this.$refs.users.reload();
},
origin() {
this.$refs.users.reload();
},
},
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
lookupUser,
searchUser() {
os.selectUser().then(user => {
this.show(user);
});
},
async addUser() {
const { canceled: canceled1, result: username } = await os.dialog({
title: this.$ts.username,
input: true
});
if (canceled1) return;
const { canceled: canceled2, result: password } = await os.dialog({
title: this.$ts.password,
input: { type: 'password' }
});
if (canceled2) return;
os.apiWithDialog('admin/accounts/create', {
username: username,
password: password,
}).then(res => {
this.$refs.users.reload();
});
},
show(user) {
os.pageWindow(`/user-info/${user.id}`);
},
acct
}
});
</script>
<style lang="scss" scoped>
.lknzcolw {
> .users {
margin: var(--margin);
> .inputs {
display: flex;
margin-bottom: 16px;
> * {
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
}
> .users {
margin-top: var(--margin);
> .user {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
padding: 16px;
&:hover {
color: var(--accent);
}
> .avatar {
width: 60px;
height: 60px;
}
> .body {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
}
> header {
> .name {
font-weight: bold;
}
> .acct {
margin-left: 8px;
opacity: 0.7;
}
> .staff {
margin-left: 0.5em;
color: var(--badge);
}
> .punished {
margin-left: 0.5em;
color: #4dabf7;
}
}
}
}
}
}
}
</style>