diff options
Diffstat (limited to 'src/client/pages/instance')
| -rw-r--r-- | src/client/pages/instance/announcements.vue | 97 | ||||
| -rw-r--r-- | src/client/pages/instance/emoji-edit-dialog.vue | 116 | ||||
| -rw-r--r-- | src/client/pages/instance/emojis.vue | 307 | ||||
| -rw-r--r-- | src/client/pages/instance/federation.vue | 143 | ||||
| -rw-r--r-- | src/client/pages/instance/file-dialog.vue | 136 | ||||
| -rw-r--r-- | src/client/pages/instance/files.vue | 184 | ||||
| -rw-r--r-- | src/client/pages/instance/index.metrics.vue | 576 | ||||
| -rw-r--r-- | src/client/pages/instance/index.queue-chart.vue | 198 | ||||
| -rw-r--r-- | src/client/pages/instance/index.vue | 768 | ||||
| -rw-r--r-- | src/client/pages/instance/instance.vue | 164 | ||||
| -rw-r--r-- | src/client/pages/instance/logs.vue | 95 | ||||
| -rw-r--r-- | src/client/pages/instance/queue.chart.vue | 24 | ||||
| -rw-r--r-- | src/client/pages/instance/queue.vue | 51 | ||||
| -rw-r--r-- | src/client/pages/instance/relays.vue | 50 | ||||
| -rw-r--r-- | src/client/pages/instance/settings.vue | 284 | ||||
| -rw-r--r-- | src/client/pages/instance/user-dialog.vue | 233 | ||||
| -rw-r--r-- | src/client/pages/instance/users.user.vue | 206 | ||||
| -rw-r--r-- | src/client/pages/instance/users.vue | 165 |
18 files changed, 2002 insertions, 1795 deletions
diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue index 0e11e2932e..7abec88042 100644 --- a/src/client/pages/instance/announcements.vue +++ b/src/client/pages/instance/announcements.vue @@ -1,44 +1,41 @@ <template> <div class="ztgjmzrw"> - <portal to="icon"><fa :icon="faBroadcastTower"/></portal> - <portal to="title">{{ $t('announcements') }}</portal> - <mk-button @click="add()" primary style="margin: 0 auto 16px auto;"><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> - <section class="_card announcements"> - <div class="_content announcement" v-for="announcement in announcements"> - <mk-input v-model="announcement.title"> - <span>{{ $t('title') }}</span> - </mk-input> - <mk-textarea v-model="announcement.text"> - <span>{{ $t('text') }}</span> - </mk-textarea> - <mk-input v-model="announcement.imageUrl"> - <span>{{ $t('imageUrl') }}</span> - </mk-input> - <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> - <div class="buttons"> - <mk-button class="button" inline @click="save(announcement)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button> - <mk-button class="button" inline @click="remove(announcement)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button> - </div> + <div class="_section"> + <div class="_content"> + <MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><Fa :icon="faPlus"/> {{ $t('add') }}</MkButton> + <section class="_card _vMargin announcements" v-for="announcement in announcements"> + <div class="_content announcement"> + <MkInput v-model:value="announcement.title"> + <span>{{ $t('title') }}</span> + </MkInput> + <MkTextarea v-model:value="announcement.text"> + <span>{{ $t('text') }}</span> + </MkTextarea> + <MkInput v-model:value="announcement.imageUrl"> + <span>{{ $t('imageUrl') }}</span> + </MkInput> + <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline @click="save(announcement)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><Fa :icon="faTrashAlt"/> {{ $t('remove') }}</MkButton> + </div> + </div> + </section> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('announcements') as string - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, MkInput, @@ -47,13 +44,19 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('announcements'), + icon: faBroadcastTower + }] + }, announcements: [], faBroadcastTower, faSave, faTrashAlt, faPlus } }, created() { - this.$root.api('admin/announcements/list').then(announcements => { + os.api('admin/announcements/list').then(announcements => { this.announcements = announcements; }); }, @@ -69,38 +72,38 @@ export default Vue.extend({ }, remove(announcement) { - this.$root.dialog({ + 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); - this.$root.api('admin/announcements/delete', announcement); + os.api('admin/announcements/delete', announcement); }); }, save(announcement) { if (announcement.id == null) { - this.$root.api('admin/announcements/create', announcement).then(() => { - this.$root.dialog({ + os.api('admin/announcements/create', announcement).then(() => { + os.dialog({ type: 'success', text: this.$t('saved') }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); }); } else { - this.$root.api('admin/announcements/update', announcement).then(() => { - this.$root.dialog({ + os.api('admin/announcements/update', announcement).then(() => { + os.dialog({ type: 'success', text: this.$t('saved') }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -110,17 +113,3 @@ export default Vue.extend({ } }); </script> - -<style lang="scss" scoped> -.ztgjmzrw { - > .announcements { - > .announcement { - > .buttons { - > .button:first-child { - margin-right: 8px; - } - } - } - } -} -</style> diff --git a/src/client/pages/instance/emoji-edit-dialog.vue b/src/client/pages/instance/emoji-edit-dialog.vue new file mode 100644 index 0000000000..ed81f15f6e --- /dev/null +++ b/src/client/pages/instance/emoji-edit-dialog.vue @@ -0,0 +1,116 @@ +<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="yigymqpb _section"> + <img :src="emoji.url" class="img"/> + <MkInput v-model:value="name"><span>{{ $t('name') }}</span></MkInput> + <MkInput v-model:value="category" :datalist="categories"><span>{{ $t('category') }}</span></MkInput> + <MkInput v-model:value="aliases"> + <span>{{ $t('tags') }}</span> + <template #desc>{{ $t('setMultipleBySeparatingWithSpace') }}</template> + </MkInput> + <MkButton danger @click="del()"><Fa :icon="faTrashAlt"/> {{ $t('delete') }}</MkButton> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; +import { unique } from '../../../prelude/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: [], + faTrashAlt, + } + }, + + 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> diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue index 25897ea7d9..465a9ebe00 100644 --- a/src/client/pages/instance/emojis.vue +++ b/src/client/pages/instance/emojis.vue @@ -1,80 +1,67 @@ <template> <div class="mk-instance-emojis"> - <portal to="icon"><fa :icon="faLaugh"/></portal> - <portal to="title">{{ $t('customEmojis') }}</portal> + <div class="_section" style="padding: 0;"> + <MkTab v-model:value="tab" :items="[{ label: $t('local'), value: 'local' }, { label: $t('remote'), value: 'remote' }]"/> + </div> - <section class="_card _vMargin local"> - <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div> - <div class="_content"> - <mk-pagination :pagination="pagination" class="emojis" ref="emojis"> + <div class="_section"> + <div class="_content local" v-if="tab === 'local'"> + <MkButton primary @click="add" style="margin: 0 auto var(--margin) auto;"><Fa :icon="faPlus"/> {{ $t('addEmoji') }}</MkButton> + <MkInput v-model:value="query" :debounce="true" type="search"><template #icon><Fa :icon="faSearch"/></template><span>{{ $t('search') }}</span></MkInput> + <MkPagination :pagination="pagination" ref="emojis"> <template #empty><span>{{ $t('noCustomEmojis') }}</span></template> <template #default="{items}"> - <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" @click="selected = emoji" :class="{ selected: selected && (selected.id === emoji.id) }"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <span class="name">{{ emoji.name }}</span> - <span class="info"> - <b class="category">{{ emoji.category }}</b> - <span class="aliases">{{ emoji.aliases.join(' ') }}</span> - </span> - </div> + <div class="emojis"> + <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"> + <span class="name">{{ emoji.name }}</span> + <span class="info"> + <span class="category">{{ emoji.category }}</span> + </span> + </div> + </button> </div> </template> - </mk-pagination> - </div> - <div class="_content" v-if="selected"> - <mk-input v-model="name"><span>{{ $t('name') }}</span></mk-input> - <mk-input v-model="category" :datalist="categories"><span>{{ $t('category') }}</span></mk-input> - <mk-input v-model="aliases"><span>{{ $t('tags') }}</span></mk-input> - <mk-button inline primary @click="update"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> - <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button> - </div> - <div class="_footer"> - <mk-button inline primary @click="add"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button> + </MkPagination> </div> - </section> - <section class="_card _vMargin remote"> - <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div> - <div class="_content"> - <mk-input v-model="host" :debounce="true"><span>{{ $t('host') }}</span></mk-input> - <mk-pagination :pagination="remotePagination" class="emojis" ref="remoteEmojis"> + + <div class="_content remote" v-else-if="tab === 'remote'"> + <MkInput v-model:value="queryRemote" :debounce="true" type="search"><template #icon><Fa :icon="faSearch"/></template><span>{{ $t('search') }}</span></MkInput> + <MkInput v-model:value="host" :debounce="true"><span>{{ $t('host') }}</span></MkInput> + <MkPagination :pagination="remotePagination" ref="remoteEmojis"> <template #empty><span>{{ $t('noCustomEmojis') }}</span></template> <template #default="{items}"> - <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" @click="selectedRemote = emoji" :class="{ selected: selectedRemote && (selectedRemote.id === emoji.id) }"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <span class="name">{{ emoji.name }}</span> - <span class="info">{{ emoji.host }}</span> + <div class="emojis"> + <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"> + <span class="name">{{ emoji.name }}</span> + <span class="info">{{ emoji.host }}</span> + </div> </div> </div> </template> - </mk-pagination> - </div> - <div class="_footer"> - <mk-button inline primary :disabled="selectedRemote == null" @click="im()"><fa :icon="faPlus"/> {{ $t('import') }}</mk-button> + </MkPagination> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faPlus, faSave } from '@fortawesome/free-solid-svg-icons'; +import { computed, defineComponent } from 'vue'; +import { faPlus, faSave, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt, faLaugh } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkPagination from '../../components/ui/pagination.vue'; -import { selectFile } from '../../scripts/select-file'; -import { unique } from '../../../prelude/array'; - -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('customEmojis')} | ${this.$t('instance')}` - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/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'; +export default defineComponent({ components: { + MkTab, MkButton, MkInput, MkPagination, @@ -82,54 +69,44 @@ export default Vue.extend({ data() { return { - selected: null, - selectedRemote: null, - name: null, - category: null, - aliases: null, + INFO: { + header: [{ + title: this.$t('customEmojis'), + icon: faLaugh + }], + action: { + icon: faPlus, + handler: this.add + } + }, + tab: 'local', + query: null, + queryRemote: null, host: '', pagination: { endpoint: 'admin/emoji/list', - limit: 10, + limit: 15, + params: computed(() => ({ + query: (this.query && this.query !== '') ? this.query : null + })) }, remotePagination: { endpoint: 'admin/emoji/list-remote', - limit: 10, - params: () => ({ - host: this.host ? this.host : null - }) + limit: 15, + params: computed(() => ({ + query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null, + host: (this.host && this.host !== '') ? this.host : null + })) }, - faTrashAlt, faPlus, faLaugh, faSave - } - }, - - computed: { - categories() { - if (this.$store.state.instance.meta) { - return unique(this.$store.state.instance.meta.emojis.map((x: any) => x.category || '').filter((x: string) => x !== '')); - } else { - return []; - } - } - }, - - watch: { - host() { - this.$refs.remoteEmojis.reload(); - }, - - selected() { - this.name = this.selected ? this.selected.name : null; - this.category = this.selected ? this.selected.category : null; - this.aliases = this.selected ? this.selected.aliases.join(' ') : null; + faTrashAlt, faPlus, faLaugh, faSave, faSearch, } }, methods: { async add(e) { - const files = await selectFile(this, e.currentTarget || e.target, null, true); + const files = await selectFile(e.currentTarget || e.target, null, true); - const dialog = this.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', text: this.$t('doing') + '...', showOkButton: false, @@ -137,133 +114,112 @@ export default Vue.extend({ cancelableByBgClick: false }); - Promise.all(files.map(file => this.$root.api('admin/emoji/add', { + Promise.all(files.map(file => os.api('admin/emoji/add', { fileId: file.id, }))) .then(() => { this.$refs.emojis.reload(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }) .finally(() => { - dialog.close(); + dialog.cancel(); }); }, - async update() { - await this.$root.api('admin/emoji/update', { - id: this.selected.id, - name: this.name, - category: this.category, - aliases: this.aliases.split(' '), - }); - - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - - this.$refs.emojis.reload(); + async edit(emoji) { + os.popup(await 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'); }, - async del() { - const { canceled } = await this.$root.dialog({ - type: 'warning', - text: this.$t('removeAreYouSure', { x: this.selected.name }), - showCancelButton: true - }); - if (canceled) return; - - this.$root.api('admin/emoji/remove', { - id: this.selected.id - }).then(() => { - this.$refs.emojis.reload(); + im(emoji) { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, }); }, - im() { - this.$root.api('admin/emoji/copy', { - emojiId: this.selectedRemote.id, - }).then(() => { - this.$refs.emojis.reload(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, + remoteMenu(emoji, ev) { + os.modalMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: this.$t('import'), + icon: faPlus, + action: () => { this.im(emoji) } + }], ev.currentTarget || ev.target); + } } }); </script> <style lang="scss" scoped> .mk-instance-emojis { - > .local { - > ._content { - max-height: 300px; - overflow: auto; - - > .emojis { + > ._section { + > .local { + .emojis { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: var(--margin); + > .emoji { display: flex; align-items: center; + padding: 12px; + text-align: left; - &.selected { - background: var(--accent); - box-shadow: 0 0 0 8px var(--accent); - color: #fff; + &:hover { + color: var(--accent); } > .img { - width: 50px; - height: 50px; + width: 42px; + height: 42px; } > .body { - padding: 8px; + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; > .name { display: block; + text-overflow: ellipsis; + overflow: hidden; } > .info { opacity: 0.5; - - > .category { - margin-right: 16px; - } - - > .aliases { - font-style: oblique; - } } } } } } - } - > .remote { - > ._content { - max-height: 300px; - overflow: auto; - - > .emojis { + > .remote { + .emojis { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: var(--margin); + > .emoji { display: flex; align-items: center; + padding: 12px; + text-align: left; - &.selected { - background: var(--accent); - box-shadow: 0 0 0 8px var(--accent); - color: #fff; + &:hover { + color: var(--accent); } > .img { @@ -272,14 +228,21 @@ export default Vue.extend({ } > .body { - padding: 0 8px; + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; > .name { display: block; + text-overflow: ellipsis; + overflow: hidden; } > .info { opacity: 0.5; + display: block; + text-overflow: ellipsis; + overflow: hidden; } } } diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue index 8c5cbe2ff3..f2143fa003 100644 --- a/src/client/pages/instance/federation.vue +++ b/src/client/pages/instance/federation.vue @@ -1,13 +1,10 @@ <template> -<div class="mk-federation"> - <portal to="icon"><fa :icon="faGlobe"/></portal> - <portal to="title">{{ $t('federation') }}</portal> - - <section class="_card instances"> +<div> + <div class="_section"> <div class="_content"> - <mk-input v-model="host" :debounce="true"><span>{{ $t('host') }}</span></mk-input> + <MkInput v-model:value="host" :debounce="true"><span>{{ $t('host') }}</span></MkInput> <div class="inputs" style="display: flex;"> - <mk-select v-model="state" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> <template #label>{{ $t('state') }}</template> <option value="all">{{ $t('all') }}</option> <option value="federating">{{ $t('federating') }}</option> @@ -16,8 +13,8 @@ <option value="suspended">{{ $t('suspended') }}</option> <option value="blocked">{{ $t('blocked') }}</option> <option value="notResponding">{{ $t('notResponding') }}</option> - </mk-select> - <mk-select v-model="sort" style="margin: 0; flex: 1;"> + </MkSelect> + <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> <template #label>{{ $t('sort') }}</template> <option value="+pubSub">{{ $t('pubSub') }} ({{ $t('descendingOrder') }})</option> <option value="-pubSub">{{ $t('pubSub') }} ({{ $t('ascendingOrder') }})</option> @@ -37,44 +34,41 @@ <option value="-driveUsage">{{ $t('driveUsage') }} ({{ $t('ascendingOrder') }})</option> <option value="+driveFiles">{{ $t('driveFiles') }} ({{ $t('descendingOrder') }})</option> <option value="-driveFiles">{{ $t('driveFiles') }} ({{ $t('ascendingOrder') }})</option> - </mk-select> + </MkSelect> </div> </div> + </div> + <div class="_section"> <div class="_content"> - <mk-pagination :pagination="pagination" #default="{items}" class="instances" ref="instances" :key="host + state"> - <div class="instance" v-for="instance in items" :key="instance.id" @click="info(instance)"> - <div class="host"><fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div> + <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state"> + <div class="ppgwaixt _panel" v-for="instance in items" :key="instance.id" @click="info(instance)"> + <div class="host"><Fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div> <div class="status"> - <span class="sub" v-if="instance.followersCount > 0"><fa :icon="faCaretDown" class="icon"/>Sub</span> - <span class="sub" v-else><fa :icon="faCaretDown" class="icon"/>-</span> - <span class="pub" v-if="instance.followingCount > 0"><fa :icon="faCaretUp" class="icon"/>Pub</span> - <span class="pub" v-else><fa :icon="faCaretUp" class="icon"/>-</span> - <span class="lastCommunicatedAt"><fa :icon="faExchangeAlt" class="icon"/><mk-time :time="instance.lastCommunicatedAt"/></span> - <span class="latestStatus"><fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span> + <span class="sub" v-if="instance.followersCount > 0"><Fa :icon="faCaretDown" class="icon"/>Sub</span> + <span class="sub" v-else><Fa :icon="faCaretDown" class="icon"/>-</span> + <span class="pub" v-if="instance.followingCount > 0"><Fa :icon="faCaretUp" class="icon"/>Pub</span> + <span class="pub" v-else><Fa :icon="faCaretUp" class="icon"/>-</span> + <span class="lastCommunicatedAt"><Fa :icon="faExchangeAlt" class="icon"/><MkTime :time="instance.lastCommunicatedAt"/></span> + <span class="latestStatus"><Fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span> </div> </div> - </mk-pagination> + </MkPagination> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkPagination from '../../components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; import MkInstanceInfo from './instance.vue'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('federation') as string - }; - }, - +export default defineComponent({ components: { MkButton, MkInput, @@ -84,6 +78,12 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('federation'), + icon: faGlobe + }], + }, host: '', state: 'federating', sort: '+pubSub', @@ -125,60 +125,57 @@ export default Vue.extend({ }, info(instance) { - this.$root.new(MkInstanceInfo, { + os.popup(MkInstanceInfo, { instance: instance - }); + }, {}, 'closed'); } } }); </script> <style lang="scss" scoped> -.mk-federation { - > .instances { - > ._content { - > .instances { - > .instance { - cursor: pointer; +.ppgwaixt { + cursor: pointer; + padding: 16px; - > .host { - > .indicator { - font-size: 70%; - vertical-align: baseline; - margin-right: 4px; + &:hover { + color: var(--accent); + } - &.green { - color: #49c5ba; - } + > .host { + > .indicator { + font-size: 70%; + vertical-align: baseline; + margin-right: 4px; - &.yellow { - color: #c5a549; - } + &.green { + color: #49c5ba; + } - &.red { - color: #c54949; - } + &.yellow { + color: #c5a549; + } + + &.red { + color: #c54949; + } - &.off { - color: rgba(0, 0, 0, 0.5); - } - } - } + &.off { + color: rgba(0, 0, 0, 0.5); + } + } + } - > .status { - display: flex; - align-items: center; - font-size: 90%; + > .status { + display: flex; + align-items: center; + font-size: 90%; - > span { - flex: 1; - - > .icon { - margin-right: 6px; - } - } - } - } + > span { + flex: 1; + + > .icon { + margin-right: 6px; } } } diff --git a/src/client/pages/instance/file-dialog.vue b/src/client/pages/instance/file-dialog.vue new file mode 100644 index 0000000000..c03a691bfd --- /dev/null +++ b/src/client/pages/instance/file-dialog.vue @@ -0,0 +1,136 @@ +<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:value="toggleIsSensitive" v-model:value="isSensitive">NSFW</MkSwitch> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton full @click="showUser"><Fa :icon="faExternalLinkSquareAlt"/> {{ $t('user') }}</MkButton> + <MkButton full danger @click="del"><Fa :icon="faTrashAlt"/> {{ $t('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 { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faTrashAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/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, + faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt + }; + }, + + 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(); + }, + + async showUser() { + os.popup(await import('./user-dialog.vue'), { + userId: this.file.userId + }, {}, 'closed'); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('drive/files/delete', { + fileId: this.file.id + }).then(() => { + this.$refs.files.removeItem(x => x.id === 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> diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue index 0bc1c81e6f..ea90e3b5cd 100644 --- a/src/client/pages/instance/files.vue +++ b/src/client/pages/instance/files.vue @@ -1,54 +1,190 @@ <template> -<section class="_card"> - <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div> - <div class="_content"> - <mk-button primary @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</mk-button> +<div class="xrmjdkdw"> + <div class="_section"> + <div class="_content"> + <MkButton primary @click="clear()"><Fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</MkButton> + </div> </div> -</section> + + <div class="_section lookup"> + <div class="_title"><Fa :icon="faSearch"/> {{ $t('lookup') }}</div> + <div class="_content"> + <MkInput class="target" v-model:value="q" type="text" @enter="find()"> + <span>{{ $t('fileIdOrUrl') }}</span> + </MkInput> + <MkButton @click="find()" primary><Fa :icon="faSearch"/> {{ $t('lookup') }}</MkButton> + </div> + </div> + + <div class="_section"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model:value="origin" style="margin: 0; flex: 1;"> + <template #label>{{ $t('instance') }}</template> + <option value="combined">{{ $t('all') }}</option> + <option value="local">{{ $t('local') }}</option> + <option value="remote">{{ $t('remote') }}</option> + </MkSelect> + <MkInput v-model:value="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'"> + <span>{{ $t('host') }}</span> + </MkInput> + </div> + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model:value="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <span>{{ $t('type') }}</span> + </MkInput> + </div> + <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files" :auto-margin="false"> + <button class="file _panel _button _vMargin" 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 :user="file.user"/> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ $t('registeredDate') }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> + </div> + </div> +</div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faCloud } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent } from 'vue'; +import { faCloud, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkPagination from '../../components/ui/pagination.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('files')} | ${this.$t('instance')}` - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, + MkInput, + MkSelect, MkPagination, + MkDriveFileThumbnail, }, data() { return { - faTrashAlt, faCloud + INFO: { + header: [{ + title: this.$t('files'), + icon: faCloud + }], + }, + 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, + }), + }, + faTrashAlt, faCloud, faSearch, } }, + watch: { + type() { + this.$refs.files.reload(); + }, + origin() { + this.$refs.files.reload(); + }, + searchHost() { + this.$refs.files.reload(); + }, + }, + methods: { clear() { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('clearCachedFilesConfirm'), showCancelButton: true }).then(({ canceled }) => { if (canceled) return; - this.$root.api('admin/drive/clean-remote-files', {}).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); + }, + + async show(file, ev) { + os.popup(await 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.$t('notFound') }); - }); + } }); - } + }, + + bytes } }); </script> + +<style lang="scss" scoped> +.xrmjdkdw { + .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; + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/index.metrics.vue b/src/client/pages/instance/index.metrics.vue new file mode 100644 index 0000000000..f3060b29d5 --- /dev/null +++ b/src/client/pages/instance/index.metrics.vue @@ -0,0 +1,576 @@ +<template> +<div> + <MkFolder> + <template #header><Fa :icon="faHeartbeat"/> {{ $t('metrics') }}</template> + <div class="_section" style="padding: 0 var(--margin);"> + <div class="_content"> + <MkContainer :body-togglable="false" class="_vMargin"> + <template #header><Fa :icon="faMicrochip"/>{{ $t('cpuAndMemory') }}</template> + <!-- + <template #func> + <button class="_button" @click="resume" :disabled="!paused"><Fa :icon="faPlay"/></button> + <button class="_button" @click="pause" :disabled="paused"><Fa :icon="faPause"/></button> + </template> + --> + + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <canvas :ref="cpumem"></canvas> + </div> + <div class="_content" v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">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> + </MkContainer> + + <MkContainer :body-togglable="false" class="_vMargin"> + <template #header><Fa :icon="faHdd"/> {{ $t('disk') }}</template> + <!-- + <template #func> + <button class="_button" @click="resume" :disabled="!paused"><Fa :icon="faPlay"/></button> + <button class="_button" @click="pause" :disabled="paused"><Fa :icon="faPause"/></button> + </template> + --> + + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <canvas :ref="disk"></canvas> + </div> + <div class="_content" v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Disk total</div>{{ 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> + </MkContainer> + + <MkContainer :body-togglable="false" class="_vMargin"> + <template #header><Fa :icon="faExchangeAlt"/> {{ $t('network') }}</template> + <!-- + <template #func> + <button class="_button" @click="resume" :disabled="!paused"><Fa :icon="faPlay"/></button> + <button class="_button" @click="pause" :disabled="paused"><Fa :icon="faPause"/></button> + </template> + --> + + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <canvas :ref="net"></canvas> + </div> + <div class="_content" v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </MkContainer> + </div> + </div> + </MkFolder> + + <MkFolder> + <template #header><Fa :icon="faClipboardList"/> {{ $t('jobQueue') }}</template> + + <div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> + <MkContainer :body-togglable="false" :scrollable="true" :resize-base-el="() => $el"> + <template #header><Fa :icon="faExclamationTriangle"/> {{ $t('delayed') }}</template> + + <div class="_content"> + <div class="_keyValue" v-for="job in jobs" :key="job[0]"> + <button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button> + <div style="text-align: right;">{{ number(job[1]) }} jobs</div> + </div> + </div> + </MkContainer> + <XQueue :connection="queueConnection" domain="inbox" ref="queue" class="queue"> + <template #title><Fa :icon="faExchangeAlt"/> In</template> + </XQueue> + <XQueue :connection="queueConnection" domain="deliver" class="queue"> + <template #title><Fa :icon="faExchangeAlt"/> Out</template> + </XQueue> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList } from '@fortawesome/free-solid-svg-icons'; +import Chart from 'chart.js'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkInput from '@/components/ui/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'; + +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: os.stream.useSharedConnection('queueStats'), + memUsage: 0, + chartCpuMem: null, + chartNet: null, + jobs: [], + logs: [], + logLevel: 'all', + logDomain: '', + modLogs: [], + dbInfo: null, + overviewHeight: '1fr', + queueHeight: '1fr', + paused: false, + faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList, + } + }, + + computed: { + gridColor() { + // TODO: var(--panel)の色が暗いか明るいかで判定する + return this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + }, + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + os.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = os.stream.useSharedConnection('serverStats'); + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 150 + }); + + this.$nextTick(() => { + this.queueConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }); + }, + + beforeUnmount() { + 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, + lineTension: 0, + borderWidth: 2, + borderColor: '#86b300', + backgroundColor: alpha('#86b300', 0.1), + data: [] + }, { + label: 'MEM (active)', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#935dbf', + backgroundColor: alpha('#935dbf', 0.02), + data: [] + }, { + label: 'MEM (used)', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#935dbf', + borderDash: [5, 5], + fill: false, + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }], + yAxes: [{ + 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, + lineTension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Out', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }], + yAxes: [{ + 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, + lineTension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Write', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + 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'); + }, + + 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 { + &.min-width_1000px { + .sboqnrfi { + display: grid; + grid-template-columns: 3.2fr 1fr; + grid-template-rows: 1fr; + gap: 16px 16px; + + > .stats { + height: min-content; + } + + > .column { + display: flex; + flex-direction: column; + + > .info { + flex-shrink: 0; + flex-grow: 0; + } + + > .db { + flex: 1; + flex-grow: 0; + height: 100%; + } + + > .fed { + flex: 1; + flex-grow: 0; + height: 100%; + } + + > *:not(:last-child) { + margin-bottom: var(--margin); + } + } + } + + .segusily { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr; + gap: 16px 16px; + padding: 0 16px; + } + + .vkyrmkwb { + display: grid; + grid-template-columns: 0.5fr 1fr 1fr; + grid-template-rows: 1fr; + gap: 16px 16px; + margin-bottom: var(--margin); + + > .queue { + height: min-content; + } + + > * { + margin-bottom: 0; + } + } + + .uwuemslx { + display: grid; + grid-template-columns: 2fr 3fr; + grid-template-rows: 1fr; + gap: 16px 16px; + height: 400px; + } + } + + .vkyrmkwb { + > * { + margin-bottom: var(--margin); + } + } +} +</style> diff --git a/src/client/pages/instance/index.queue-chart.vue b/src/client/pages/instance/index.queue-chart.vue deleted file mode 100644 index 3b7823d924..0000000000 --- a/src/client/pages/instance/index.queue-chart.vue +++ /dev/null @@ -1,198 +0,0 @@ -<template> -<mk-container :body-togglable="false"> - <template #header><slot name="title"></slot></template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> - - <div class="_content _table"> - <div class="_row"> - <div class="_cell"><div class="_label">Process</div>{{ activeSincePrevTick | number }}</div> - <div class="_cell"><div class="_label">Active</div>{{ active | number }}</div> - <div class="_cell"><div class="_label">Waiting</div>{{ waiting | number }}</div> - <div class="_cell"><div class="_label">Delayed</div>{{ delayed | number }}</div> - </div> - </div> - <div class="_content" style="margin-bottom: -8px;"> - <canvas ref="chart"></canvas> - </div> -</mk-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import Chart from 'chart.js'; -import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../../components/ui/container.vue'; - -const alpha = (hex, a) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -export default Vue.extend({ - components: { - MkContainer, - }, - - props: { - domain: { - required: true - }, - connection: { - required: true - }, - }, - - data() { - return { - chart: null, - activeSincePrevTick: 0, - active: 0, - waiting: 0, - delayed: 0, - paused: false, - faPlay, faPause - } - }, - - mounted() { - // TODO: var(--panel)の色が暗いか明るいかで判定する - const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - this.chart = new Chart(this.$refs.chart, { - type: 'bar', - data: { - labels: [], - datasets: [{ - label: 'Process', - pointRadius: 0, - lineTension: 0, - borderWidth: 0, - backgroundColor: '#8BC34A', - data: [] - }, { - label: 'Active', - pointRadius: 0, - lineTension: 0, - borderWidth: 0, - backgroundColor: '#03A9F4', - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - lineTension: 0, - borderWidth: 0, - backgroundColor: '#FFC107', - data: [] - }, { - label: 'Delayed', - order: -1, - type: 'line', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#F44336', - borderDash: [5, 5], - fill: false, - data: [] - }] - }, - options: { - aspectRatio: 3, - layout: { - padding: { - left: 0, - right: 0, - top: 8, - bottom: 0 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - stacked: true, - gridLines: { - display: false, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - stacked: true, - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - }); - - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - }, - - beforeDestroy() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - }, - - methods: { - onStats(stats) { - if (this.paused) return; - this.activeSincePrevTick = stats[this.domain].activeSincePrevTick; - this.active = stats[this.domain].active; - this.waiting = stats[this.domain].waiting; - this.delayed = stats[this.domain].delayed; - this.chart.data.labels.push(''); - this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick); - this.chart.data.datasets[1].data.push(stats[this.domain].active); - this.chart.data.datasets[2].data.push(stats[this.domain].waiting); - this.chart.data.datasets[3].data.push(stats[this.domain].delayed); - if (this.chart.data.datasets[0].data.length > 100) { - this.chart.data.labels.shift(); - this.chart.data.datasets[0].data.shift(); - this.chart.data.datasets[1].data.shift(); - this.chart.data.datasets[2].data.shift(); - this.chart.data.datasets[3].data.shift(); - } - this.chart.update(); - }, - - onStatsLog(statsLog) { - for (const stats of [...statsLog].reverse()) { - this.onStats(stats); - } - }, - - pause() { - this.paused = true; - }, - - resume() { - this.paused = false; - }, - } -}); -</script> diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue index f55a53b5f3..9383f256eb 100644 --- a/src/client/pages/instance/index.vue +++ b/src/client/pages/instance/index.vue @@ -1,219 +1,77 @@ <template> -<div v-if="meta" class="xhexznfu" v-size="{ min: [1600] }"> - <portal to="icon"><fa :icon="faServer"/></portal> - <portal to="title">{{ $t('instance') }}</portal> - - <mk-folder> - <template #header><fa :icon="faTachometerAlt"/> {{ $t('overview') }}</template> +<div v-if="meta" v-show="page === 'index'" class="xhexznfu _section"> + <MkFolder> + <template #header><Fa :icon="faTachometerAlt"/> {{ $t('overview') }}</template> <div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }"> - <mk-instance-stats :chart-limit="300" :detailed="true" class="stats" ref="stats"/> - - <div class="column"> - <mk-container :body-togglable="true" :resize-base-el="() => $el" class="info"> - <template #header><fa :icon="faInfoCircle"/>{{ $t('instanceInfo') }}</template> - - <div class="_content"> - <div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div> - </div> - <div class="_content" v-if="serverInfo"> - <div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div> - <div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> - <div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> - </div> - </mk-container> - - <mk-container :body-togglable="true" :scrollable="true" :resize-base-el="() => $el" class="db"> - <template #header><fa :icon="faDatabase"/>{{ $t('database') }}</template> - - <div class="_content" v-if="dbInfo"> - <table style="border-collapse: collapse; width: 100%;"> - <tr style="opacity: 0.7;"> - <th style="text-align: left; padding: 0 8px 8px 0;">Table</th> - <th style="text-align: left; padding: 0 8px 8px 0;">Records</th> - <th style="text-align: left; padding: 0 0 8px 0;">Size</th> - </tr> - <tr v-for="table in dbInfo" :key="table[0]"> - <th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th> - <td style="padding: 0 8px 0 0;">{{ table[1].count | number }}</td> - <td style="padding: 0; opacity: 0.7;">{{ table[1].size | bytes }}</td> - </tr> - </table> - </div> - </mk-container> - - <mkw-federation class="fed" :body-togglable="true" :scrollable="true"/> - </div> - </div> - </mk-folder> - - <mk-folder style="margin: var(--margin) 0;"> - <template #header><fa :icon="faHeartbeat"/> {{ $t('metrics') }}</template> - - <div class="segusily"> - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faMicrochip"/>{{ $t('cpuAndMemory') }}</template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> - - <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> - <canvas ref="cpumem"></canvas> - </div> - <div class="_content" v-if="serverInfo"> - <div class="_table"> - <!-- - <div class="_row"> - <div class="_cell"><div class="_label">CPU</div>{{ serverInfo.cpu.model }}</div> - </div> - --> - <div class="_row"> - <div class="_cell"><div class="_label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div> - <div class="_cell"><div class="_label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> - <div class="_cell"><div class="_label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> - </div> - </div> - </div> - </mk-container> - - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faHdd"/> {{ $t('disk') }}</template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> - - <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> - <canvas ref="disk"></canvas> - </div> - <div class="_content" v-if="serverInfo"> - <div class="_table"> - <div class="_row"> - <div class="_cell"><div class="_label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div> - <div class="_cell"><div class="_label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> - <div class="_cell"><div class="_label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> - </div> - </div> - </div> - </mk-container> + <MkInstanceStats :chart-limit="300" :detailed="true" class="_vMargin" ref="stats"/> - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faExchangeAlt"/> {{ $t('network') }}</template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> + <MkContainer :body-togglable="true" class="_vMargin"> + <template #header><Fa :icon="faInfoCircle"/>{{ $t('instanceInfo') }}</template> - <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> - <canvas ref="net"></canvas> + <div class="_content"> + <div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div> </div> <div class="_content" v-if="serverInfo"> - <div class="_table"> - <div class="_row"> - <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> - </div> - </div> + <div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div> + <div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> + <div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> </div> - </mk-container> - </div> - </mk-folder> - - <mk-folder> - <template #header><fa :icon="faClipboardList"/> {{ $t('jobQueue') }}</template> - - <div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> - <mk-container :body-togglable="false" :scrollable="true" :resize-base-el="() => $el"> - <template #header><fa :icon="faExclamationTriangle"/> {{ $t('delayed') }}</template> + </MkContainer> + + <MkContainer :body-togglable="true" :scrollable="true" class="_vMargin" style="height: 300px;"> + <template #header><Fa :icon="faDatabase"/>{{ $t('database') }}</template> - <div class="_content"> - <div class="_keyValue" v-for="job in jobs" :key="job[0]"> - <button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button> - <div style="text-align: right;">{{ job[1] | number }} jobs</div> - </div> + <div class="_content" v-if="dbInfo"> + <table style="border-collapse: collapse; width: 100%;"> + <tr style="opacity: 0.7;"> + <th style="text-align: left; padding: 0 8px 8px 0;">Table</th> + <th style="text-align: left; padding: 0 8px 8px 0;">Records</th> + <th style="text-align: left; padding: 0 0 8px 0;">Size</th> + </tr> + <tr v-for="table in dbInfo" :key="table[0]"> + <th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th> + <td style="padding: 0 8px 0 0;">{{ number(table[1].count) }}</td> + <td style="padding: 0; opacity: 0.7;">{{ bytes(table[1].size) }}</td> + </tr> + </table> </div> - </mk-container> - <x-queue :connection="queueConnection" domain="inbox" ref="queue" class="queue"> - <template #title><fa :icon="faExchangeAlt"/> In</template> - </x-queue> - <x-queue :connection="queueConnection" domain="deliver" class="queue"> - <template #title><fa :icon="faExchangeAlt"/> Out</template> - </x-queue> + </MkContainer> </div> - </mk-folder> - - <mk-folder> - <template #header><fa :icon="faStream"/> {{ $t('logs') }}</template> - - <div class="uwuemslx"> - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faInfoCircle"/>{{ $t('') }}</template> - - <div class="_content"> - <div class="_keyValue" v-for="log in modLogs"> - <b>{{ log.type }}</b><span>by {{ log.user.username }}</span><mk-time :time="log.createdAt" style="opacity: 0.7;"/> - </div> - </div> - </mk-container> - - <section class="_card logs"> - <div class="_title"><fa :icon="faStream"/> {{ $t('serverLogs') }}</div> - <div class="_content"> - <div class="_inputs"> - <mk-input v-model="logDomain" :debounce="true"> - <span>{{ $t('domain') }}</span> - </mk-input> - <mk-select v-model="logLevel"> - <template #label>{{ $t('level') }}</template> - <option value="all">{{ $t('levels.all') }}</option> - <option value="info">{{ $t('levels.info') }}</option> - <option value="success">{{ $t('levels.success') }}</option> - <option value="warning">{{ $t('levels.warning') }}</option> - <option value="error">{{ $t('levels.error') }}</option> - <option value="debug">{{ $t('levels.debug') }}</option> - </mk-select> - </div> + </MkFolder> +</div> +<div v-if="page === 'logs'" class="_section"> + <MkFolder> + <template #header><Fa :icon="faStream"/> {{ $t('logs') }}</template> - <div class="logs"> - <code v-for="log in logs" :key="log.id" :class="log.level"> - <details> - <summary><mk-time :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary> - <vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty> - </details> - </code> - </div> - </div> - <div class="_footer"> - <mk-button @click="deleteAllLogs()" primary><fa :icon="faTrashAlt"/> {{ $t('deleteAll') }}</mk-button> - </div> - </section> + <div class="_keyValue" v-for="log in modLogs"> + <b>{{ log.type }}</b><span>by {{ log.user.username }}</span><MkTime :time="log.createdAt" style="opacity: 0.7;"/> </div> - </mk-folder> + </MkFolder> +</div> +<div v-if="page === 'metrics'"> + <XMetrics/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent, markRaw } from 'vue'; import { faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList } from '@fortawesome/free-solid-svg-icons'; -import Chart from 'chart.js'; import VueJsonPretty from 'vue-json-pretty'; -import MkInstanceStats from '../../components/instance-stats.vue'; -import MkButton from '../../components/ui/button.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkInput from '../../components/ui/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 XQueue from './index.queue-chart.vue'; +import MkInstanceStats from '@/components/instance-stats.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import { version, url } from '@/config'; +import bytes from '../../filters/bytes'; +import number from '../../filters/number'; import MkInstanceInfo from './instance.vue'; +import XMetrics from './index.metrics.vue'; +import * as os from '@/os'; -const alpha = (hex, a) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('instance') as string - }; - }, - +export default defineComponent({ components: { MkInstanceStats, MkButton, @@ -221,31 +79,43 @@ export default Vue.extend({ MkInput, MkContainer, MkFolder, - MkwFederation, - XQueue, + XMetrics, VueJsonPretty, }, data() { return { + INFO: { + header: [{ + id: 'index', + title: null, + tooltip: this.$t('instance'), + icon: faServer, + onClick: () => { this.page = 'index'; }, + selected: computed(() => this.page === 'index') + }, { + id: 'metrics', + title: null, + tooltip: this.$t('metrics'), + icon: faHeartbeat, + onClick: () => { this.page = 'metrics'; }, + selected: computed(() => this.page === 'metrics') + }, { + id: 'logs', + title: null, + tooltip: this.$t('logs'), + icon: faStream, + onClick: () => { this.page = 'logs'; }, + selected: computed(() => this.page === 'logs') + }] + }, + page: 'index', version, url, stats: null, serverInfo: null, - connection: null, - queueConnection: this.$root.stream.useSharedConnection('queueStats'), - memUsage: 0, - chartCpuMem: null, - chartNet: null, - jobs: [], - logs: [], - logLevel: 'all', - logDomain: '', modLogs: [], dbInfo: null, - overviewHeight: '1fr', - queueHeight: '1fr', - paused: false, faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList, } }, @@ -256,509 +126,47 @@ export default Vue.extend({ }, }, - watch: { - logLevel() { - this.logs = []; - this.fetchLogs(); - }, - logDomain() { - this.logs = []; - this.fetchLogs(); - } - }, - - created() { - this.$store.commit('setFullView', true); - }, - mounted() { - this.fetchLogs(); this.fetchJobs(); this.fetchModLogs(); - // TODO: var(--panel)の色が暗いか明るいかで判定する - const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - this.chartCpuMem = new Chart(this.$refs.cpumem, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'CPU', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#86b300', - backgroundColor: alpha('#86b300', 0.1), - data: [] - }, { - label: 'MEM (active)', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#935dbf', - backgroundColor: alpha('#935dbf', 0.02), - data: [] - }, { - label: 'MEM (used)', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#935dbf', - borderDash: [5, 5], - fill: false, - data: [] - }] - }, - options: { - aspectRatio: 3, - layout: { - padding: { - left: 0, - right: 0, - top: 8, - bottom: 0 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - gridLines: { - display: false, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - max: 100 - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - }); - - this.chartNet = new Chart(this.$refs.net, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'In', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#94a029', - backgroundColor: alpha('#94a029', 0.1), - data: [] - }, { - label: 'Out', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#ff9156', - backgroundColor: alpha('#ff9156', 0.1), - data: [] - }] - }, - options: { - aspectRatio: 3, - layout: { - padding: { - left: 0, - right: 0, - top: 8, - bottom: 0 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - gridLines: { - display: false, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - }); - - this.chartDisk = new Chart(this.$refs.disk, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'Read', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#94a029', - backgroundColor: alpha('#94a029', 0.1), - data: [] - }, { - label: 'Write', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#ff9156', - backgroundColor: alpha('#ff9156', 0.1), - data: [] - }] - }, - options: { - aspectRatio: 3, - layout: { - padding: { - left: 0, - right: 0, - top: 8, - bottom: 0 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - gridLines: { - display: false, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - }); - - this.$root.api('admin/server-info', {}).then(res => { + os.api('admin/server-info', {}).then(res => { this.serverInfo = res; - - this.connection = this.$root.stream.useSharedConnection('serverStats'); - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - this.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 150 - }); - - this.$nextTick(() => { - this.queueConnection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 200 - }); - }); }); - this.$root.api('admin/get-table-stats', {}).then(res => { + os.api('admin/get-table-stats', {}).then(res => { this.dbInfo = Object.entries(res).sort((a, b) => b[1].size - a[1].size); }); - - this.$nextTick(() => { - new ResizeObserver((entries, observer) => { - if (this.$refs.stats && this.$refs.stats.$el) { - this.overviewHeight = this.$refs.stats.$el.offsetHeight + 'px'; - } - }).observe(this.$refs.stats.$el); - - new ResizeObserver((entries, observer) => { - if (this.$refs.queue && this.$refs.queue.$el) { - this.queueHeight = this.$refs.queue.$el.offsetHeight + 'px'; - } - }).observe(this.$refs.queue.$el); - }); - }, - - beforeDestroy() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - this.connection.dispose(); - this.queueConnection.dispose(); - this.$store.commit('setFullView', false); }, methods: { async showInstanceInfo(q) { let instance = q; if (typeof q === 'string') { - instance = await this.$root.api('federation/show-instance', { + instance = await os.api('federation/show-instance', { host: q }); } - this.$root.new(MkInstanceInfo, { + os.popup(MkInstanceInfo, { instance: instance - }); - }, - - fetchLogs() { - this.$root.api('admin/logs', { - level: this.logLevel === 'all' ? null : this.logLevel, - domain: this.logDomain === '' ? null : this.logDomain, - limit: 30 - }).then(logs => { - this.logs = logs.reverse(); - }); + }, {}, 'closed'); }, fetchJobs() { - this.$root.api('admin/queue/deliver-delayed', {}).then(jobs => { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { this.jobs = jobs; }); }, fetchModLogs() { - this.$root.api('admin/show-moderation-logs', {}).then(logs => { + os.api('admin/show-moderation-logs', {}).then(logs => { this.modLogs = logs; }); }, - deleteAllLogs() { - this.$root.api('admin/delete-logs').then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); - }, - - 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); - } - }, - - pause() { - this.paused = true; - }, + bytes, - resume() { - this.paused = false; - }, + number, } }); </script> - -<style lang="scss" scoped> -.xhexznfu { - &.min-width_1600px { - .sboqnrfi { - display: grid; - grid-template-columns: 3.2fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - - > .stats { - height: min-content; - } - - > .column { - display: flex; - flex-direction: column; - - > .info { - flex-shrink: 0; - flex-grow: 0; - } - - > .db { - flex: 1; - flex-grow: 0; - height: 100%; - } - - > .fed { - flex: 1; - flex-grow: 0; - height: 100%; - } - - > *:not(:last-child) { - margin-bottom: var(--margin); - } - } - } - - .segusily { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - } - - .vkyrmkwb { - display: grid; - grid-template-columns: 0.5fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - margin-bottom: var(--margin); - - > .queue { - height: min-content; - } - - > * { - margin-bottom: 0; - } - } - - .uwuemslx { - display: grid; - grid-template-columns: 2fr 3fr; - grid-template-rows: 1fr; - gap: 16px 16px; - height: 400px; - } - } - - .vkyrmkwb { - > * { - margin-bottom: var(--margin); - } - } - - > .stats { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - margin: calc(0px - var(--margin) / 2); - margin-bottom: calc(var(--margin) / 2); - - > div { - flex: 1 0 213px; - margin: calc(var(--margin) / 2); - box-sizing: border-box; - padding: 16px; - } - } - - > .logs { - > ._content { - > .logs { - padding: 8px; - background: #000; - color: #fff; - font-size: 0.9em; - - > code { - display: block; - - &.error { - color: #f00; - } - - &.warning { - color: #ff0; - } - - &.success { - color: #0f0; - } - - &.debug { - opacity: 0.7; - } - } - } - } - } -} -</style> diff --git a/src/client/pages/instance/instance.vue b/src/client/pages/instance/instance.vue index 30893f381b..97f85d3b1f 100644 --- a/src/client/pages/instance/instance.vue +++ b/src/client/pages/instance/instance.vue @@ -1,8 +1,13 @@ <template> -<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true" :width="520" :height="500"> +<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"> + <div class="_table section"> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('software') }}</div> @@ -14,47 +19,47 @@ </div> </div> </div> - <div class="_table data"> + <div class="_table data section"> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('registeredAt') }}</div> - <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</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">{{ $t('following') }}</div> - <button class="_data _textButton" @click="showFollowing()">{{ instance.followingCount | number }}</button> + <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button> </div> <div class="_cell"> <div class="_label">{{ $t('followers') }}</div> - <button class="_data _textButton" @click="showFollowers()">{{ instance.followersCount | number }}</button> + <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('users') }}</div> - <button class="_data _textButton" @click="showUsers()">{{ instance.usersCount | number }}</button> + <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button> </div> <div class="_cell"> <div class="_label">{{ $t('notes') }}</div> - <div class="_data">{{ instance.notesCount | number }}</div> + <div class="_data">{{ number(instance.notesCount) }}</div> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('files') }}</div> - <div class="_data">{{ instance.driveFiles | number }}</div> + <div class="_data">{{ number(instance.driveFiles) }}</div> </div> <div class="_cell"> <div class="_label">{{ $t('storageUsage') }}</div> - <div class="_data">{{ instance.driveUsage | bytes }}</div> + <div class="_data">{{ bytes(instance.driveUsage) }}</div> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('latestRequestSentAt') }}</div> - <div class="_data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></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">{{ $t('latestStatus') }}</div> @@ -64,7 +69,7 @@ <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('latestRequestReceivedAt') }}</div> - <div class="_data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> </div> </div> </div> @@ -72,7 +77,7 @@ <div class="header"> <span class="label">{{ $t('charts') }}</span> <div class="selects"> - <mk-select v-model="chartSrc" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;"> <option value="requests">{{ $t('_instanceCharts.requests') }}</option> <option value="users">{{ $t('_instanceCharts.users') }}</option> <option value="users-total">{{ $t('_instanceCharts.usersTotal') }}</option> @@ -84,49 +89,52 @@ <option value="drive-usage-total">{{ $t('_instanceCharts.cacheSizeTotal') }}</option> <option value="drive-files">{{ $t('_instanceCharts.files') }}</option> <option value="drive-files-total">{{ $t('_instanceCharts.filesTotal') }}</option> - </mk-select> - <mk-select v-model="chartSpan" style="margin: 0;"> + </MkSelect> + <MkSelect v-model:value="chartSpan" style="margin: 0;"> <option value="hour">{{ $t('perHour') }}</option> <option value="day">{{ $t('perDay') }}</option> - </mk-select> + </MkSelect> </div> </div> <div class="chart"> - <canvas ref="chart"></canvas> + <canvas :ref="setChart"></canvas> </div> </div> - <div class="operations"> + <div class="operations section"> <span class="label">{{ $t('operations') }}</span> - <mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch> - <mk-switch :value="isBlocked" class="switch" @change="changeBlock">{{ $t('blockThisInstance') }}</mk-switch> + <MkSwitch v-model:value="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</MkSwitch> + <MkSwitch :value="isBlocked" class="switch" @update:value="changeBlock">{{ $t('blockThisInstance') }}</MkSwitch> <details> <summary>{{ $t('deleteAllFiles') }}</summary> - <mk-button @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</mk-button> + <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><Fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</MkButton> </details> <details> <summary>{{ $t('removeAllFollowing') }}</summary> - <mk-button @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><fa :icon="faMinusCircle"/> {{ $t('removeAllFollowing') }}</mk-button> - <mk-info warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</mk-info> + <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><Fa :icon="faMinusCircle"/> {{ $t('removeAllFollowing') }}</MkButton> + <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo> </details> </div> - <details class="metadata"> + <details class="metadata section"> <summary class="label">{{ $t('metadata') }}</summary> <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre> </details> </div> -</x-window> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import Chart from 'chart.js'; import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown, faMinusCircle, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; -import XWindow from '../../components/window.vue'; -import MkUsersDialog from '../../components/users-dialog.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkButton from '../../components/ui/button.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import MkInfo from '../../components/ui/info.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkUsersDialog from '@/components/users-dialog.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkInfo from '@/components/ui/info.vue'; +import bytes from '../../filters/bytes'; +import number from '../../filters/number'; +import * as os from '@/os'; const chartLimit = 90; const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); @@ -139,9 +147,9 @@ const alpha = hex => { return `rgba(${r}, ${g}, ${b}, 0.1)`; }; -export default Vue.extend({ +export default defineComponent({ components: { - XWindow, + XModalWindow, MkSelect, MkButton, MkSwitch, @@ -155,10 +163,13 @@ export default Vue.extend({ } }, + emits: ['closed'], + data() { return { isSuspended: this.instance.isSuspended, now: null, + canvas: null, chart: null, chartInstance: null, chartSrc: 'requests', @@ -199,13 +210,13 @@ export default Vue.extend({ }, isBlocked() { - return this.meta && this.meta.blockedHosts.includes(this.instance.host); + return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host); } }, watch: { isSuspended() { - this.$root.api('admin/federation/update-instance', { + os.api('admin/federation/update-instance', { host: this.instance.host, isSuspended: this.isSuspended }); @@ -220,12 +231,12 @@ export default Vue.extend({ } }, - async created() { + async created() { this.now = new Date(); const [perHour, perDay] = await Promise.all([ - this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), - this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), + os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), + os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), ]); const chart = { @@ -239,8 +250,12 @@ export default Vue.extend({ }, methods: { + setChart(el) { + this.canvas = el; + }, + changeBlock(e) { - this.$root.api('admin/update-meta', { + os.api('admin/update-meta', { blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) }); }, @@ -250,24 +265,14 @@ export default Vue.extend({ }, removeAllFollowing() { - this.$root.api('admin/federation/remove-all-following', { + os.apiWithDialog('admin/federation/remove-all-following', { host: this.instance.host - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, deleteAllFiles() { - this.$root.api('admin/federation/delete-all-files', { + os.apiWithDialog('admin/federation/delete-all-files', { host: this.instance.host - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, @@ -277,7 +282,7 @@ export default Vue.extend({ } Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - this.chartInstance = new Chart(this.$refs.chart, { + this.chartInstance = new Chart(this.canvas, { type: 'line', data: { labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), @@ -436,7 +441,7 @@ export default Vue.extend({ }, showFollowing() { - this.$root.new(MkUsersDialog, { + os.modal(MkUsersDialog, { title: this.$t('instanceFollowing'), pagination: { endpoint: 'federation/following', @@ -450,7 +455,7 @@ export default Vue.extend({ }, showFollowers() { - this.$root.new(MkUsersDialog, { + os.modal(MkUsersDialog, { title: this.$t('instanceFollowers'), pagination: { endpoint: 'federation/followers', @@ -464,7 +469,7 @@ export default Vue.extend({ }, showUsers() { - this.$root.new(MkUsersDialog, { + os.modal(MkUsersDialog, { title: this.$t('instanceUsers'), pagination: { endpoint: 'federation/users', @@ -474,7 +479,11 @@ export default Vue.extend({ } } }); - } + }, + + bytes, + + number } }); </script> @@ -483,34 +492,21 @@ export default Vue.extend({ .mk-instance-info { overflow: auto; - > ._table { - padding: 0 32px; + > .section { + padding: 16px 32px; @media (max-width: 500px) { - padding: 0 16px; + padding: 8px 16px; } - } - - > .data { - margin-top: 16px; - padding-top: 16px; - border-top: solid 1px var(--divider); - @media (max-width: 500px) { - margin-top: 8px; - padding-top: 8px; + &:not(:first-child) { + border-top: solid 1px var(--divider); } } > .chart { - margin-top: 16px; - padding-top: 16px; border-top: solid 1px var(--divider); - - @media (max-width: 500px) { - margin-top: 8px; - padding-top: 8px; - } + padding: 16px 0 12px 0; > .header { padding: 0 32px; @@ -539,15 +535,6 @@ export default Vue.extend({ } > .operations { - padding: 16px 32px 16px 32px; - margin-top: 8px; - border-top: solid 1px var(--divider); - - @media (max-width: 500px) { - padding: 8px 16px 8px 16px; - margin-top: 0; - } - > .label { font-size: 80%; opacity: 0.7; @@ -559,13 +546,6 @@ export default Vue.extend({ } > .metadata { - padding: 16px 32px 16px 32px; - border-top: solid 1px var(--divider); - - @media (max-width: 500px) { - padding: 8px 16px 8px 16px; - } - > .label { font-size: 80%; opacity: 0.7; diff --git a/src/client/pages/instance/logs.vue b/src/client/pages/instance/logs.vue new file mode 100644 index 0000000000..5549bd5a1a --- /dev/null +++ b/src/client/pages/instance/logs.vue @@ -0,0 +1,95 @@ +<template> +<div class="_section"> + <div class="_inputs"> + <MkInput v-model:value="logDomain" :debounce="true"> + <span>{{ $t('domain') }}</span> + </MkInput> + <MkSelect v-model:value="logLevel"> + <template #label>{{ $t('level') }}</template> + <option value="all">{{ $t('levels.all') }}</option> + <option value="info">{{ $t('levels.info') }}</option> + <option value="success">{{ $t('levels.success') }}</option> + <option value="warning">{{ $t('levels.warning') }}</option> + <option value="error">{{ $t('levels.error') }}</option> + <option value="debug">{{ $t('levels.debug') }}</option> + </MkSelect> + </div> + + <div class="logs"> + <code v-for="log in logs" :key="log.id" :class="log.level"> + <details> + <summary><MkTime :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary> + <!--<vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty>--> + </details> + </code> + </div> + + <MkButton @click="deleteAllLogs()" primary><Fa :icon="faTrashAlt"/> {{ $t('deleteAll') }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faStream } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkTextarea, + }, + + data() { + return { + INFO: { + header: [{ + title: this.$t('serverLogs'), + icon: faStream + }] + }, + logs: [], + logLevel: 'all', + logDomain: '', + faTrashAlt, + } + }, + + watch: { + logLevel() { + this.logs = []; + this.fetchLogs(); + }, + logDomain() { + this.logs = []; + this.fetchLogs(); + } + }, + + created() { + this.fetchLogs(); + }, + + methods: { + fetchLogs() { + os.api('admin/logs', { + level: this.logLevel === 'all' ? null : this.logLevel, + domain: this.logDomain === '' ? null : this.logDomain, + limit: 30 + }).then(logs => { + this.logs = logs.reverse(); + }); + }, + + deleteAllLogs() { + os.apiWithDialog('admin/delete-logs'); + }, + } +}); +</script> diff --git a/src/client/pages/instance/queue.chart.vue b/src/client/pages/instance/queue.chart.vue index 8f66c8e486..742c2b7d3c 100644 --- a/src/client/pages/instance/queue.chart.vue +++ b/src/client/pages/instance/queue.chart.vue @@ -1,12 +1,12 @@ <template> -<section class="_card"> +<section class="_section"> <div class="_title"><slot name="title"></slot></div> <div class="_content _table"> <div class="_row"> - <div class="_cell"><div class="_label">Process</div>{{ activeSincePrevTick | number }}</div> - <div class="_cell"><div class="_label">Active</div>{{ active | number }}</div> - <div class="_cell"><div class="_label">Waiting</div>{{ waiting | number }}</div> - <div class="_cell"><div class="_label">Delayed</div>{{ delayed | number }}</div> + <div 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="_content" style="margin-bottom: -8px;"> @@ -16,7 +16,7 @@ <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;">({{ job[1] | number }} jobs)</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> </div> </div> <span v-else style="opacity: 0.5;">{{ $t('noJobs') }}</span> @@ -25,8 +25,9 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import Chart from 'chart.js'; +import number from '../../filters/number'; const alpha = (hex, a) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; @@ -35,8 +36,9 @@ const alpha = (hex, a) => { const b = parseInt(result[3], 16); return `rgba(${r}, ${g}, ${b}, ${a})`; }; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { domain: { required: true @@ -154,7 +156,7 @@ export default Vue.extend({ this.connection.on('statsLog', this.onStatsLog); }, - beforeDestroy() { + beforeUnmount() { this.connection.off('stats', this.onStats); this.connection.off('statsLog', this.onStatsLog); }, @@ -187,10 +189,12 @@ export default Vue.extend({ }, fetchJobs() { - this.$root.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { + os.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { this.jobs = jobs; }); }, + + number } }); </script> diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue index d9f12577e4..5dec95c670 100644 --- a/src/client/pages/instance/queue.vue +++ b/src/client/pages/instance/queue.vue @@ -1,36 +1,28 @@ <template> <div> - <portal to="icon"><fa :icon="faExchangeAlt"/></portal> - <portal to="title">{{ $t('jobQueue') }}</portal> - - <x-queue :connection="connection" domain="inbox"> - <template #title><fa :icon="faExchangeAlt"/> In</template> - </x-queue> - <x-queue :connection="connection" domain="deliver"> - <template #title><fa :icon="faExchangeAlt"/> Out</template> - </x-queue> - <section class="_card"> + <XQueue :connection="connection" domain="inbox"> + <template #title><Fa :icon="faExchangeAlt"/> In</template> + </XQueue> + <XQueue :connection="connection" domain="deliver"> + <template #title><Fa :icon="faExchangeAlt"/> Out</template> + </XQueue> + <section class="_section"> <div class="_content"> - <mk-button @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</mk-button> + <MkButton @click="clear()"><Fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; +import MkButton from '@/components/ui/button.vue'; import XQueue from './queue.chart.vue'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('jobQueue')} | ${this.$t('instance')}` - }; - }, - +export default defineComponent({ components: { MkButton, XQueue, @@ -38,7 +30,13 @@ export default Vue.extend({ data() { return { - connection: this.$root.stream.useSharedConnection('queueStats'), + INFO: { + header: [{ + title: this.$t('jobQueue'), + icon: faExchangeAlt, + }], + }, + connection: os.stream.useSharedConnection('queueStats'), faExchangeAlt, faTrashAlt } }, @@ -52,13 +50,13 @@ export default Vue.extend({ }); }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); }, methods: { clear() { - this.$root.dialog({ + os.dialog({ type: 'warning', title: this.$t('clearQueueConfirmTitle'), text: this.$t('clearQueueConfirmText'), @@ -66,12 +64,7 @@ export default Vue.extend({ }).then(({ canceled }) => { if (canceled) return; - this.$root.api('admin/queue/clear', {}).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); + os.apiWithDialog('admin/queue/clear', {}); }); } } diff --git a/src/client/pages/instance/relays.vue b/src/client/pages/instance/relays.vue index eaf6c0b682..82b7b006ed 100644 --- a/src/client/pages/instance/relays.vue +++ b/src/client/pages/instance/relays.vue @@ -1,43 +1,35 @@ <template> <div class="relaycxt"> - <portal to="icon"><fa :icon="faProjectDiagram"/></portal> - <portal to="title">{{ $t('relays') }}</portal> - - <section class="_card _vMargin add"> - <div class="_title"><fa :icon="faPlus"/> {{ $t('addRelay') }}</div> + <section class="_section add"> + <div class="_title"><Fa :icon="faPlus"/> {{ $t('addRelay') }}</div> <div class="_content"> - <mk-input v-model="inbox"> + <MkInput v-model:value="inbox"> <span>{{ $t('inboxUrl') }}</span> - </mk-input> - <mk-button @click="add(inbox)" primary><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> + </MkInput> + <MkButton @click="add(inbox)" primary><Fa :icon="faPlus"/> {{ $t('add') }}</MkButton> </div> </section> - <section class="_card _vMargin relays"> - <div class="_title"><fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div> + <section class="_section relays"> + <div class="_title"><Fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div> <div class="_content relay" v-for="relay in relays" :key="relay.inbox"> <div>{{ relay.inbox }}</div> <div>{{ $t(`_relayStatus.${relay.status}`) }}</div> - <mk-button class="button" inline @click="remove(relay.inbox)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button> + <MkButton class="button" inline @click="remove(relay.inbox)"><Fa :icon="faTrashAlt"/> {{ $t('remove') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlus, faProjectDiagram } from '@fortawesome/free-solid-svg-icons'; import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('relays') as string - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, MkInput, @@ -45,6 +37,12 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('relays'), + icon: faProjectDiagram, + }], + }, relays: [], inbox: '', faPlus, faProjectDiagram, faSave, faTrashAlt @@ -57,12 +55,12 @@ export default Vue.extend({ methods: { add(inbox: string) { - this.$root.api('admin/relays/add', { + os.api('admin/relays/add', { inbox }).then((relay: any) => { this.refresh(); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message || e }); @@ -70,12 +68,12 @@ export default Vue.extend({ }, remove(inbox: string) { - this.$root.api('admin/relays/remove', { + os.api('admin/relays/remove', { inbox }).then(() => { this.refresh(); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message || e }); @@ -83,7 +81,7 @@ export default Vue.extend({ }, refresh() { - this.$root.api('admin/relays/list').then((relays: any) => { + os.api('admin/relays/list').then((relays: any) => { this.relays = relays; }); } diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index 0c0e506ab8..e8bf4a0bda 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -1,53 +1,50 @@ <template> <div v-if="meta"> - <portal to="icon"><fa :icon="faCog"/></portal> - <portal to="title">{{ $t('settings') }}</portal> - - <section class="_card _vMargin info"> - <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div> + <section class="_section info"> + <div class="_title"><Fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div> <div class="_content"> - <mk-input v-model="name">{{ $t('instanceName') }}</mk-input> - <mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea> - <mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input> - <mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input> - <mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input> - <mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input> - <mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input> + <MkInput v-model:value="name">{{ $t('instanceName') }}</MkInput> + <MkTextarea v-model:value="description">{{ $t('instanceDescription') }}</MkTextarea> + <MkInput v-model:value="iconUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('iconUrl') }}</MkInput> + <MkInput v-model:value="bannerUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</MkInput> + <MkInput v-model:value="tosUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('tosUrl') }}</MkInput> + <MkInput v-model:value="maintainerName">{{ $t('maintainerName') }}</MkInput> + <MkInput v-model:value="maintainerEmail" type="email"><template #icon><Fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</MkInput> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin info"> + <section class="_section info"> <div class="_content"> - <mk-input v-model="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</mk-input> + <MkInput v-model:value="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><Fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</MkInput> </div> <div class="_content"> - <mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch> - <mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch> - <mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info> + <MkSwitch v-model:value="enableLocalTimeline" @update:value="save()">{{ $t('enableLocalTimeline') }}</MkSwitch> + <MkSwitch v-model:value="enableGlobalTimeline" @update:value="save()">{{ $t('enableGlobalTimeline') }}</MkSwitch> + <MkInfo>{{ $t('disablingTimelinesInfo') }}</MkInfo> </div> <div class="_content"> - <mk-switch v-model="useStarForReactionFallback" @change="save()">{{ $t('useStarForReactionFallback') }}</mk-switch> + <MkSwitch v-model:value="useStarForReactionFallback" @update:value="save()">{{ $t('useStarForReactionFallback') }}</MkSwitch> </div> </section> - <section class="_card _vMargin info"> - <div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div> + <section class="_section info"> + <div class="_title"><Fa :icon="faUser"/> {{ $t('registration') }}</div> <div class="_content"> - <mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch> - <mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button> + <MkSwitch v-model:value="enableRegistration" @update:value="save()">{{ $t('enableRegistration') }}</MkSwitch> + <MkButton v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div> <div class="_content"> - <mk-switch v-model="enableHcaptcha" ref="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch> + <MkSwitch v-model:value="enableHcaptcha">{{ $t('enableHcaptcha') }}</MkSwitch> <template v-if="enableHcaptcha"> - <mk-input v-model="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</mk-input> - <mk-input v-model="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</mk-input> + <MkInput v-model:value="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</MkInput> + <MkInput v-model:value="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</MkInput> </template> </div> <div class="_content" v-if="enableHcaptcha"> @@ -55,17 +52,17 @@ <captcha v-if="enableHcaptcha" provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> <div class="_content"> - <mk-switch v-model="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch> + <MkSwitch v-model:value="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</MkSwitch> <template v-if="enableRecaptcha"> - <mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input> - <mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input> + <MkInput v-model:value="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</MkInput> + <MkInput v-model:value="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</MkInput> </template> </div> <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey"> @@ -73,198 +70,198 @@ <captcha v-if="enableRecaptcha" provider="grecaptcha" :sitekey="recaptchaSiteKey"/> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div> <div class="_content"> - <mk-switch v-model="enableEmail" @change="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></mk-switch> - <mk-input v-model="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</mk-input> + <MkSwitch v-model:value="enableEmail" @update:value="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></MkSwitch> + <MkInput v-model:value="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</MkInput> <div><b>{{ $t('smtpConfig') }}</b></div> <div class="_inputs"> - <mk-input v-model="smtpHost" :disabled="!enableEmail">{{ $t('smtpHost') }}</mk-input> - <mk-input v-model="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtpPort') }}</mk-input> + <MkInput v-model:value="smtpHost" :disabled="!enableEmail">{{ $t('smtpHost') }}</MkInput> + <MkInput v-model:value="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtpPort') }}</MkInput> </div> <div class="_inputs"> - <mk-input v-model="smtpUser" :disabled="!enableEmail">{{ $t('smtpUser') }}</mk-input> - <mk-input v-model="smtpPass" type="password" :disabled="!enableEmail">{{ $t('smtpPass') }}</mk-input> + <MkInput v-model:value="smtpUser" :disabled="!enableEmail">{{ $t('smtpUser') }}</MkInput> + <MkInput v-model:value="smtpPass" type="password" :disabled="!enableEmail">{{ $t('smtpPass') }}</MkInput> </div> - <mk-info>{{ $t('emptyToDisableSmtpAuth') }}</mk-info> - <mk-switch v-model="smtpSecure" :disabled="!enableEmail">{{ $t('smtpSecure') }}<template #desc>{{ $t('smtpSecureInfo') }}</template></mk-switch> + <MkInfo>{{ $t('emptyToDisableSmtpAuth') }}</MkInfo> + <MkSwitch v-model:value="smtpSecure" :disabled="!enableEmail">{{ $t('smtpSecure') }}<template #desc>{{ $t('smtpSecureInfo') }}</template></MkSwitch> <div> - <mk-button :disabled="!enableEmail" inline @click="testEmail()">{{ $t('testEmail') }}</mk-button> - <mk-button :disabled="!enableEmail" primary inline @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton :disabled="!enableEmail" inline @click="testEmail()">{{ $t('testEmail') }}</MkButton> + <MkButton :disabled="!enableEmail" primary inline @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faBolt"/> {{ $t('serviceworker') }}</div> <div class="_content"> - <mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></mk-switch> + <MkSwitch v-model:value="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></MkSwitch> <template v-if="enableServiceWorker"> <div class="_inputs"> - <mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Public key</mk-input> - <mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Private key</mk-input> + <MkInput v-model:value="swPublicKey" :disabled="!enableServiceWorker"><template #icon><Fa :icon="faKey"/></template>Public key</MkInput> + <MkInput v-model:value="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><Fa :icon="faKey"/></template>Private key</MkInput> </div> </template> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div> <div class="_content"> - <mk-textarea v-model="pinnedUsers"> + <MkTextarea v-model:value="pinnedUsers"> <template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template> - </mk-textarea> + </MkTextarea> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faCloud"/> {{ $t('files') }}</div> <div class="_content"> - <mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch> - <mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch> - <mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input> - <mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input> + <MkSwitch v-model:value="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></MkSwitch> + <MkSwitch v-model:value="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></MkSwitch> + <MkInput v-model:value="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></MkInput> + <MkInput v-model:value="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></MkInput> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCloud"/> {{ $t('objectStorage') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faCloud"/> {{ $t('objectStorage') }}</div> <div class="_content"> - <mk-switch v-model="useObjectStorage">{{ $t('useObjectStorage') }}</mk-switch> + <MkSwitch v-model:value="useObjectStorage">{{ $t('useObjectStorage') }}</MkSwitch> <template v-if="useObjectStorage"> - <mk-input v-model="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $t('objectStorageBaseUrl') }}<template #desc>{{ $t('objectStorageBaseUrlDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $t('objectStorageBaseUrl') }}<template #desc>{{ $t('objectStorageBaseUrlDesc') }}</template></MkInput> <div class="_inputs"> - <mk-input v-model="objectStorageBucket" :disabled="!useObjectStorage">{{ $t('objectStorageBucket') }}<template #desc>{{ $t('objectStorageBucketDesc') }}</template></mk-input> - <mk-input v-model="objectStoragePrefix" :disabled="!useObjectStorage">{{ $t('objectStoragePrefix') }}<template #desc>{{ $t('objectStoragePrefixDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageBucket" :disabled="!useObjectStorage">{{ $t('objectStorageBucket') }}<template #desc>{{ $t('objectStorageBucketDesc') }}</template></MkInput> + <MkInput v-model:value="objectStoragePrefix" :disabled="!useObjectStorage">{{ $t('objectStoragePrefix') }}<template #desc>{{ $t('objectStoragePrefixDesc') }}</template></MkInput> </div> - <mk-input v-model="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $t('objectStorageEndpoint') }}<template #desc>{{ $t('objectStorageEndpointDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $t('objectStorageEndpoint') }}<template #desc>{{ $t('objectStorageEndpointDesc') }}</template></MkInput> <div class="_inputs"> - <mk-input v-model="objectStorageRegion" :disabled="!useObjectStorage">{{ $t('objectStorageRegion') }}<template #desc>{{ $t('objectStorageRegionDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageRegion" :disabled="!useObjectStorage">{{ $t('objectStorageRegion') }}<template #desc>{{ $t('objectStorageRegionDesc') }}</template></MkInput> </div> <div class="_inputs"> - <mk-input v-model="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><fa :icon="faKey"/></template>Access key</mk-input> - <mk-input v-model="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><fa :icon="faKey"/></template>Secret key</mk-input> + <MkInput v-model:value="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><Fa :icon="faKey"/></template>Access key</MkInput> + <MkInput v-model:value="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><Fa :icon="faKey"/></template>Secret key</MkInput> </div> - <mk-switch v-model="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('objectStorageUseSSL') }}<template #desc>{{ $t('objectStorageUseSSLDesc') }}</template></mk-switch> - <mk-switch v-model="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $t('objectStorageUseProxy') }}<template #desc>{{ $t('objectStorageUseProxyDesc') }}</template></mk-switch> - <mk-switch v-model="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $t('objectStorageSetPublicRead') }}</mk-switch> + <MkSwitch v-model:value="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('objectStorageUseSSL') }}<template #desc>{{ $t('objectStorageUseSSLDesc') }}</template></MkSwitch> + <MkSwitch v-model:value="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $t('objectStorageUseProxy') }}<template #desc>{{ $t('objectStorageUseProxyDesc') }}</template></MkSwitch> + <MkSwitch v-model:value="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $t('objectStorageSetPublicRead') }}</MkSwitch> </template> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div> <div class="_content"> - <mk-input :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input> - <mk-button primary @click="chooseProxyAccount">{{ $t('chooseProxyAccount') }}</mk-button> + <MkInput :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></MkInput> + <MkButton primary @click="chooseProxyAccount">{{ $t('chooseProxyAccount') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faBan"/> {{ $t('blockedInstances') }}</div> <div class="_content"> - <mk-textarea v-model="blockedHosts"> + <MkTextarea v-model:value="blockedHosts"> <template #desc>{{ $t('blockedInstancesDescription') }}</template> - </mk-textarea> + </MkTextarea> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faShareAlt"/> {{ $t('integration') }}</div> <div class="_content"> - <header><fa :icon="faTwitter"/> Twitter</header> - <mk-switch v-model="enableTwitterIntegration">{{ $t('enable') }}</mk-switch> + <header><Fa :icon="faTwitter"/> Twitter</header> + <MkSwitch v-model:value="enableTwitterIntegration">{{ $t('enable') }}</MkSwitch> <template v-if="enableTwitterIntegration"> - <mk-info>Callback URL: {{ `${url}/api/tw/cb` }}</mk-info> - <mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Key</mk-input> - <mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Secret</mk-input> + <MkInfo>Callback URL: {{ `${url}/api/tw/cb` }}</MkInfo> + <MkInput v-model:value="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><Fa :icon="faKey"/></template>Consumer Key</MkInput> + <MkInput v-model:value="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><Fa :icon="faKey"/></template>Consumer Secret</MkInput> </template> </div> <div class="_content"> - <header><fa :icon="faGithub"/> GitHub</header> - <mk-switch v-model="enableGithubIntegration">{{ $t('enable') }}</mk-switch> + <header><Fa :icon="faGithub"/> GitHub</header> + <MkSwitch v-model:value="enableGithubIntegration">{{ $t('enable') }}</MkSwitch> <template v-if="enableGithubIntegration"> - <mk-info>Callback URL: {{ `${url}/api/gh/cb` }}</mk-info> - <mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input> - <mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input> + <MkInfo>Callback URL: {{ `${url}/api/gh/cb` }}</MkInfo> + <MkInput v-model:value="githubClientId" :disabled="!enableGithubIntegration"><template #icon><Fa :icon="faKey"/></template>Client ID</MkInput> + <MkInput v-model:value="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><Fa :icon="faKey"/></template>Client Secret</MkInput> </template> </div> <div class="_content"> - <header><fa :icon="faDiscord"/> Discord</header> - <mk-switch v-model="enableDiscordIntegration">{{ $t('enable') }}</mk-switch> + <header><Fa :icon="faDiscord"/> Discord</header> + <MkSwitch v-model:value="enableDiscordIntegration">{{ $t('enable') }}</MkSwitch> <template v-if="enableDiscordIntegration"> - <mk-info>Callback URL: {{ `${url}/api/dc/cb` }}</mk-info> - <mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input> - <mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input> + <MkInfo>Callback URL: {{ `${url}/api/dc/cb` }}</MkInfo> + <MkInput v-model:value="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><Fa :icon="faKey"/></template>Client ID</MkInput> + <MkInput v-model:value="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><Fa :icon="faKey"/></template>Client Secret</MkInput> </template> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faArchway" /> Summaly Proxy</div> + <section class="_section"> + <div class="_title"><Fa :icon="faArchway" /> Summaly Proxy</div> <div class="_content"> - <mk-input v-model="summalyProxy">URL</mk-input> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkInput v-model:value="summalyProxy">URL</MkInput> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, defineAsyncComponent } from 'vue'; import { faPencilAlt, faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt, faArchway } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import MkInfo from '../../components/ui/info.vue'; -import MkUserSelect from '../../components/user-select.vue'; -import { url } from '../../config'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkInfo from '@/components/ui/info.vue'; +import { url } from '@/config'; import getAcct from '../../../misc/acct/render'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('instance') as string - }; - }, - +export default defineComponent({ components: { MkButton, MkInput, MkTextarea, MkSwitch, MkInfo, - Captcha: () => import('../../components/captcha.vue').then(x => x.default), + Captcha: defineAsyncComponent(() => import('@/components/captcha.vue')), }, data() { return { + INFO: { + header: [{ + title: this.$t('instance'), + icon: faCog, + }], + }, url, proxyAccount: null, proxyAccountId: null, @@ -394,16 +391,16 @@ export default Vue.extend({ this.summalyProxy = this.meta.summalyProxy; if (this.proxyAccountId) { - this.$root.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { + os.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { this.proxyAccount = proxyAccount; }); } }, mounted() { - this.$refs.enableHcaptcha.$on('change', () => { + this.$watch('enableHcaptcha', () => { if (this.enableHcaptcha && this.enableRecaptcha) { - this.$root.dialog({ + os.dialog({ type: 'question', // warning だと間違って cancel するかもしれない showCancelButton: true, title: this.$t('settingGuide'), @@ -418,9 +415,9 @@ export default Vue.extend({ } }); - this.$refs.enableRecaptcha.$on('change', () => { + this.$watch('enableRecaptcha', () => { if (this.enableRecaptcha && this.enableHcaptcha) { - this.$root.dialog({ + os.dialog({ type: 'question', // warning だと間違って cancel するかもしれない showCancelButton: true, title: this.$t('settingGuide'), @@ -438,13 +435,13 @@ export default Vue.extend({ methods: { invite() { - this.$root.api('admin/invite').then(x => { - this.$root.dialog({ + os.api('admin/invite').then(x => { + os.dialog({ type: 'info', text: x.code }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -452,7 +449,7 @@ export default Vue.extend({ }, addPinUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.pinnedUsers = this.pinnedUsers.trim(); this.pinnedUsers += '\n@' + getAcct(user); this.pinnedUsers = this.pinnedUsers.trim(); @@ -460,7 +457,7 @@ export default Vue.extend({ }, chooseProxyAccount() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.proxyAccount = user; this.proxyAccountId = user.id; this.save(true); @@ -468,17 +465,17 @@ export default Vue.extend({ }, async testEmail() { - this.$root.api('admin/send-email', { + os.api('admin/send-email', { to: this.maintainerEmail, subject: 'Test email', text: 'Yo' }).then(x => { - this.$root.dialog({ + os.dialog({ type: 'success', splash: true }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -486,7 +483,7 @@ export default Vue.extend({ }, save(withDialog = false) { - this.$root.api('admin/update-meta', { + os.api('admin/update-meta', { name: this.name, description: this.description, tosUrl: this.tosUrl, @@ -547,13 +544,10 @@ export default Vue.extend({ }).then(() => { this.$store.dispatch('instance/fetch'); if (withDialog) { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); } }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); diff --git a/src/client/pages/instance/user-dialog.vue b/src/client/pages/instance/user-dialog.vue new file mode 100644 index 0000000000..3cf30e115f --- /dev/null +++ b/src/client/pages/instance/user-dialog.vue @@ -0,0 +1,233 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header v-if="user"><MkUserName class="name" :user="user"/></template> + <div class="vrcsvlkm" v-if="user && info"> + <div class="_section"> + <div class="banner" :style="bannerStyle"> + <MkAvatar class="avatar" :user="user"/> + </div> + </div> + <div class="_section"> + <div class="title"> + <span class="acct">@{{ acct(user) }}</span> + </div> + <div class="status"> + <span class="staff" v-if="user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="staff" v-if="user.isModerator"><Fa :icon="farBookmark"/></span> + <span class="punished" v-if="user.isSilenced"><Fa :icon="faMicrophoneSlash"/></span> + <span class="punished" v-if="user.isSuspended"><Fa :icon="faSnowflake"/></span> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkSwitch v-if="user.host == null && $store.state.i.isAdmin && (this.moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $t('moderator') }}</MkSwitch> + <MkSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $t('silence') }}</MkSwitch> + <MkSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $t('suspend') }}</MkSwitch> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton full @click="openProfile"><Fa :icon="faExternalLinkSquareAlt"/> {{ $t('profile') }}</MkButton> + <MkButton full v-if="user.host != null" @click="updateRemoteUser"><Fa :icon="faSync"/> {{ $t('updateRemoteUser') }}</MkButton> + <MkButton full @click="resetPassword"><Fa :icon="faKey"/> {{ $t('resetPassword') }}</MkButton> + <MkButton full @click="deleteAllFiles" danger><Fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</MkButton> + </div> + </div> + <div class="_section"> + <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 { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faTrashAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import Progress from '@/scripts/loading'; +import { acct, userPage } from '../../filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + XModalWindow, + }, + + props: { + userId: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + user: null, + info: null, + moderator: false, + silenced: false, + suspended: false, + faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt + }; + }, + + computed: { + bannerStyle(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ this.user.bannerUrl })` + }; + }, + }, + + created() { + this.fetch(); + }, + + methods: { + async fetch() { + Progress.start(); + this.user = await os.api('users/show', { userId: this.userId }); + this.info = await os.api('admin/show-user', { userId: this.userId }); + this.moderator = this.info.isModerator; + this.silenced = this.info.isSilenced; + this.suspended = this.info.isSuspended; + Progress.done(); + }, + + /** 処理対象ユーザーの情報を更新する */ + async refreshUser() { + this.user = await os.api('users/show', { userId: this.user.id }); + this.info = await os.api('admin/show-user', { userId: this.user.id }); + }, + + openProfile() { + window.open(userPage(this.user, null, true), '_blank'); + }, + + async updateRemoteUser() { + await os.api('admin/update-remote-user', { userId: this.user.id }).then(res => { + os.success(); + }); + await this.refreshUser(); + }, + + async resetPassword() { + os.apiWithDialog('admin/reset-password', { + userId: this.user.id, + }, undefined, ({ password }) => { + os.dialog({ + type: 'success', + text: this.$t('newPasswordIs', { password }) + }); + }); + }, + + async toggleSilence(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'), + }); + if (confirm.canceled) { + this.silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleSuspend(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'), + }); + if (confirm.canceled) { + this.suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); + await this.refreshUser(); + }, + + async deleteAllFiles() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$t('deleteAllFilesConfirm'), + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); + os.success(); + }; + await process().catch(e => { + os.dialog({ + type: 'error', + text: e.toString() + }); + }); + await this.refreshUser(); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.vrcsvlkm { + > ._section { + > .banner { + position: relative; + height: 100px; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + border-radius: 8px; + + > .avatar { + position: absolute; + top: 60px; + width: 64px; + height: 64px; + left: 0; + right: 0; + margin: 0 auto; + border: solid 4px var(--panel); + } + } + + > .title { + text-align: center; + } + + > .status { + text-align: center; + margin-top: 8px; + } + + > .rawdata { + overflow: auto; + } + } +} +</style> diff --git a/src/client/pages/instance/users.user.vue b/src/client/pages/instance/users.user.vue deleted file mode 100644 index 25f0260637..0000000000 --- a/src/client/pages/instance/users.user.vue +++ /dev/null @@ -1,206 +0,0 @@ -<template> -<div class="vrcsvlkm" v-if="user && info"> - <portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal> - <portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal> - - <section class="_card"> - <div class="_title"> - <mk-avatar class="avatar" :user="user"/> - <mk-user-name class="name" :user="user"/> - <span class="acct">@{{ user | acct }}</span> - <span class="staff" v-if="user.isAdmin"><fa :icon="faBookmark"/></span> - <span class="staff" v-if="user.isModerator"><fa :icon="farBookmark"/></span> - <span class="punished" v-if="user.isSilenced"><fa :icon="faMicrophoneSlash"/></span> - <span class="punished" v-if="user.isSuspended"><fa :icon="faSnowflake"/></span> - </div> - <div class="_content actions"> - <div style="flex: 1; padding-left: 1em;"> - <mk-switch v-if="user.host == null && $store.state.i.isAdmin && (this.moderator || !user.isAdmin)" @change="toggleModerator()" v-model="moderator">{{ $t('moderator') }}</mk-switch> - <mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch> - <mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch> - </div> - <div style="flex: 1; padding-left: 1em;"> - <mk-button @click="openProfile"><fa :icon="faExternalLinkSquareAlt"/> {{ $t('profile')}}</mk-button> - <mk-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('updateRemoteUser') }}</mk-button> - <mk-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('resetPassword') }}</mk-button> - <mk-button @click="deleteAllFiles"><fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</mk-button> - </div> - </div> - <div class="_content rawdata"> - <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> - </div> - </section> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; -import { faSnowflake, faTrashAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import Progress from '../../scripts/loading'; - -export default Vue.extend({ - components: { - MkButton, - MkSwitch, - }, - - data() { - return { - user: null, - info: null, - moderator: false, - silenced: false, - suspended: false, - faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt - }; - }, - - watch: { - $route: 'fetch' - }, - - created() { - this.fetch(); - }, - - methods: { - async fetch() { - Progress.start(); - this.user = await this.$root.api('users/show', { userId: this.$route.params.user }); - this.info = await this.$root.api('admin/show-user', { userId: this.$route.params.user }); - this.moderator = this.info.isModerator; - this.silenced = this.info.isSilenced; - this.suspended = this.info.isSuspended; - Progress.done(); - }, - - /** 処理対象ユーザーの情報を更新する */ - async refreshUser() { - this.user = await this.$root.api('users/show', { userId: this.user.id }); - this.info = await this.$root.api('admin/show-user', { userId: this.user.id }); - }, - - openProfile() { - window.open(Vue.filter('userPage')(this.user, null, true), '_blank'); - }, - - async updateRemoteUser() { - await this.$root.api('admin/update-remote-user', { userId: this.user.id }).then(res => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); - await this.refreshUser(); - }, - - async resetPassword() { - const dialog = this.$root.dialog({ - type: 'waiting', - iconOnly: true - }); - - this.$root.api('admin/reset-password', { - userId: this.user.id, - }).then(({ password }) => { - this.$root.dialog({ - type: 'success', - text: this.$t('newPasswordIs', { password }) - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }).finally(() => { - dialog.close(); - }); - }, - - async toggleSilence() { - const confirm = await this.$root.dialog({ - type: 'warning', - showCancelButton: true, - text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'), - }); - if (confirm.canceled) { - this.silenced = !this.silenced; - } else { - await this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, - - async toggleSuspend() { - const confirm = await this.$root.dialog({ - type: 'warning', - showCancelButton: true, - text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'), - }); - if (confirm.canceled) { - this.suspended = !this.suspended; - } else { - await this.$root.api(this.suspended ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, - - async toggleModerator() { - await this.$root.api(this.moderator ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); - await this.refreshUser(); - }, - - async deleteAllFiles() { - const confirm = await this.$root.dialog({ - type: 'warning', - showCancelButton: true, - text: this.$t('deleteAllFilesConfirm'), - }); - if (confirm.canceled) return; - const process = async () => { - await this.$root.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }; - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - await this.refreshUser(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.vrcsvlkm { - display: flex; - flex-direction: column; - - > ._card { - > .actions { - display: flex; - box-sizing: border-box; - text-align: left; - align-items: center; - margin-top: 16px; - margin-bottom: 16px; - } - - > .rawdata { - > pre > code { - display: block; - width: 100%; - height: 100%; - } - } - } -} -</style> diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue index cf3786c965..b891ed8412 100644 --- a/src/client/pages/instance/users.vue +++ b/src/client/pages/instance/users.vue @@ -1,33 +1,33 @@ <template> <div class="mk-instance-users"> - <portal to="icon"><fa :icon="faUsers"/></portal> - <portal to="title">{{ $t('users') }}</portal> + <div class="_section"> + <div class="_content"> + <MkButton inline primary @click="addUser()"><Fa :icon="faPlus"/> {{ $t('addUser') }}</MkButton> + </div> + </div> - <section class="_card _vMargin lookup"> - <div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div> + <div class="_section lookup"> + <div class="_title"><Fa :icon="faSearch"/> {{ $t('lookup') }}</div> <div class="_content"> - <mk-input class="target" v-model="target" type="text" @enter="showUser()"> + <MkInput class="target" v-model:value="target" type="text" @enter="showUser()"> <span>{{ $t('usernameOrUserId') }}</span> - </mk-input> - <mk-button @click="showUser()" primary><fa :icon="faSearch"/> {{ $t('lookup') }}</mk-button> - </div> - <div class="_footer"> - <mk-button inline primary @click="searchUser()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button> + </MkInput> + <MkButton @click="showUser()" primary><Fa :icon="faSearch"/> {{ $t('lookup') }}</MkButton> </div> - </section> + </div> - <section class="_card _vMargin users"> - <div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div> + <div class="_section users"> + <div class="_title"><Fa :icon="faUsers"/> {{ $t('users') }}</div> <div class="_content"> <div class="inputs" style="display: flex;"> - <mk-select v-model="sort" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> <template #label>{{ $t('sort') }}</template> <option value="-createdAt">{{ $t('registeredDate') }} ({{ $t('ascendingOrder') }})</option> <option value="+createdAt">{{ $t('registeredDate') }} ({{ $t('descendingOrder') }})</option> <option value="-updatedAt">{{ $t('lastUsed') }} ({{ $t('ascendingOrder') }})</option> <option value="+updatedAt">{{ $t('lastUsed') }} ({{ $t('descendingOrder') }})</option> - </mk-select> - <mk-select v-model="state" style="margin: 0; flex: 1;"> + </MkSelect> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> <template #label>{{ $t('state') }}</template> <option value="all">{{ $t('all') }}</option> <option value="available">{{ $t('normal') }}</option> @@ -35,71 +35,62 @@ <option value="moderator">{{ $t('moderator') }}</option> <option value="silenced">{{ $t('silence') }}</option> <option value="suspended">{{ $t('suspend') }}</option> - </mk-select> - <mk-select v-model="origin" style="margin: 0; flex: 1;"> + </MkSelect> + <MkSelect v-model:value="origin" style="margin: 0; flex: 1;"> <template #label>{{ $t('instance') }}</template> <option value="combined">{{ $t('all') }}</option> <option value="local">{{ $t('local') }}</option> <option value="remote">{{ $t('remote') }}</option> - </mk-select> + </MkSelect> </div> <div class="inputs" style="display: flex; padding-top: 1.2em;"> - <mk-input v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @input="$refs.users.reload()"> + <MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()"> <span>{{ $t('username') }}</span> - </mk-input> - <mk-input v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @input="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> + </MkInput> + <MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> <span>{{ $t('host') }}</span> - </mk-input> + </MkInput> </div> - </div> - <div class="_content _list"> - <mk-pagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false"> - <button class="user _button _listItem" v-for="(user, i) in items" :key="user.id" @click="show(user)"> - <mk-avatar class="avatar" :user="user" :disable-link="true"/> + + <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false"> + <button class="user _panel _button _vMargin" v-for="user in items" :key="user.id" @click="show(user)"> + <MkAvatar class="avatar" :user="user" :disable-link="true"/> <div class="body"> <header> - <mk-user-name class="name" :user="user"/> - <span class="acct">@{{ user | acct }}</span> - <span class="staff" v-if="user.isAdmin"><fa :icon="faBookmark"/></span> - <span class="staff" v-if="user.isModerator"><fa :icon="farBookmark"/></span> - <span class="punished" v-if="user.isSilenced"><fa :icon="faMicrophoneSlash"/></span> - <span class="punished" v-if="user.isSuspended"><fa :icon="faSnowflake"/></span> + <MkUserName class="name" :user="user"/> + <span class="acct">@{{ acct(user) }}</span> + <span class="staff" v-if="user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="staff" v-if="user.isModerator"><Fa :icon="farBookmark"/></span> + <span class="punished" v-if="user.isSilenced"><Fa :icon="faMicrophoneSlash"/></span> + <span class="punished" v-if="user.isSuspended"><Fa :icon="faSnowflake"/></span> </header> <div> - <span>{{ $t('lastUsed') }}: <mk-time :time="user.updatedAt" mode="detail"/></span> + <span>{{ $t('lastUsed') }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> </div> <div> - <span>{{ $t('registeredDate') }}: <mk-time :time="user.createdAt" mode="detail"/></span> + <span>{{ $t('registeredDate') }}: <MkTime :time="user.createdAt" mode="detail"/></span> </div> </div> </button> - </mk-pagination> - </div> - <div class="_footer"> - <mk-button inline primary @click="addUser()"><fa :icon="faPlus"/> {{ $t('addUser') }}</mk-button> + </MkPagination> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlus, faUsers, faSearch, faBookmark, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; import { faSnowflake, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; import parseAcct from '../../../misc/acct/parse'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkPagination from '../../components/ui/pagination.vue'; -import MkUserSelect from '../../components/user-select.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('users')} | ${this.$t('instance')}` - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { acct } from '../../filters/user'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, MkInput, @@ -109,6 +100,16 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('users'), + icon: faUsers + }], + action: { + icon: faSearch, + handler: this.searchUser + } + }, target: '', sort: '+createdAt', state: 'all', @@ -147,12 +148,12 @@ export default Vue.extend({ /** テキストエリアのユーザーを解決する */ fetchUser() { return new Promise((res) => { - const usernamePromise = this.$root.api('users/show', parseAcct(this.target)); - const idPromise = this.$root.api('users/show', { userId: this.target }); + const usernamePromise = os.api('users/show', parseAcct(this.target)); + const idPromise = os.api('users/show', { userId: this.target }); let _notFound = false; const notFound = () => { if (_notFound) { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('noSuchUser') }); @@ -179,51 +180,39 @@ export default Vue.extend({ }, searchUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.show(user); }); }, async addUser() { - const { canceled: canceled1, result: username } = await this.$root.dialog({ + const { canceled: canceled1, result: username } = await os.dialog({ title: this.$t('username'), input: true }); if (canceled1) return; - const { canceled: canceled2, result: password } = await this.$root.dialog({ + const { canceled: canceled2, result: password } = await os.dialog({ title: this.$t('password'), input: { type: 'password' } }); if (canceled2) return; - const dialog = this.$root.dialog({ - type: 'waiting', - iconOnly: true - }); - - this.$root.api('admin/accounts/create', { + os.apiWithDialog('admin/accounts/create', { username: username, password: password, }).then(res => { this.$refs.users.reload(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e.id - }); - }).finally(() => { - dialog.close(); }); }, async show(user) { - this.$router.push('./users/' + user.id); - } + os.popup(await import('./user-dialog.vue'), { + userId: user.id + }, {}, 'closed'); + }, + + acct } }); </script> @@ -232,28 +221,32 @@ export default Vue.extend({ .mk-instance-users { > .users { > ._content { - max-height: 300px; - overflow: auto; - > .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: 64px; - height: 64px; + width: 60px; + height: 60px; } > .body { margin-left: 0.3em; - padding: 8px; + padding: 0 8px; flex: 1; - @media (max-width 500px) { + @media (max-width: 500px) { font-size: 14px; } |