diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages/admin | |
| parent | wip: retention for dashboard (diff) | |
| download | misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages/admin')
40 files changed, 5704 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue new file mode 100644 index 0000000000..bdb41b2d2c --- /dev/null +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -0,0 +1,292 @@ +<template> +<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick"> + <template v-if="metadata"> + <div class="titleContainer" @click="showTabsPopup"> + <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <div class="title">{{ metadata.title }}</div> + </div> + </div> + <div class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + <div ref="tabHighlightEl" class="highlight"></div> + </div> + </template> + <div class="buttons right"> + <template v-if="actions"> + <template v-for="action in actions"> + <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { url } from '@/config'; +import { scrollToTop } from '@/scripts/scroll'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + asFullButton?: boolean; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const el = ref<HTMLElement>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref(null); +const height = ref(0); +const hasTabs = computed(() => { + return props.tabs && props.tabs.length > 0; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, + })); + popupMenu(menu, ev.currentTarget ?? ev.target); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); +}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + +const calcBg = () => { + const rawBg = metadata?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); +}); +</script> + +<style lang="scss" scoped> +.fdidabkc { + --height: 60px; + display: flex; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + max-width: 400px; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + width: 16px; + text-align: center; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + position: relative; + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + > .icon + .title { + margin-left: 8px; + } + } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue new file mode 100644 index 0000000000..973ec871ab --- /dev/null +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -0,0 +1,97 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lcixvhis"> + <div class="_section reports"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unresolved">{{ i18n.ts.unresolved }}</option> + <option value="resolved">{{ i18n.ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporteeOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporterOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + </div> + <!-- TODO + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false"> + <span>{{ i18n.ts.username }}</span> + </MkInput> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'"> + <span>{{ i18n.ts.host }}</span> + </MkInput> + </div> + --> + + <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + </MkPagination> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +import XHeader from './_header_.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XAbuseReport from '@/components/MkAbuseReport.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let reports = $ref<InstanceType<typeof MkPagination>>(); + +let state = $ref('unresolved'); +let reporterOrigin = $ref('combined'); +let targetUserOrigin = $ref('combined'); +let searchUsername = $ref(''); +let searchHost = $ref(''); + +const pagination = { + endpoint: 'admin/abuse-user-reports' as const, + limit: 10, + params: computed(() => ({ + state, + reporterOrigin, + targetUserOrigin, + })), +}; + +function resolved(reportId) { + reports.removeItem(item => item.id === reportId); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.abuseReports, + icon: 'ti ti-exclamation-circle', +}); +</script> + +<style lang="scss" scoped> +.lcixvhis { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue new file mode 100644 index 0000000000..2ec926c65c --- /dev/null +++ b/packages/frontend/src/pages/admin/ads.vue @@ -0,0 +1,132 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="uqshojas"> + <div v-for="ad in ads" class="_panel _formRoot ad"> + <MkAd v-if="ad.url" :specify="ad"/> + <MkInput v-model="ad.url" type="url" class="_formBlock"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="ad.imageUrl" class="_formBlock"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <FormRadios v-model="ad.place" class="_formBlock"> + <template #label>Form</template> + <option value="square">square</option> + <option value="horizontal">horizontal</option> + <option value="horizontal-big">horizontal-big</option> + </FormRadios> + <!-- + <div style="margin: 32px 0;"> + {{ i18n.ts.priority }} + <MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio> + <MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio> + <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> + </div> + --> + <FormSplit> + <MkInput v-model="ad.ratio" type="number"> + <template #label>{{ i18n.ts.ratio }}</template> + </MkInput> + <MkInput v-model="ad.expiresAt" type="date"> + <template #label>{{ i18n.ts.expiration }}</template> + </MkInput> + </FormSplit> + <MkTextarea v-model="ad.memo" class="_formBlock"> + <template #label>{{ i18n.ts.memo }}</template> + </MkTextarea> + <div class="buttons _formBlock"> + <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let ads: any[] = $ref([]); + +os.api('admin/ad/list').then(adsResponse => { + ads = adsResponse; +}); + +function add() { + ads.unshift({ + id: null, + memo: '', + place: 'square', + priority: 'middle', + ratio: 1, + url: '', + imageUrl: null, + expiresAt: null, + }); +} + +function remove(ad) { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: ad.url }), + }).then(({ canceled }) => { + if (canceled) return; + ads = ads.filter(x => x !== ad); + os.apiWithDialog('admin/ad/delete', { + id: ad.id, + }); + }); +} + +function save(ad) { + if (ad.id == null) { + os.apiWithDialog('admin/ad/create', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime(), + }); + } else { + os.apiWithDialog('admin/ad/update', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime(), + }); + } +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.ads, + icon: 'ti ti-ad', +}); +</script> + +<style lang="scss" scoped> +.uqshojas { + > .ad { + padding: 32px; + + &:not(:last-child) { + margin-bottom: var(--margin); + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue new file mode 100644 index 0000000000..607ad8aa02 --- /dev/null +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -0,0 +1,112 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ztgjmzrw"> + <section v-for="announcement in announcements" class="_card _gap announcements"> + <div class="_content announcement"> + <MkInput v-model="announcement.title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text"> + <template #label>{{ i18n.ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </section> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let announcements: any[] = $ref([]); + +os.api('admin/announcements/list').then(announcementResponse => { + announcements = announcementResponse; +}); + +function add() { + announcements.unshift({ + id: null, + title: '', + text: '', + imageUrl: null, + }); +} + +function remove(announcement) { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: announcement.title }), + }).then(({ canceled }) => { + if (canceled) return; + announcements = announcements.filter(x => x !== announcement); + os.api('admin/announcements/delete', announcement); + }); +} + +function save(announcement) { + if (announcement.id == null) { + os.api('admin/announcements/create', announcement).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); + } else { + os.api('admin/announcements/update', announcement).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); + } +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'ti ti-speakerphone', +}); +</script> + +<style lang="scss" scoped> +.ztgjmzrw { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue new file mode 100644 index 0000000000..d03961cf95 --- /dev/null +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -0,0 +1,109 @@ +<template> +<div> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormRadios v-model="provider" class="_formBlock"> + <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + <option value="turnstile">Turnstile</option> + </FormRadios> + + <template v-if="provider === 'hcaptcha'"> + <FormInput v-model="hcaptchaSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> + </FormInput> + <FormInput v-model="hcaptchaSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + </FormSlot> + </template> + <template v-else-if="provider === 'recaptcha'"> + <FormInput v-model="recaptchaSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSiteKey }}</template> + </FormInput> + <FormInput v-model="recaptchaSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSecretKey }}</template> + </FormInput> + <FormSlot v-if="recaptchaSiteKey" class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> + </FormSlot> + </template> + <template v-else-if="provider === 'turnstile'"> + <FormInput v-model="turnstileSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSiteKey }}</template> + </FormInput> + <FormInput v-model="turnstileSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/> + </FormSlot> + </template> + + <FormButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormSuspense> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSlot from '@/components/form/slot.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); + +let provider = $ref(null); +let hcaptchaSiteKey: string | null = $ref(null); +let hcaptchaSecretKey: string | null = $ref(null); +let recaptchaSiteKey: string | null = $ref(null); +let recaptchaSecretKey: string | null = $ref(null); +let turnstileSiteKey: string | null = $ref(null); +let turnstileSecretKey: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + hcaptchaSiteKey = meta.hcaptchaSiteKey; + hcaptchaSecretKey = meta.hcaptchaSecretKey; + recaptchaSiteKey = meta.recaptchaSiteKey; + recaptchaSecretKey = meta.recaptchaSecretKey; + turnstileSiteKey = meta.turnstileSiteKey; + turnstileSecretKey = meta.turnstileSecretKey; + + provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableHcaptcha: provider === 'hcaptcha', + hcaptchaSiteKey, + hcaptchaSecretKey, + enableRecaptcha: provider === 'recaptcha', + recaptchaSiteKey, + recaptchaSecretKey, + enableTurnstile: provider === 'turnstile', + turnstileSiteKey, + turnstileSecretKey, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue new file mode 100644 index 0000000000..5a0d3d5e51 --- /dev/null +++ b/packages/frontend/src/pages/admin/database.vue @@ -0,0 +1,35 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> + <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> + <template #key>{{ table[0] }}</template> + <template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template> + </MkKeyValue> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.database, + icon: 'ti ti-database', +}); +</script> diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue new file mode 100644 index 0000000000..6c9dee1704 --- /dev/null +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -0,0 +1,126 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableEmail" class="_formBlock"> + <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template> + <template #caption>{{ i18n.ts.emailConfigInfo }}</template> + </FormSwitch> + + <template v-if="enableEmail"> + <FormInput v-model="email" type="email" class="_formBlock"> + <template #label>{{ i18n.ts.emailAddress }}</template> + </FormInput> + + <FormSection> + <template #label>{{ i18n.ts.smtpConfig }}</template> + <FormSplit :min-width="280"> + <FormInput v-model="smtpHost" class="_formBlock"> + <template #label>{{ i18n.ts.smtpHost }}</template> + </FormInput> + <FormInput v-model="smtpPort" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPort }}</template> + </FormInput> + </FormSplit> + <FormSplit :min-width="280"> + <FormInput v-model="smtpUser" class="_formBlock"> + <template #label>{{ i18n.ts.smtpUser }}</template> + </FormInput> + <FormInput v-model="smtpPass" type="password" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPass }}</template> + </FormInput> + </FormSplit> + <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model="smtpSecure" class="_formBlock"> + <template #label>{{ i18n.ts.smtpSecure }}</template> + <template #caption>{{ i18n.ts.smtpSecureInfo }}</template> + </FormSwitch> + </FormSection> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { fetchInstance, instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let enableEmail: boolean = $ref(false); +let email: any = $ref(null); +let smtpSecure: boolean = $ref(false); +let smtpHost: string = $ref(''); +let smtpPort: number = $ref(0); +let smtpUser: string = $ref(''); +let smtpPass: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + enableEmail = meta.enableEmail; + email = meta.email; + smtpSecure = meta.smtpSecure; + smtpHost = meta.smtpHost; + smtpPort = meta.smtpPort; + smtpUser = meta.smtpUser; + smtpPass = meta.smtpPass; +} + +async function testEmail() { + const { canceled, result: destination } = await os.inputText({ + title: i18n.ts.destination, + type: 'email', + placeholder: instance.maintainerEmail, + }); + if (canceled) return; + os.apiWithDialog('admin/send-email', { + to: destination, + subject: 'Test email', + text: 'Yo', + }); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableEmail, + email, + smtpSecure, + smtpHost, + smtpPort, + smtpUser, + smtpPass, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + text: i18n.ts.testEmail, + handler: testEmail, +}, { + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.emailServer, + icon: 'ti ti-mail', +}); +</script> diff --git a/packages/frontend/src/pages/admin/emoji-edit-dialog.vue b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue new file mode 100644 index 0000000000..bd601cb1de --- /dev/null +++ b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue @@ -0,0 +1,106 @@ +<template> +<XModalWindow + ref="dialog" + :width="370" + :with-ok-button="true" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>:{{ emoji.name }}:</template> + + <div class="_monolithic_"> + <div class="yigymqpb _section"> + <img :src="emoji.url" class="img"/> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkInput v-model="category" class="_formBlock" :datalist="categories"> + <template #label>{{ i18n.ts.category }}</template> + </MkInput> + <MkInput v-model="aliases" class="_formBlock"> + <template #label>{{ i18n.ts.tags }}</template> + <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; +import { unique } from '@/scripts/array'; +import { i18n } from '@/i18n'; +import { emojiCategories } from '@/instance'; + +const props = defineProps<{ + emoji: any, +}>(); + +let dialog = $ref(null); +let name: string = $ref(props.emoji.name); +let category: string = $ref(props.emoji.category); +let aliases: string = $ref(props.emoji.aliases.join(' ')); +let categories: string[] = $ref(emojiCategories); + +const emit = defineEmits<{ + (ev: 'done', v: { deleted?: boolean, updated?: any }): void, + (ev: 'closed'): void +}>(); + +function ok() { + update(); +} + +async function update() { + await os.apiWithDialog('admin/emoji/update', { + id: props.emoji.id, + name, + category, + aliases: aliases.split(' '), + }); + + emit('done', { + updated: { + id: props.emoji.id, + name, + category, + aliases: aliases.split(' '), + }, + }); + + dialog.close(); +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: name }), + }); + if (canceled) return; + + os.api('admin/emoji/delete', { + id: props.emoji.id, + }).then(() => { + emit('done', { + deleted: true, + }); + dialog.close(); + }); +} +</script> + +<style lang="scss" scoped> +.yigymqpb { + > .img { + display: block; + height: 64px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/admin/emojis.vue b/packages/frontend/src/pages/admin/emojis.vue new file mode 100644 index 0000000000..14c8466d73 --- /dev/null +++ b/packages/frontend/src/pages/admin/emojis.vue @@ -0,0 +1,398 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ogwlenmc"> + <div v-if="tab === 'local'" class="local"> + <MkInput v-model="query" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> + </MkSwitch> + <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> + </div> + <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> + + <div v-else-if="tab === 'remote'" class="remote"> + <FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </FormSplit> + <MkPagination :pagination="remotePagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormSplit from '@/components/form/split.vue'; +import { selectFile, selectFiles } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>(); + +const tab = ref('local'); +const query = ref(null); +const queryRemote = ref(null); +const host = ref(null); +const selectMode = ref(false); +const selectedEmojis = ref<string[]>([]); + +const pagination = { + endpoint: 'admin/emoji/list' as const, + limit: 30, + params: computed(() => ({ + query: (query.value && query.value !== '') ? query.value : null, + })), +}; + +const remotePagination = { + endpoint: 'admin/emoji/list-remote' as const, + limit: 30, + params: computed(() => ({ + query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null, + host: (host.value && host.value !== '') ? host.value : null, + })), +}; + +const selectAll = () => { + if (selectedEmojis.value.length > 0) { + selectedEmojis.value = []; + } else { + selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id); + } +}; + +const toggleSelect = (emoji) => { + if (selectedEmojis.value.includes(emoji.id)) { + selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); + } else { + selectedEmojis.value.push(emoji.id); + } +}; + +const add = async (ev: MouseEvent) => { + const files = await selectFiles(ev.currentTarget ?? ev.target, null); + + const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { + fileId: file.id, + }))); + promise.then(() => { + emojisPaginationComponent.value.reload(); + }); + os.promiseDialog(promise); +}; + +const edit = (emoji) => { + os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + emoji: emoji, + }, { + done: result => { + if (result.updated) { + emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ + ...oldEmoji, + ...result.updated, + })); + } else if (result.deleted) { + emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); + } + }, + }, 'closed'); +}; + +const im = (emoji) => { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, + }); +}; + +const remoteMenu = (emoji, ev: MouseEvent) => { + os.popupMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: i18n.ts.import, + icon: 'ti ti-plus', + action: () => { im(emoji); }, + }], ev.currentTarget ?? ev.target); +}; + +const menu = (ev: MouseEvent) => { + os.popupMenu([{ + icon: 'ti ti-download', + text: i18n.ts.export, + action: async () => { + os.api('export-custom-emojis', { + }) + .then(() => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); + }).catch((err) => { + os.alert({ + type: 'error', + text: err.message, + }); + }); + }, + }, { + icon: 'ti ti-upload', + text: i18n.ts.import, + action: async () => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('admin/emoji/import-zip', { + fileId: file.id, + }) + .then(() => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); + }).catch((err) => { + os.alert({ + type: 'error', + text: err.message, + }); + }); + }, + }], ev.currentTarget ?? ev.target); +}; + +const setCategoryBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Category', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-category-bulk', { + ids: selectedEmojis.value, + category: result, + }); + emojisPaginationComponent.value.reload(); +}; + +const addTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/add-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const removeTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const setTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const delBulk = async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/delete-bulk', { + ids: selectedEmojis.value, + }); + emojisPaginationComponent.value.reload(); +}; + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addEmoji, + handler: add, +}, { + icon: 'ti ti-dots', + handler: menu, +}]); + +const headerTabs = $computed(() => [{ + key: 'local', + title: i18n.ts.local, +}, { + key: 'remote', + title: i18n.ts.remote, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', +}))); +</script> + +<style lang="scss" scoped> +.ogwlenmc { + > .local { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .emoji { + display: flex; + align-items: center; + padding: 11px; + text-align: left; + border: solid 1px var(--panel); + + &:hover { + border-color: var(--inputBorderHover); + } + + &.selected { + border-color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } + + > .remote { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 32px; + height: 32px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 90%; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue new file mode 100644 index 0000000000..8ad6bd4fc0 --- /dev/null +++ b/packages/frontend/src/pages/admin/files.vue @@ -0,0 +1,120 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions"/></template> + <MkSpacer :content-max="900"> + <div class="xrmjdkdw"> + <div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;"> + <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>User ID</template> + </MkInput> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let origin = $ref('local'); +let type = $ref(null); +let searchHost = $ref(''); +let userId = $ref(''); +let viewMode = $ref('grid'); +const pagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + type: (type && type !== '') ? type : null, + userId: (userId && userId !== '') ? userId : null, + origin: origin, + hostname: (searchHost && searchHost !== '') ? searchHost : null, + })), +}; + +function clear() { + os.confirm({ + type: 'warning', + text: i18n.ts.clearCachedFilesConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); +} + +function show(file) { + os.pageWindow(`/admin/file/${file.id}`); +} + +async function find() { + const { canceled, result: q } = await os.inputText({ + title: i18n.ts.fileIdOrUrl, + allowEmpty: false, + }); + if (canceled) return; + + os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { + show(file); + }).catch(err => { + if (err.code === 'NO_SUCH_FILE') { + os.alert({ + type: 'error', + text: i18n.ts.notFound, + }); + } + }); +} + +const headerActions = $computed(() => [{ + text: i18n.ts.lookup, + icon: 'ti ti-search', + handler: find, +}, { + text: i18n.ts.clearCachedFiles, + icon: 'ti ti-trash', + handler: clear, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.files, + icon: 'ti ti-cloud', +}))); +</script> + +<style lang="scss" scoped> +.xrmjdkdw { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue new file mode 100644 index 0000000000..6c07a87eeb --- /dev/null +++ b/packages/frontend/src/pages/admin/index.vue @@ -0,0 +1,316 @@ +<template> +<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <MkSpacer :content-max="700" :margin-min="16"> + <div class="lxpfedzu"> + <div class="banner"> + <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + </div> + + <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <RouterView/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { scroll } from '@/scripts/scroll'; +import { instance } from '@/instance'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +const isEmpty = (x: string | null) => x == null || x === ''; + +const router = useRouter(); + +const indexInfo = { + title: i18n.ts.controlPanel, + icon: 'ti ti-settings', + hideHeader: true, +}; + +provide('shouldOmitHeaderTitle', false); + +let INFO = $ref(indexInfo); +let childInfo = $ref(null); +let narrow = $ref(false); +let view = $ref(null); +let el = $ref(null); +let pageProps = $ref({}); +let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); +let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; +let noEmailServer = !instance.enableEmail; +let thereIsUnresolvedAbuseReport = $ref(false); +let currentPage = $computed(() => router.currentRef.value.child); + +os.api('admin/abuse-user-reports', { + state: 'unresolved', + limit: 1, +}).then(reports => { + if (reports.length > 0) thereIsUnresolvedAbuseReport = true; +}); + +const NARROW_THRESHOLD = 600; +const ro = new ResizeObserver((entries, observer) => { + if (entries.length === 0) return; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; +}); + +const menuDef = $computed(() => [{ + title: i18n.ts.quickAction, + items: [{ + type: 'button', + icon: 'ti ti-search', + text: i18n.ts.lookup, + action: lookup, + }, ...(instance.disableRegistration ? [{ + type: 'button', + icon: 'ti ti-user', + text: i18n.ts.invite, + action: invite, + }] : [])], +}, { + title: i18n.ts.administration, + items: [{ + icon: 'ti ti-dashboard', + text: i18n.ts.dashboard, + to: '/admin/overview', + active: currentPage?.route.name === 'overview', + }, { + icon: 'ti ti-users', + text: i18n.ts.users, + to: '/admin/users', + active: currentPage?.route.name === 'users', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.customEmojis, + to: '/admin/emojis', + active: currentPage?.route.name === 'emojis', + }, { + icon: 'ti ti-whirl', + text: i18n.ts.federation, + to: '/about#federation', + active: currentPage?.route.name === 'federation', + }, { + icon: 'ti ti-clock-play', + text: i18n.ts.jobQueue, + to: '/admin/queue', + active: currentPage?.route.name === 'queue', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.files, + to: '/admin/files', + active: currentPage?.route.name === 'files', + }, { + icon: 'ti ti-speakerphone', + text: i18n.ts.announcements, + to: '/admin/announcements', + active: currentPage?.route.name === 'announcements', + }, { + icon: 'ti ti-ad', + text: i18n.ts.ads, + to: '/admin/ads', + active: currentPage?.route.name === 'ads', + }, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.abuseReports, + to: '/admin/abuses', + active: currentPage?.route.name === 'abuses', + }], +}, { + title: i18n.ts.settings, + items: [{ + icon: 'ti ti-settings', + text: i18n.ts.general, + to: '/admin/settings', + active: currentPage?.route.name === 'settings', + }, { + icon: 'ti ti-mail', + text: i18n.ts.emailServer, + to: '/admin/email-settings', + active: currentPage?.route.name === 'email-settings', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.objectStorage, + to: '/admin/object-storage', + active: currentPage?.route.name === 'object-storage', + }, { + icon: 'ti ti-lock', + text: i18n.ts.security, + to: '/admin/security', + active: currentPage?.route.name === 'security', + }, { + icon: 'ti ti-planet', + text: i18n.ts.relays, + to: '/admin/relays', + active: currentPage?.route.name === 'relays', + }, { + icon: 'ti ti-share', + text: i18n.ts.integration, + to: '/admin/integrations', + active: currentPage?.route.name === 'integrations', + }, { + icon: 'ti ti-ban', + text: i18n.ts.instanceBlocking, + to: '/admin/instance-block', + active: currentPage?.route.name === 'instance-block', + }, { + icon: 'ti ti-ghost', + text: i18n.ts.proxyAccount, + to: '/admin/proxy-account', + active: currentPage?.route.name === 'proxy-account', + }, { + icon: 'ti ti-adjustments', + text: i18n.ts.other, + to: '/admin/other-settings', + active: currentPage?.route.name === 'other-settings', + }], +}, { + title: i18n.ts.info, + items: [{ + icon: 'ti ti-database', + text: i18n.ts.database, + to: '/admin/database', + active: currentPage?.route.name === 'database', + }], +}]); + +watch(narrow, () => { + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + +onMounted(() => { + ro.observe(el); + + narrow = el.offsetWidth < NARROW_THRESHOLD; + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +provideMetadataReceiver((info) => { + if (info == null) { + childInfo = null; + } else { + childInfo = info; + } +}); + +const invite = () => { + os.api('admin/invite').then(x => { + os.alert({ + type: 'info', + text: x.code, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); +}; + +const lookup = (ev) => { + os.popupMenu([{ + text: i18n.ts.user, + icon: 'ti ti-user', + action: () => { + lookupUser(); + }, + }, { + text: i18n.ts.note, + icon: 'ti ti-pencil', + action: () => { + alert('TODO'); + }, + }, { + text: i18n.ts.file, + icon: 'ti ti-cloud', + action: () => { + alert('TODO'); + }, + }, { + text: i18n.ts.instance, + icon: 'ti ti-planet', + action: () => { + alert('TODO'); + }, + }], ev.currentTarget ?? ev.target); +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); + +defineExpose({ + header: { + title: i18n.ts.controlPanel, + }, +}); +</script> + +<style lang="scss" scoped> +.hiyeyicy { + &.wide { + display: flex; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + max-width: 280px; + box-sizing: border-box; + border-right: solid 0.5px var(--divider); + overflow: auto; + height: 100%; + } + + > .main { + flex: 1; + min-width: 0; + } + } + + > .nav { + .lxpfedzu { + > .info { + margin: 16px 0; + } + + > .banner { + margin: 16px; + + > .icon { + display: block; + margin: auto; + height: 42px; + border-radius: 8px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue new file mode 100644 index 0000000000..1bdd174de4 --- /dev/null +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -0,0 +1,51 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormTextarea v-model="blockedHosts" class="_formBlock"> + <span>{{ i18n.ts.blockedInstances }}</span> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </FormTextarea> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let blockedHosts: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + blockedHosts = meta.blockedHosts.join('\n'); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: blockedHosts.split('\n') || [], + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceBlocking, + icon: 'ti ti-ban', +}); +</script> diff --git a/packages/frontend/src/pages/admin/integrations.discord.vue b/packages/frontend/src/pages/admin/integrations.discord.vue new file mode 100644 index 0000000000..0a69c44c93 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.discord.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableDiscordIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableDiscordIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo> + + <FormInput v-model="discordClientId" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client ID</template> + </FormInput> + + <FormInput v-model="discordClientSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableDiscordIntegration: boolean = $ref(false); +let discordClientId: string | null = $ref(null); +let discordClientSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableDiscordIntegration = meta.enableDiscordIntegration; + discordClientId = meta.discordClientId; + discordClientSecret = meta.discordClientSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableDiscordIntegration, + discordClientId, + discordClientSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.github.vue b/packages/frontend/src/pages/admin/integrations.github.vue new file mode 100644 index 0000000000..66419d5891 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.github.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableGithubIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableGithubIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/gh/cb` }}</FormInfo> + + <FormInput v-model="githubClientId" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client ID</template> + </FormInput> + + <FormInput v-model="githubClientSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableGithubIntegration: boolean = $ref(false); +let githubClientId: string | null = $ref(null); +let githubClientSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableGithubIntegration = meta.enableGithubIntegration; + githubClientId = meta.githubClientId; + githubClientSecret = meta.githubClientSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableGithubIntegration, + githubClientId, + githubClientSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.twitter.vue b/packages/frontend/src/pages/admin/integrations.twitter.vue new file mode 100644 index 0000000000..1e8d882b9c --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.twitter.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableTwitterIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableTwitterIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/tw/cb` }}</FormInfo> + + <FormInput v-model="twitterConsumerKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Consumer Key</template> + </FormInput> + + <FormInput v-model="twitterConsumerSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Consumer Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableTwitterIntegration: boolean = $ref(false); +let twitterConsumerKey: string | null = $ref(null); +let twitterConsumerSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableTwitterIntegration = meta.enableTwitterIntegration; + twitterConsumerKey = meta.twitterConsumerKey; + twitterConsumerSecret = meta.twitterConsumerSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableTwitterIntegration, + twitterConsumerKey, + twitterConsumerSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.vue b/packages/frontend/src/pages/admin/integrations.vue new file mode 100644 index 0000000000..9cc35baefd --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.vue @@ -0,0 +1,57 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-twitter"></i></template> + <template #label>Twitter</template> + <template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XTwitter/> + </FormFolder> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-github"></i></template> + <template #label>GitHub</template> + <template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XGithub/> + </FormFolder> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-discord"></i></template> + <template #label>Discord</template> + <template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XDiscord/> + </FormFolder> + </FormSuspense> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XTwitter from './integrations.twitter.vue'; +import XGithub from './integrations.github.vue'; +import XDiscord from './integrations.discord.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let enableTwitterIntegration: boolean = $ref(false); +let enableGithubIntegration: boolean = $ref(false); +let enableDiscordIntegration: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + enableTwitterIntegration = meta.enableTwitterIntegration; + enableGithubIntegration = meta.enableGithubIntegration; + enableDiscordIntegration = meta.enableDiscordIntegration; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'ti ti-share', +}); +</script> diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue new file mode 100644 index 0000000000..db8e448639 --- /dev/null +++ b/packages/frontend/src/pages/admin/metrics.vue @@ -0,0 +1,472 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="cpumem"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> + <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="disk"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div> + <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="net"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle, +} from 'chart.js'; +import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkwFederation from '../../widgets/federation.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle, +); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; +import * as os from '@/os'; +import { stream } from '@/stream'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + MkwFederation, + }, + + data() { + return { + version, + url, + stats: null, + serverInfo: null, + connection: null, + queueConnection: markRaw(stream.useChannel('queueStats')), + memUsage: 0, + chartCpuMem: null, + chartNet: null, + jobs: [], + logs: [], + logLevel: 'all', + logDomain: '', + modLogs: [], + dbInfo: null, + overviewHeight: '1fr', + queueHeight: '1fr', + paused: false, + }; + }, + + computed: { + gridColor() { + // TODO: var(--panel)の色が暗いか明るいかで判定する + return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + }, + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + os.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = markRaw(stream.useChannel('serverStats')); + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 150, + }); + + this.$nextTick(() => { + this.queueConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, + }); + }); + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + } + this.queueConnection.dispose(); + }, + + methods: { + cpumem(el) { + if (this.chartCpuMem != null) return; + this.chartCpuMem = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'CPU', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#86b300', + backgroundColor: alpha('#86b300', 0.1), + data: [], + }, { + label: 'MEM (active)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + backgroundColor: alpha('#935dbf', 0.02), + data: [], + }, { + label: 'MEM (used)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + borderDash: [5, 5], + fill: false, + data: [], + }], + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + max: 100, + }, + }, + }, + tooltips: { + intersect: false, + mode: 'index', + }, + }, + })); + }, + + net(el) { + if (this.chartNet != null) return; + this.chartNet = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'In', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [], + }, { + label: 'Out', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [], + }], + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + }, + tooltips: { + intersect: false, + mode: 'index', + }, + }, + })); + }, + + disk(el) { + if (this.chartDisk != null) return; + this.chartDisk = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Read', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [], + }, { + label: 'Write', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [], + }], + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0, + }, + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + }, + }, + }, + tooltips: { + intersect: false, + mode: 'index', + }, + }, + })); + }, + + fetchJobs() { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { + this.jobs = jobs; + }); + }, + + onStats(stats) { + if (this.paused) return; + + const cpu = (stats.cpu * 100).toFixed(0); + const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); + const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); + this.memUsage = stats.mem.active; + + this.chartCpuMem.data.labels.push(''); + this.chartCpuMem.data.datasets[0].data.push(cpu); + this.chartCpuMem.data.datasets[1].data.push(memActive); + this.chartCpuMem.data.datasets[2].data.push(memUsed); + this.chartNet.data.labels.push(''); + this.chartNet.data.datasets[0].data.push(stats.net.rx); + this.chartNet.data.datasets[1].data.push(stats.net.tx); + this.chartDisk.data.labels.push(''); + this.chartDisk.data.datasets[0].data.push(stats.fs.r); + this.chartDisk.data.datasets[1].data.push(stats.fs.w); + if (this.chartCpuMem.data.datasets[0].data.length > 150) { + this.chartCpuMem.data.labels.shift(); + this.chartCpuMem.data.datasets[0].data.shift(); + this.chartCpuMem.data.datasets[1].data.shift(); + this.chartCpuMem.data.datasets[2].data.shift(); + this.chartNet.data.labels.shift(); + this.chartNet.data.datasets[0].data.shift(); + this.chartNet.data.datasets[1].data.shift(); + this.chartDisk.data.labels.shift(); + this.chartDisk.data.datasets[0].data.shift(); + this.chartDisk.data.datasets[1].data.shift(); + } + this.chartCpuMem.update(); + this.chartNet.update(); + this.chartDisk.update(); + }, + + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + + bytes, + + number, + + pause() { + this.paused = true; + }, + + resume() { + this.paused = false; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.xhexznfu { + > div:nth-child(2) { + padding: 16px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue new file mode 100644 index 0000000000..f2ab30eaa5 --- /dev/null +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -0,0 +1,148 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch> + + <template v-if="useObjectStorage"> + <FormInput v-model="objectStorageBaseUrl" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> + <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageBucket" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBucket }}</template> + <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template> + </FormInput> + + <FormInput v-model="objectStoragePrefix" class="_formBlock"> + <template #label>{{ i18n.ts.objectStoragePrefix }}</template> + <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageEndpoint" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> + <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageRegion" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageRegion }}</template> + <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> + </FormInput> + + <FormSplit :min-width="280"> + <FormInput v-model="objectStorageAccessKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Access key</template> + </FormInput> + + <FormInput v-model="objectStorageSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Secret key</template> + </FormInput> + </FormSplit> + + <FormSwitch v-model="objectStorageUseSSL" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseSSL }}</template> + <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageUseProxy" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseProxy }}</template> + <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock"> + <template #label>s3ForcePathStyle</template> + </FormSwitch> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let useObjectStorage: boolean = $ref(false); +let objectStorageBaseUrl: string | null = $ref(null); +let objectStorageBucket: string | null = $ref(null); +let objectStoragePrefix: string | null = $ref(null); +let objectStorageEndpoint: string | null = $ref(null); +let objectStorageRegion: string | null = $ref(null); +let objectStoragePort: number | null = $ref(null); +let objectStorageAccessKey: string | null = $ref(null); +let objectStorageSecretKey: string | null = $ref(null); +let objectStorageUseSSL: boolean = $ref(false); +let objectStorageUseProxy: boolean = $ref(false); +let objectStorageSetPublicRead: boolean = $ref(false); +let objectStorageS3ForcePathStyle: boolean = $ref(true); + +async function init() { + const meta = await os.api('admin/meta'); + useObjectStorage = meta.useObjectStorage; + objectStorageBaseUrl = meta.objectStorageBaseUrl; + objectStorageBucket = meta.objectStorageBucket; + objectStoragePrefix = meta.objectStoragePrefix; + objectStorageEndpoint = meta.objectStorageEndpoint; + objectStorageRegion = meta.objectStorageRegion; + objectStoragePort = meta.objectStoragePort; + objectStorageAccessKey = meta.objectStorageAccessKey; + objectStorageSecretKey = meta.objectStorageSecretKey; + objectStorageUseSSL = meta.objectStorageUseSSL; + objectStorageUseProxy = meta.objectStorageUseProxy; + objectStorageSetPublicRead = meta.objectStorageSetPublicRead; + objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + useObjectStorage, + objectStorageBaseUrl, + objectStorageBucket, + objectStoragePrefix, + objectStorageEndpoint, + objectStorageRegion, + objectStoragePort, + objectStorageAccessKey, + objectStorageSecretKey, + objectStorageUseSSL, + objectStorageUseProxy, + objectStorageSetPublicRead, + objectStorageS3ForcePathStyle, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.objectStorage, + icon: 'ti ti-cloud', +}); +</script> diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue new file mode 100644 index 0000000000..62dff6ce7f --- /dev/null +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -0,0 +1,44 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + none + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +async function init() { + await os.api('admin/meta'); +} + +function save() { + os.apiWithDialog('admin/update-meta').then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'ti ti-adjustments', +}); +</script> diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue new file mode 100644 index 0000000000..c3ce5ac901 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -0,0 +1,217 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root" class="_panel"> + <canvas ref="chartEl"></canvas> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import * as os from '@/os'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import gradient from 'chartjs-plugin-gradient'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + gradient, +); + +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})`; +}; + +const chartEl = $ref<HTMLCanvasElement>(null); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 7; +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + const colorRead = '#3498db'; + const colorWrite = '#2ecc71'; + + const max = Math.max(...raw.read); + + chartInstance = new Chart(chartEl, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'Read', + data: format(raw.read).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorRead, + barPercentage: 0.7, + categoryPercentage: 0.5, + fill: true, + }, { + parsing: false, + label: 'Write', + data: format(raw.write).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorWrite, + barPercentage: 0.7, + categoryPercentage: 0.5, + fill: true, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: true, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 8, + }, + adapters: { + date: { + locale: enUS, + }, + }, + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +} + +onMounted(async () => { + renderChart(); +}); +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue new file mode 100644 index 0000000000..024ffdc245 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -0,0 +1,346 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <div class="charts _panel"> + <div class="chart"> + <canvas ref="chartEl2"></canvas> + </div> + <div class="chart"> + <canvas ref="chartEl"></canvas> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import gradient from 'chartjs-plugin-gradient'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + gradient, +); + +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})`; +}; + +const chartLimit = 50; +const chartEl = $ref<HTMLCanvasElement>(); +const chartEl2 = $ref<HTMLCanvasElement>(); +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +onMounted(async () => { + const now = new Date(); + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const formatMinus = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: -v, + })); + }; + + const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const succColor = '#87e000'; + const failColor = '#ff4400'; + + const succMax = Math.max(...raw.deliverSucceeded); + const failMax = Math.max(...raw.deliverFailed); + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + new Chart(chartEl, { + type: 'line', + data: { + datasets: [{ + stack: 'a', + parsing: false, + label: 'Out: Succ', + data: format(raw.deliverSucceeded).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + borderColor: succColor, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: alpha(succColor, 0.35), + fill: true, + clip: 8, + }, { + stack: 'a', + parsing: false, + label: 'Out: Fail', + data: formatMinus(raw.deliverFailed).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + borderColor: failColor, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: alpha(failColor, 0.35), + fill: true, + clip: 8, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + stacked: true, + offset: false, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + stacked: true, + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + //mirror: true, + callback: (value, index, values) => value < 0 ? -value : value, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + new Chart(chartEl2, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'In', + data: format(raw.inboxReceived).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: '#0cc2d6', + barPercentage: 0.8, + categoryPercentage: 0.9, + fill: true, + clip: 8, + }], + }, + options: { + aspectRatio: 5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: false, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler2, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .charts { + > .chart { + padding: 16px; + + &:first-child { + border-bottom: solid 0.5px var(--divider); + } + } + } + } +} +</style> + diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue new file mode 100644 index 0000000000..71f5a054b4 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -0,0 +1,185 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies"> + <div class="pie deliver _panel"> + <div class="title">Sub</div> + <XPie :data="topSubInstancesForPie" class="chart"/> + <div class="subTitle">Top 10</div> + </div> + <div class="pie inbox _panel"> + <div class="title">Pub</div> + <XPie :data="topPubInstancesForPie" class="chart"/> + <div class="subTitle">Top 10</div> + </div> + </div> + <div v-if="!fetching" class="items"> + <div class="item _panel sub"> + <div class="icon"><i class="ti ti-world-download"></i></div> + <div class="body"> + <div class="value"> + {{ number(federationSubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> + </div> + <div class="label">Sub</div> + </div> + </div> + <div class="item _panel pub"> + <div class="icon"><i class="ti ti-world-upload"></i></div> + <div class="body"> + <div class="value"> + {{ number(federationPubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> + </div> + <div class="label">Pub</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import XPie from './overview.pie.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +onMounted(async () => { + const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' }); + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + + &:global { + > .pies { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin-bottom: 12px; + + > .pie { + position: relative; + padding: 12px; + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + + > .chart { + max-height: 150px; + } + + > .subTitle { + position: absolute; + bottom: 20px; + right: 20px; + font-size: 85%; + } + } + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + + > .item { + display: flex; + box-sizing: border-box; + padding: 12px; + + > .icon { + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + margin-right: 12px; + background: var(--accentedBg); + color: var(--accent); + border-radius: 10px; + } + + &.sub { + > .icon { + background: #d5ba0026; + color: #dfc300; + } + } + + &.pub { + > .icon { + background: #00cf2326; + color: #00cd5b; + } + } + + > .body { + padding: 2px 0; + + > .value { + font-size: 1.25em; + font-weight: bold; + + > .diff { + font-size: 0.65em; + font-weight: normal; + } + } + + > .label { + font-size: 0.8em; + opacity: 0.5; + } + } + } + } + } +} +</style> + diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue new file mode 100644 index 0000000000..16d1c83b9f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -0,0 +1,15 @@ +<template> +<div class="_panel" :class="$style.root"> + <MkActiveUsersHeatmap/> +</div> +</template> + +<script lang="ts" setup> +import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue new file mode 100644 index 0000000000..29848bf03b --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -0,0 +1,50 @@ +<template> +<div class="wbrkwale"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else class="instances"> + <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; + +const instances = ref([]); +const fetching = ref(true); + +const fetch = async () => { + const fetchedInstances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 6, + }); + instances.value = fetchedInstances; + fetching.value = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.wbrkwale { + > .instances { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue new file mode 100644 index 0000000000..a1f63c8711 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -0,0 +1,55 @@ +<template> +<div> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root" class="_panel"> + <MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`"> + <MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let moderators: any = $ref(null); +let fetching = $ref(true); + +onMounted(async () => { + moderators = await os.api('admin/show-users', { + sort: '+lastActiveDate', + state: 'adminOrModerator', + limit: 30, + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30px, 40px)); + grid-gap: 12px; + place-content: center; + padding: 12px; + + &:global { + > .user { + width: 100%; + height: 100%; + aspect-ratio: 1; + + > .avatar { + width: 100%; + height: 100%; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue new file mode 100644 index 0000000000..94509cf006 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -0,0 +1,110 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + data: { name: string; value: number; color: string; onClick?: () => void }[]; +}>(); + +const chartEl = ref<HTMLCanvasElement>(null); + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip({ + position: 'middle', +}); + +let chartInstance: Chart; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'doughnut', + data: { + labels: props.data.map(x => x.name), + datasets: [{ + backgroundColor: props.data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: props.data.map(x => x.value), + }], + }, + options: { + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick) { + props.data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue new file mode 100644 index 0000000000..1e095bddaa --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -0,0 +1,186 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +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})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue new file mode 100644 index 0000000000..72ebddc72f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -0,0 +1,127 @@ +<template> +<div :class="$style.root"> + <div class="_table status"> + <div class="_row"> + <div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> + </div> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> + </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './overview.queue.chart.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); + +const activeSincePrevTick = ref(0); +const active = ref(0); +const delayed = ref(0); +const waiting = ref(0); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); + +const props = defineProps<{ + domain: string; +}>(); + +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + +onMounted(() => { + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 100, + }); +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .status { + padding: 0 0 16px 0; + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + font-size: 0.85em; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue new file mode 100644 index 0000000000..feac6f8118 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.retention.vue @@ -0,0 +1,49 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root"> + <div v-for="row in retention" class="row"> + <div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell"> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let retention: any = $ref(null); +let fetching = $ref(true); + +function getValues(row) { + const data = []; + for (const key in row.data) { + data.push({ + date: new Date(key), + value: number(row.data[key]), + percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`, + }); + } + data.sort((a, b) => a.date > b.date); + return data; +} + +onMounted(async () => { + retention = await os.apiGet('retention', {}); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + + &:global { + + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue new file mode 100644 index 0000000000..4dcf7e751a --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -0,0 +1,155 @@ +<template> +<div> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root"> + <div class="item _panel users"> + <div class="icon"><i class="ti ti-users"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> + </div> + <div class="label">Users</div> + </div> + </div> + <div class="item _panel notes"> + <div class="icon"><i class="ti ti-pencil"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> + </div> + <div class="label">Notes</div> + </div> + </div> + <div class="item _panel instances"> + <div class="icon"><i class="ti ti-planet"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.instances) }} + </div> + <div class="label">Instances</div> + </div> + </div> + <div class="item _panel online"> + <div class="icon"><i class="ti ti-access-point"></i></div> + <div class="body"> + <div class="value"> + {{ number(onlineUsersCount) }} + </div> + <div class="label">Online</div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; + +let stats: any = $ref(null); +let usersComparedToThePrevDay = $ref<number>(); +let notesComparedToThePrevDay = $ref<number>(); +let onlineUsersCount = $ref(0); +let fetching = $ref(true); + +onMounted(async () => { + const [_stats, _onlineUsersCount] = await Promise.all([ + os.api('stats', {}), + os.api('get-online-users-count').then(res => res.count), + ]); + stats = _stats; + onlineUsersCount = _onlineUsersCount; + + os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { + usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1]; + }); + + os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { + notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1]; + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + + &:global { + > .item { + display: flex; + box-sizing: border-box; + padding: 12px; + + > .icon { + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + margin-right: 12px; + background: var(--accentedBg); + color: var(--accent); + border-radius: 10px; + } + + &.users { + > .icon { + background: #0088d726; + color: #3d96c1; + } + } + + &.notes { + > .icon { + background: #86b30026; + color: #86b300; + } + } + + &.instances { + > .icon { + background: #e96b0026; + color: #d76d00; + } + } + + &.online { + > .icon { + background: #8a00d126; + color: #c01ac3; + } + } + + > .body { + padding: 2px 0; + + > .value { + font-size: 1.25em; + font-weight: bold; + + > .diff { + font-size: 0.65em; + font-weight: normal; + } + } + + > .label { + font-size: 0.8em; + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue new file mode 100644 index 0000000000..5d4be11742 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -0,0 +1,57 @@ +<template> +<div :class="$style.root"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else class="users"> + <MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user"> + <MkUserCardMini :user="user"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +let newUsers = $ref(null); +let fetching = $ref(true); + +const fetch = async () => { + const _newUsers = await os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + origin: 'local', + }); + newUsers = _newUsers; + fetching = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .users { + .chart-move { + transition: transform 1s ease; + } + + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 12px; + + > .user:hover { + text-decoration: none; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue new file mode 100644 index 0000000000..d656e55200 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.vue @@ -0,0 +1,190 @@ +<template> +<MkSpacer :content-max="1000"> + <div ref="rootEl" class="edbbcaef"> + <MkFolder class="item"> + <template #header>Stats</template> + <XStats/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Active users</template> + <XActiveUsers/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Heatmap</template> + <XHeatmap/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Retention rate</template> + <XRetention/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Moderators</template> + <XModerators/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Federation</template> + <XFederation/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Instances</template> + <XInstances/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Ap requests</template> + <XApRequests/> + </MkFolder> + + <MkFolder class="item"> + <template #header>New users</template> + <XUsers/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Deliver queue</template> + <XQueue domain="deliver"/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Inbox queue</template> + <XQueue domain="inbox"/> + </MkFolder> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import XFederation from './overview.federation.vue'; +import XInstances from './overview.instances.vue'; +import XQueue from './overview.queue.vue'; +import XApRequests from './overview.ap-requests.vue'; +import XUsers from './overview.users.vue'; +import XActiveUsers from './overview.active-users.vue'; +import XStats from './overview.stats.vue'; +import XRetention from './overview.retention.vue'; +import XModerators from './overview.moderators.vue'; +import XHeatmap from './overview.heatmap.vue'; +import MkTagCloud from '@/components/MkTagCloud.vue'; +import { version, url } from '@/config'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import MkFolder from '@/components/MkFolder.vue'; + +const rootEl = $ref<HTMLElement>(); +let serverInfo: any = $ref(null); +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let newUsers = $ref(null); +let activeInstances = $shallowRef(null); +const queueStatsConnection = markRaw(stream.useChannel('queueStats')); +const now = new Date(); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 9, + noPaging: true, +}; + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +onMounted(async () => { + /* + const magicGrid = new MagicGrid({ + container: rootEl, + static: true, + animate: true, + }); + + magicGrid.listen(); + */ + + os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + }); + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + + os.api('admin/server-info').then(serverInfoResponse => { + serverInfo = serverInfoResponse; + }); + + os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + }).then(res => { + newUsers = res; + }); + + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + }); + + nextTick(() => { + queueStatsConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 100, + }); + }); +}); + +onBeforeUnmount(() => { + queueStatsConnection.dispose(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.dashboard, + icon: 'ti ti-dashboard', +}); +</script> + +<style lang="scss" scoped> +.edbbcaef { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + grid-gap: 16px; +} +</style> diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue new file mode 100644 index 0000000000..5d0d67980e --- /dev/null +++ b/packages/frontend/src/pages/admin/proxy-account.vue @@ -0,0 +1,62 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.proxyAccount }}</template> + <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template> + </MkKeyValue> + + <FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton> + </FormSuspense> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let proxyAccount: any = $ref(null); +let proxyAccountId: any = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + proxyAccountId = meta.proxyAccountId; + if (proxyAccountId) { + proxyAccount = await os.api('users/show', { userId: proxyAccountId }); + } +} + +function chooseProxyAccount() { + os.selectUser().then(user => { + proxyAccount = user; + proxyAccountId = user.id; + save(); + }); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + proxyAccountId: proxyAccountId, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.proxyAccount, + icon: 'ti ti-ghost', +}); +</script> diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue new file mode 100644 index 0000000000..5777674ae3 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -0,0 +1,186 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +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})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..186a22c43e --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -0,0 +1,149 @@ +<template> +<div class="pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> + </div> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> + </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> + </div> + </div> + <span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './queue.chart.chart.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); + +const activeSincePrevTick = ref(0); +const active = ref(0); +const delayed = ref(0); +const waiting = ref(0); +const jobs = ref([]); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); + +const props = defineProps<{ + domain: string; +}>(); + +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + +onMounted(() => { + os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { + jobs.value = result; + }); + + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, + }); +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); +</script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + margin-bottom: 8px; + } + } + } + + > .jobs { + margin-top: 16px; + padding: 16px; + max-height: 180px; + overflow: auto; + background: var(--panel); + border-radius: var(--radius); + } + +} +</style> diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue new file mode 100644 index 0000000000..8d19b49fc5 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.vue @@ -0,0 +1,56 @@ +<template> +<MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XQueue v-if="tab === 'deliver'" domain="deliver"/> + <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import XQueue from './queue.chart.vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import * as config from '@/config'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('deliver'); + +function clear() { + os.confirm({ + type: 'warning', + title: i18n.ts.clearQueueConfirmTitle, + text: i18n.ts.clearQueueConfirmText, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/queue/clear'); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-external-link', + text: i18n.ts.dashboard, + handler: () => { + window.open(config.url + '/queue', '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'deliver', + title: 'Deliver', +}, { + key: 'inbox', + title: 'Inbox', +}]); + +definePageMetadata({ + title: i18n.ts.jobQueue, + icon: 'ti ti-clock-play', +}); +</script> diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue new file mode 100644 index 0000000000..4768ae67b1 --- /dev/null +++ b/packages/frontend/src/pages/admin/relays.vue @@ -0,0 +1,103 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div class="status"> + <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i> + <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i> + <i v-else class="ti ti-clock icon requesting"></i> + <span>{{ $t(`_relayStatus.${relay.status}`) }}</span> + </div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let relays: any[] = $ref([]); + +async function addRelay() { + const { canceled, result: inbox } = await os.inputText({ + title: i18n.ts.addRelay, + type: 'url', + placeholder: i18n.ts.inboxUrl, + }); + if (canceled) return; + os.api('admin/relays/add', { + inbox, + }).then((relay: any) => { + refresh(); + }).catch((err: any) => { + os.alert({ + type: 'error', + text: err.message || err, + }); + }); +} + +function remove(inbox: string) { + os.api('admin/relays/remove', { + inbox, + }).then(() => { + refresh(); + }).catch((err: any) => { + os.alert({ + type: 'error', + text: err.message || err, + }); + }); +} + +function refresh() { + os.api('admin/relays/list').then((relayList: any) => { + relays = relayList; + }); +} + +refresh(); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addRelay, + handler: addRelay, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.relays, + icon: 'ti ti-planet', +}); +</script> + +<style lang="scss" scoped> +.relaycxt { + > .status { + margin: 8px 0; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.accepted { + color: var(--success); + } + + &.rejected { + color: var(--error); + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue new file mode 100644 index 0000000000..2682bda337 --- /dev/null +++ b/packages/frontend/src/pages/admin/security.vue @@ -0,0 +1,179 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-shield"></i></template> + <template #label>{{ i18n.ts.botProtection }}</template> + <template v-if="enableHcaptcha" #suffix>hCaptcha</template> + <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> + <template v-else-if="enableTurnstile" #suffix>Turnstile</template> + <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> + + <XBotProtection/> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-eye-off"></i></template> + <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> + <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> + <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> + <template v-else #suffix>{{ i18n.ts.none }}</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span> + + <FormRadios v-model="sensitiveMediaDetection" class="_formBlock"> + <option value="none">{{ i18n.ts.none }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.localOnly }}</option> + <option value="remote">{{ i18n.ts.remoteOnly }}</option> + </FormRadios> + + <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> + </FormRange> + + <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> + </FormSwitch> + + <!-- 現状 false positive が多すぎて実用に耐えない + <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> + </FormSwitch> + --> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Active Email Validation</template> + <template v-if="enableActiveEmailValidation" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span> + <FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:model-value="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Log IP address</template> + <template v-if="enableIpLogging" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:model-value="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Summaly Proxy</template> + + <div class="_formRoot"> + <FormInput v-model="summalyProxy" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>Summaly Proxy URL</template> + </FormInput> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XBotProtection from './bot-protection.vue'; +import XHeader from './_header_.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormRange from '@/components/form/range.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let summalyProxy: string = $ref(''); +let enableHcaptcha: boolean = $ref(false); +let enableRecaptcha: boolean = $ref(false); +let enableTurnstile: boolean = $ref(false); +let sensitiveMediaDetection: string = $ref('none'); +let sensitiveMediaDetectionSensitivity: number = $ref(0); +let setSensitiveFlagAutomatically: boolean = $ref(false); +let enableSensitiveMediaDetectionForVideos: boolean = $ref(false); +let enableIpLogging: boolean = $ref(false); +let enableActiveEmailValidation: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + summalyProxy = meta.summalyProxy; + enableHcaptcha = meta.enableHcaptcha; + enableRecaptcha = meta.enableRecaptcha; + enableTurnstile = meta.enableTurnstile; + sensitiveMediaDetection = meta.sensitiveMediaDetection; + sensitiveMediaDetectionSensitivity = + meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : + meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 : + meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 : + meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 : + meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0; + setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically; + enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos; + enableIpLogging = meta.enableIpLogging; + enableActiveEmailValidation = meta.enableActiveEmailValidation; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + summalyProxy, + sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: + sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : + sensitiveMediaDetectionSensitivity === 1 ? 'low' : + sensitiveMediaDetectionSensitivity === 2 ? 'medium' : + sensitiveMediaDetectionSensitivity === 3 ? 'high' : + sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : + 0, + setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos, + enableIpLogging, + enableActiveEmailValidation, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'ti ti-lock', +}); +</script> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue new file mode 100644 index 0000000000..460eb92694 --- /dev/null +++ b/packages/frontend/src/pages/admin/settings.vue @@ -0,0 +1,262 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.instanceName }}</template> + </FormInput> + + <FormTextarea v-model="description" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDescription }}</template> + </FormTextarea> + + <FormInput v-model="tosUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.tosUrl }}</template> + </FormInput> + + <FormSplit :min-width="300"> + <FormInput v-model="maintainerName" class="_formBlock"> + <template #label>{{ i18n.ts.maintainerName }}</template> + </FormInput> + + <FormInput v-model="maintainerEmail" type="email" class="_formBlock"> + <template #prefix><i class="ti ti-mail"></i></template> + <template #label>{{ i18n.ts.maintainerEmail }}</template> + </FormInput> + </FormSplit> + + <FormTextarea v-model="pinnedUsers" class="_formBlock"> + <template #label>{{ i18n.ts.pinnedUsers }}</template> + <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> + </FormTextarea> + + <FormSection> + <FormSwitch v-model="enableRegistration" class="_formBlock"> + <template #label>{{ i18n.ts.enableRegistration }}</template> + </FormSwitch> + + <FormSwitch v-model="emailRequiredForSignup" class="_formBlock"> + <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + </FormSwitch> + </FormSection> + + <FormSection> + <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.theme }}</template> + + <FormInput v-model="iconUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.iconUrl }}</template> + </FormInput> + + <FormInput v-model="bannerUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.bannerUrl }}</template> + </FormInput> + + <FormInput v-model="backgroundImageUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.backgroundImageUrl }}</template> + </FormInput> + + <FormInput v-model="themeColor" class="_formBlock"> + <template #prefix><i class="ti ti-palette"></i></template> + <template #label>{{ i18n.ts.themeColor }}</template> + <template #caption>#RRGGBB</template> + </FormInput> + + <FormTextarea v-model="defaultLightTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + + <FormTextarea v-model="defaultDarkTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.files }}</template> + + <FormSwitch v-model="cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.cacheRemoteFiles }}</template> + <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> + + <FormSplit :min-width="280"> + <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + + <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + </FormSplit> + </FormSection> + + <FormSection> + <template #label>ServiceWorker</template> + + <FormSwitch v-model="enableServiceWorker" class="_formBlock"> + <template #label>{{ i18n.ts.enableServiceworker }}</template> + <template #caption>{{ i18n.ts.serviceworkerInfo }}</template> + </FormSwitch> + + <template v-if="enableServiceWorker"> + <FormInput v-model="swPublicKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Public key</template> + </FormInput> + + <FormInput v-model="swPrivateKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Private key</template> + </FormInput> + </template> + </FormSection> + + <FormSection> + <template #label>DeepL Translation</template> + + <FormInput v-model="deeplAuthKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>DeepL Auth Key</template> + </FormInput> + <FormSwitch v-model="deeplIsPro" class="_formBlock"> + <template #label>Pro account</template> + </FormSwitch> + </FormSection> + </div> + </FormSuspense> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let name: string | null = $ref(null); +let description: string | null = $ref(null); +let tosUrl: string | null = $ref(null); +let maintainerName: string | null = $ref(null); +let maintainerEmail: string | null = $ref(null); +let iconUrl: string | null = $ref(null); +let bannerUrl: string | null = $ref(null); +let backgroundImageUrl: string | null = $ref(null); +let themeColor: any = $ref(null); +let defaultLightTheme: any = $ref(null); +let defaultDarkTheme: any = $ref(null); +let enableLocalTimeline: boolean = $ref(false); +let enableGlobalTimeline: boolean = $ref(false); +let pinnedUsers: string = $ref(''); +let cacheRemoteFiles: boolean = $ref(false); +let localDriveCapacityMb: any = $ref(0); +let remoteDriveCapacityMb: any = $ref(0); +let enableRegistration: boolean = $ref(false); +let emailRequiredForSignup: boolean = $ref(false); +let enableServiceWorker: boolean = $ref(false); +let swPublicKey: any = $ref(null); +let swPrivateKey: any = $ref(null); +let deeplAuthKey: string = $ref(''); +let deeplIsPro: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + name = meta.name; + description = meta.description; + tosUrl = meta.tosUrl; + iconUrl = meta.iconUrl; + bannerUrl = meta.bannerUrl; + backgroundImageUrl = meta.backgroundImageUrl; + themeColor = meta.themeColor; + defaultLightTheme = meta.defaultLightTheme; + defaultDarkTheme = meta.defaultDarkTheme; + maintainerName = meta.maintainerName; + maintainerEmail = meta.maintainerEmail; + enableLocalTimeline = !meta.disableLocalTimeline; + enableGlobalTimeline = !meta.disableGlobalTimeline; + pinnedUsers = meta.pinnedUsers.join('\n'); + cacheRemoteFiles = meta.cacheRemoteFiles; + localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; + remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; + enableRegistration = !meta.disableRegistration; + emailRequiredForSignup = meta.emailRequiredForSignup; + enableServiceWorker = meta.enableServiceWorker; + swPublicKey = meta.swPublickey; + swPrivateKey = meta.swPrivateKey; + deeplAuthKey = meta.deeplAuthKey; + deeplIsPro = meta.deeplIsPro; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + name, + description, + tosUrl, + iconUrl, + bannerUrl, + backgroundImageUrl, + themeColor: themeColor === '' ? null : themeColor, + defaultLightTheme: defaultLightTheme === '' ? null : defaultLightTheme, + defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme, + maintainerName, + maintainerEmail, + disableLocalTimeline: !enableLocalTimeline, + disableGlobalTimeline: !enableGlobalTimeline, + pinnedUsers: pinnedUsers.split('\n'), + cacheRemoteFiles, + localDriveCapacityMb: parseInt(localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10), + disableRegistration: !enableRegistration, + emailRequiredForSignup, + enableServiceWorker, + swPublicKey, + swPrivateKey, + deeplAuthKey, + deeplIsPro, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'ti ti-settings', +}); +</script> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue new file mode 100644 index 0000000000..d466e21907 --- /dev/null +++ b/packages/frontend/src/pages/admin/users.vue @@ -0,0 +1,170 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lknzcolw"> + <div class="users"> + <div class="inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="available">{{ i18n.ts.normal }}</option> + <option value="admin">{{ i18n.ts.administrator }}</option> + <option value="moderator">{{ i18n.ts.moderator }}</option> + <option value="silenced">{{ i18n.ts.silence }}</option> + <option value="suspended">{{ i18n.ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + </div> + <div class="inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.username }}</template> + </MkInput> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + + <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XHeader from './_header_.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +let paginationComponent = $ref<InstanceType<typeof MkPagination>>(); + +let sort = $ref('+createdAt'); +let state = $ref('all'); +let origin = $ref('local'); +let searchUsername = $ref(''); +let searchHost = $ref(''); +const pagination = { + endpoint: 'admin/show-users' as const, + limit: 10, + params: computed(() => ({ + sort: sort, + state: state, + origin: origin, + username: searchUsername, + hostname: searchHost, + })), + offsetMode: true, +}; + +function searchUser() { + os.selectUser().then(user => { + show(user); + }); +} + +async function addUser() { + const { canceled: canceled1, result: username } = await os.inputText({ + title: i18n.ts.username, + }); + if (canceled1) return; + + const { canceled: canceled2, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled2) return; + + os.apiWithDialog('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + paginationComponent.reload(); + }); +} + +function show(user) { + os.pageWindow(`/user-info/${user.id}`); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-search', + text: i18n.ts.search, + handler: searchUser, +}, { + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addUser, + handler: addUser, +}, { + asFullButton: true, + icon: 'ti ti-search', + text: i18n.ts.lookup, + handler: lookupUser, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.users, + icon: 'ti ti-users', +}))); +</script> + +<style lang="scss" scoped> +.lknzcolw { + > .users { + + > .inputs { + display: flex; + margin-bottom: 16px; + + > * { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } + + > .users { + margin-top: var(--margin); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .user:hover { + text-decoration: none; + } + } + } +} +</style> |