rename: client -> frontend
This commit is contained in:
parent
db6fff6f26
commit
9384f5399d
592 changed files with 111 additions and 111 deletions
292
packages/frontend/src/pages/admin/_header_.vue
Normal file
292
packages/frontend/src/pages/admin/_header_.vue
Normal file
|
|
@ -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>
|
||||
97
packages/frontend/src/pages/admin/abuses.vue
Normal file
97
packages/frontend/src/pages/admin/abuses.vue
Normal file
|
|
@ -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>
|
||||
132
packages/frontend/src/pages/admin/ads.vue
Normal file
132
packages/frontend/src/pages/admin/ads.vue
Normal file
|
|
@ -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>
|
||||
112
packages/frontend/src/pages/admin/announcements.vue
Normal file
112
packages/frontend/src/pages/admin/announcements.vue
Normal file
|
|
@ -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>
|
||||
109
packages/frontend/src/pages/admin/bot-protection.vue
Normal file
109
packages/frontend/src/pages/admin/bot-protection.vue
Normal file
|
|
@ -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>
|
||||
35
packages/frontend/src/pages/admin/database.vue
Normal file
35
packages/frontend/src/pages/admin/database.vue
Normal file
|
|
@ -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>
|
||||
126
packages/frontend/src/pages/admin/email-settings.vue
Normal file
126
packages/frontend/src/pages/admin/email-settings.vue
Normal file
|
|
@ -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>
|
||||
106
packages/frontend/src/pages/admin/emoji-edit-dialog.vue
Normal file
106
packages/frontend/src/pages/admin/emoji-edit-dialog.vue
Normal file
|
|
@ -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>
|
||||
398
packages/frontend/src/pages/admin/emojis.vue
Normal file
398
packages/frontend/src/pages/admin/emojis.vue
Normal file
|
|
@ -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>
|
||||
120
packages/frontend/src/pages/admin/files.vue
Normal file
120
packages/frontend/src/pages/admin/files.vue
Normal file
|
|
@ -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>
|
||||
316
packages/frontend/src/pages/admin/index.vue
Normal file
316
packages/frontend/src/pages/admin/index.vue
Normal file
|
|
@ -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>
|
||||
51
packages/frontend/src/pages/admin/instance-block.vue
Normal file
51
packages/frontend/src/pages/admin/instance-block.vue
Normal file
|
|
@ -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>
|
||||
60
packages/frontend/src/pages/admin/integrations.discord.vue
Normal file
60
packages/frontend/src/pages/admin/integrations.discord.vue
Normal file
|
|
@ -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>
|
||||
60
packages/frontend/src/pages/admin/integrations.github.vue
Normal file
60
packages/frontend/src/pages/admin/integrations.github.vue
Normal file
|
|
@ -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>
|
||||
60
packages/frontend/src/pages/admin/integrations.twitter.vue
Normal file
60
packages/frontend/src/pages/admin/integrations.twitter.vue
Normal file
|
|
@ -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>
|
||||
57
packages/frontend/src/pages/admin/integrations.vue
Normal file
57
packages/frontend/src/pages/admin/integrations.vue
Normal file
|
|
@ -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>
|
||||
472
packages/frontend/src/pages/admin/metrics.vue
Normal file
472
packages/frontend/src/pages/admin/metrics.vue
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
<template>
|
||||
<div class="_debobigegoItem">
|
||||
<div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
|
||||
<div class="_debobigegoPanel xhexznfu">
|
||||
<div>
|
||||
<canvas :ref="cpumem"></canvas>
|
||||
</div>
|
||||
<div v-if="serverInfo">
|
||||
<div class="_table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
|
||||
<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_debobigegoItem">
|
||||
<div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
|
||||
<div class="_debobigegoPanel xhexznfu">
|
||||
<div>
|
||||
<canvas :ref="disk"></canvas>
|
||||
</div>
|
||||
<div v-if="serverInfo">
|
||||
<div class="_table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
|
||||
<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_debobigegoItem">
|
||||
<div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
|
||||
<div class="_debobigegoPanel xhexznfu">
|
||||
<div>
|
||||
<canvas :ref="net"></canvas>
|
||||
</div>
|
||||
<div v-if="serverInfo">
|
||||
<div class="_table">
|
||||
<div class="_row">
|
||||
<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, markRaw } from 'vue';
|
||||
import {
|
||||
Chart,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
} from 'chart.js';
|
||||
import MkButton from '@/components/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>
|
||||
148
packages/frontend/src/pages/admin/object-storage.vue
Normal file
148
packages/frontend/src/pages/admin/object-storage.vue
Normal file
|
|
@ -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>
|
||||
44
packages/frontend/src/pages/admin/other-settings.vue
Normal file
44
packages/frontend/src/pages/admin/other-settings.vue
Normal file
|
|
@ -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>
|
||||
217
packages/frontend/src/pages/admin/overview.active-users.vue
Normal file
217
packages/frontend/src/pages/admin/overview.active-users.vue
Normal file
|
|
@ -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>
|
||||
346
packages/frontend/src/pages/admin/overview.ap-requests.vue
Normal file
346
packages/frontend/src/pages/admin/overview.ap-requests.vue
Normal file
|
|
@ -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>
|
||||
|
||||
185
packages/frontend/src/pages/admin/overview.federation.vue
Normal file
185
packages/frontend/src/pages/admin/overview.federation.vue
Normal file
|
|
@ -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>
|
||||
|
||||
15
packages/frontend/src/pages/admin/overview.heatmap.vue
Normal file
15
packages/frontend/src/pages/admin/overview.heatmap.vue
Normal file
|
|
@ -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>
|
||||
50
packages/frontend/src/pages/admin/overview.instances.vue
Normal file
50
packages/frontend/src/pages/admin/overview.instances.vue
Normal file
|
|
@ -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>
|
||||
55
packages/frontend/src/pages/admin/overview.moderators.vue
Normal file
55
packages/frontend/src/pages/admin/overview.moderators.vue
Normal file
|
|
@ -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>
|
||||
110
packages/frontend/src/pages/admin/overview.pie.vue
Normal file
110
packages/frontend/src/pages/admin/overview.pie.vue
Normal file
|
|
@ -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>
|
||||
186
packages/frontend/src/pages/admin/overview.queue.chart.vue
Normal file
186
packages/frontend/src/pages/admin/overview.queue.chart.vue
Normal file
|
|
@ -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>
|
||||
127
packages/frontend/src/pages/admin/overview.queue.vue
Normal file
127
packages/frontend/src/pages/admin/overview.queue.vue
Normal file
|
|
@ -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>
|
||||
49
packages/frontend/src/pages/admin/overview.retention.vue
Normal file
49
packages/frontend/src/pages/admin/overview.retention.vue
Normal file
|
|
@ -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>
|
||||
155
packages/frontend/src/pages/admin/overview.stats.vue
Normal file
155
packages/frontend/src/pages/admin/overview.stats.vue
Normal file
|
|
@ -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>
|
||||
57
packages/frontend/src/pages/admin/overview.users.vue
Normal file
57
packages/frontend/src/pages/admin/overview.users.vue
Normal file
|
|
@ -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>
|
||||
190
packages/frontend/src/pages/admin/overview.vue
Normal file
190
packages/frontend/src/pages/admin/overview.vue
Normal file
|
|
@ -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>
|
||||
62
packages/frontend/src/pages/admin/proxy-account.vue
Normal file
62
packages/frontend/src/pages/admin/proxy-account.vue
Normal file
|
|
@ -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>
|
||||
186
packages/frontend/src/pages/admin/queue.chart.chart.vue
Normal file
186
packages/frontend/src/pages/admin/queue.chart.chart.vue
Normal file
|
|
@ -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>
|
||||
149
packages/frontend/src/pages/admin/queue.chart.vue
Normal file
149
packages/frontend/src/pages/admin/queue.chart.vue
Normal file
|
|
@ -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>
|
||||
56
packages/frontend/src/pages/admin/queue.vue
Normal file
56
packages/frontend/src/pages/admin/queue.vue
Normal file
|
|
@ -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>
|
||||
103
packages/frontend/src/pages/admin/relays.vue
Normal file
103
packages/frontend/src/pages/admin/relays.vue
Normal file
|
|
@ -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>
|
||||
179
packages/frontend/src/pages/admin/security.vue
Normal file
179
packages/frontend/src/pages/admin/security.vue
Normal file
|
|
@ -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>
|
||||
262
packages/frontend/src/pages/admin/settings.vue
Normal file
262
packages/frontend/src/pages/admin/settings.vue
Normal file
|
|
@ -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>
|
||||
170
packages/frontend/src/pages/admin/users.vue
Normal file
170
packages/frontend/src/pages/admin/users.vue
Normal file
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue