mirror of
https://iceshrimp.dev/Crimekillz/jointrashposs.git
synced 2024-11-21 16:33:48 +01:00
(add) サーバー統計ページ (#58)
* (add) サーバー統計ページ * fix * add jsdoc * Update CircGraph.vue
This commit is contained in:
parent
76dc26237a
commit
4c47eb20ee
123
components/charts/CircGraph.vue
Normal file
123
components/charts/CircGraph.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<svg viewBox="-3 -3 70 70">
|
||||
<circle
|
||||
v-for="(item, index) in sortedData"
|
||||
:class="[
|
||||
$style.pie,
|
||||
(focusedIndex === index) && $style.focused,
|
||||
]"
|
||||
:style="getStyle(index)"
|
||||
:data-real-value="item"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 軽量円グラフ描画コンポーネント(すごい)
|
||||
*/
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** グラフにしたいデータ(数字の配列 or kv形式のオブジェクト) */
|
||||
data: number[] | Record<string | number | symbol, number>;
|
||||
/** 多い順にソートするか?(デフォルトON) */
|
||||
sort?: boolean;
|
||||
/** 小さな値を「その他」としてまとめるか?(デフォルトON、数値指定でその割合以下の項目をまとめる) */
|
||||
truncMinor?: boolean | number;
|
||||
/** グラデーション開始色([r, g, b]の配列で指定) */
|
||||
startColor?: [number, number, number];
|
||||
/** グラデーション終了色([r, g, b]の配列で指定) */
|
||||
endColor?: [number, number, number];
|
||||
/** 強調するindex番号を指定 */
|
||||
focusedIndex?: number;
|
||||
}>(), {
|
||||
sort: true,
|
||||
truncMinor: true,
|
||||
startColor: [74, 179, 0],
|
||||
endColor: [230, 255, 148],
|
||||
});
|
||||
|
||||
const isReady = ref(false);
|
||||
|
||||
const sortedData = computed(() => {
|
||||
let out = Array.isArray(props.data) ? props.data : Object.values(props.data);
|
||||
const sum = out.reduce((p, c) => p + c, 0);
|
||||
|
||||
if (props.sort) {
|
||||
out = out.sort((a, b) => b - a);
|
||||
}
|
||||
if (props.truncMinor !== false) {
|
||||
const ratio = props.truncMinor === true ? 0.02 : props.truncMinor;
|
||||
const toBeTrunked = out.filter((v) => v / sum < ratio);
|
||||
out = [
|
||||
...out.filter((v) => v / sum >= ratio),
|
||||
toBeTrunked.reduce((p, c) => p + c, 0),
|
||||
];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const steppedColors = computed(() => {
|
||||
const r = sortedData.value.map((_, i, a) => props.startColor[0] - (props.startColor[0] - props.endColor[0]) / a.length * i);
|
||||
const g = sortedData.value.map((_, i, a) => props.startColor[1] - (props.startColor[1] - props.endColor[1]) / a.length * i);
|
||||
const b = sortedData.value.map((_, i, a) => props.startColor[2] - (props.startColor[2] - props.endColor[2]) / a.length * i);
|
||||
return sortedData.value.map((_, i) => [r[i], g[i], b[i]]);
|
||||
});
|
||||
|
||||
const dasharrays = computed(() => {
|
||||
const sum = sortedData.value.reduce((p, c) => p + c, 0);
|
||||
|
||||
const out: number[][] = [];
|
||||
let start = 0;
|
||||
for (let index = 0; index < sortedData.value.length; index++) {
|
||||
const element = sortedData.value[index];
|
||||
const percentage = element / sum;
|
||||
if (index === 0) {
|
||||
out[index] = [percentage * 100, 100 - percentage * 100];
|
||||
start = percentage * 100;
|
||||
} else {
|
||||
out[index] = [
|
||||
0,
|
||||
start,
|
||||
percentage * 100,
|
||||
Math.max(100 - (start + (percentage * 100)), 0),
|
||||
];
|
||||
start += percentage * 100;
|
||||
}
|
||||
}
|
||||
return out.map((v) => v.join(' '));
|
||||
});
|
||||
|
||||
function getStyle(index: number) {
|
||||
return `stroke: rgb(${steppedColors.value[index][0]}, ${steppedColors.value[index][1]}, ${steppedColors.value[index][2]});
|
||||
--dasharray: ${dasharrays.value[index]}`;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(dasharrays, (to) => {
|
||||
if (to.length > 0) {
|
||||
setTimeout(() => {
|
||||
isReady.value = true;
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.pie {
|
||||
@apply transition-transform;
|
||||
transform-origin: center;
|
||||
fill: transparent;
|
||||
cx: 32;
|
||||
cy: 32;
|
||||
r: 16;
|
||||
stroke-width: 32;
|
||||
stroke-dashoffset: 25;
|
||||
stroke-dasharray: var(--dasharray);
|
||||
}
|
||||
|
||||
.focused.pie {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
</style>
|
227
components/servers/StatsViewer.vue
Normal file
227
components/servers/StatsViewer.vue
Normal file
@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="container mx-auto max-w-screen-lg px-6 space-y-6 lg:space-y-8">
|
||||
<GLocalNav :items="[
|
||||
{
|
||||
name: $t('_servers._statistics.lang'),
|
||||
anchor: '#lang',
|
||||
},
|
||||
{
|
||||
name: $t('_servers._statistics.registerAcceptance'),
|
||||
anchor: '#registerAcceptance',
|
||||
},
|
||||
{
|
||||
name: $t('_servers._statistics.notes'),
|
||||
anchor: '#notes',
|
||||
},
|
||||
{
|
||||
name: $t('_servers._statistics.version'),
|
||||
anchor: '#version'
|
||||
}
|
||||
]" />
|
||||
<div id="lang">
|
||||
<h2 class="text-2xl lg:text-3xl font-title font-bold mb-4">{{ $t(`_servers._statistics.lang`) }}</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><ChartsCircGraph class="max-w-xs mx-auto" :data="langStats" :focusedIndex="langFocus" /></div>
|
||||
<div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="value, key, index in trunc(langStats)" class="grid py-1 px-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" :class="$style.kvRoot" @mouseenter.passive="langFocus = index" @mouseleave.passive="langFocus = undefined">
|
||||
<div v-if="key === '__others'">{{ $t('other') }}</div>
|
||||
<div v-else>{{ key }} {{ lang.find((v) => v.lang === key)?.label ? `(${lang.find((v) => v.lang === key)?.label})` : '' }}</div>
|
||||
<div class="font-bold font-mono text-accent-600">{{ $n(value) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="registerAcceptance">
|
||||
<h2 class="text-2xl lg:text-3xl font-title font-bold mb-4">{{ $t(`_servers._statistics.registerAcceptance`) }}</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><ChartsCircGraph class="max-w-xs mx-auto" :data="regAcceptanceStats" :focusedIndex="regAcceptanceFocus" :truncMinor="false" /></div>
|
||||
<div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="value, key, index in regAcceptanceStats" class="grid py-1 px-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" :class="$style.kvRoot" @mouseenter.passive="regAcceptanceFocus = index" @mouseleave.passive="regAcceptanceFocus = undefined">
|
||||
<div>{{ $t(`_servers._registerAcceptance.${key}`) }}</div>
|
||||
<div class="font-bold font-mono text-accent-600">{{ $n(value) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notes">
|
||||
<h2 class="text-2xl lg:text-3xl font-title font-bold mb-4">{{ $t(`_servers._statistics.notes`) }}</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><ChartsCircGraph class="max-w-xs mx-auto" :data="notesCountStats" :focusedIndex="notesCountFocus" /></div>
|
||||
<div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="value, key, index in trunc(notesCountStats)" class="grid py-1 px-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" :class="$style.kvRoot" @mouseenter.passive="notesCountFocus = index" @mouseleave.passive="notesCountFocus = undefined">
|
||||
<div v-if="key === '__others'">{{ $t('other') }}</div>
|
||||
<div v-else>{{ key }}</div>
|
||||
<div class="font-bold font-mono text-accent-600">{{ $n(value) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="version">
|
||||
<h2 class="text-2xl lg:text-3xl font-title font-bold mb-4">{{ $t(`_servers._statistics.version`) }}</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><ChartsCircGraph class="max-w-xs mx-auto" :data="avgVersionStats" :truncMinor="0.005" :focusedIndex="avgVersionFocus" /></div>
|
||||
<div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="value, key, index in trunc(avgVersionStats, 0.005)" class="grid py-1 px-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" :class="$style.kvRoot" @mouseenter.passive="avgVersionFocus = index" @mouseleave.passive="avgVersionFocus = undefined">
|
||||
<div v-if="key === '__others'">{{ $t('other') }}</div>
|
||||
<div v-else>{{ key }}</div>
|
||||
<div class="font-bold font-mono text-accent-600">{{ $n(value) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { InstanceInfo } from '@/types/instances-info';
|
||||
import lang from '~/assets/data/lang';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data } = await useFetch<InstanceInfo>('https://instanceapp.misskey.page/instances.json', {
|
||||
onRequestError: () => {
|
||||
alert(t('_servers._system.fetchError'));
|
||||
}
|
||||
});
|
||||
|
||||
const langStats = computed(() => {
|
||||
const d = data.value?.instancesInfos;
|
||||
if (!d || d.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
d.forEach((v) => {
|
||||
if (!out[v.langs[0]]) {
|
||||
out[v.langs[0]] = 1;
|
||||
} else {
|
||||
out[v.langs[0]]++;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(out)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.reduce(
|
||||
(r, [k, v]) => ({
|
||||
...r,
|
||||
[k]: v
|
||||
}),
|
||||
{}
|
||||
);
|
||||
});
|
||||
const langFocus = ref<number | undefined>();
|
||||
|
||||
const regAcceptanceStats = computed(() => {
|
||||
const d = data.value?.instancesInfos;
|
||||
if (!d || d.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out = {
|
||||
public: 0,
|
||||
inviteOnly: 0,
|
||||
};
|
||||
d.forEach((v) => {
|
||||
if (v.meta?.disableRegistration) {
|
||||
out.inviteOnly++;
|
||||
} else {
|
||||
out.public++;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(out)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.reduce(
|
||||
(r, [k, v]) => ({
|
||||
...r,
|
||||
[k]: v
|
||||
}),
|
||||
{}
|
||||
);
|
||||
});
|
||||
const regAcceptanceFocus = ref<number | undefined>();
|
||||
|
||||
const notesCountStats = computed(() => {
|
||||
const d = data.value?.instancesInfos;
|
||||
if (!d || d.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
|
||||
d.forEach((v) => {
|
||||
out[v.name] = v.stats?.originalNotesCount ?? 0;
|
||||
});
|
||||
|
||||
return Object.entries(out)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.reduce(
|
||||
(r, [k, v]) => ({
|
||||
...r,
|
||||
[k]: v
|
||||
}),
|
||||
{}
|
||||
);
|
||||
});
|
||||
const notesCountFocus = ref<number | undefined>();
|
||||
|
||||
const avgVersionStats = computed(() => {
|
||||
const d = data.value?.instancesInfos;
|
||||
if (!d || d.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
|
||||
d.forEach((v) => {
|
||||
const ver = v.meta?.version.replace(/[-\+].+$/g, '');
|
||||
if (ver) {
|
||||
if (!out[ver]) {
|
||||
out[ver] = 1;
|
||||
} else {
|
||||
out[ver]++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(out)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.reduce(
|
||||
(r, [k, v]) => ({
|
||||
...r,
|
||||
[k]: v
|
||||
}),
|
||||
{}
|
||||
);
|
||||
});
|
||||
const avgVersionFocus = ref<number | undefined>();
|
||||
|
||||
function trunc(obj: Record<any, number>, ratio: number = 0.02) {
|
||||
const sum = Object.values(obj).reduce((p, c) => p + c, 0);
|
||||
const toBeTrunked = Object.values(obj).filter((v) => v / sum < ratio);
|
||||
|
||||
return {
|
||||
...Object.entries(obj)
|
||||
.slice(0, (Object.values(obj).length - toBeTrunked.length))
|
||||
.reduce(
|
||||
(r, [k, v]) => ({
|
||||
...r,
|
||||
[k]: v
|
||||
}),
|
||||
{}
|
||||
),
|
||||
__others: toBeTrunked.reduce((p, c) => p + c, 0),
|
||||
};
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.kvRoot {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
</style>
|
@ -5,6 +5,7 @@ clickToExpand: "(クリックして展開)"
|
||||
copy: "コピー"
|
||||
share: "共有する"
|
||||
note: "ノート"
|
||||
other: "その他"
|
||||
|
||||
_error:
|
||||
notFound: "ページが見つかりませんでした"
|
||||
@ -102,6 +103,12 @@ _servers:
|
||||
_system:
|
||||
fetchError: "データの読み込みに失敗しました。後でもう一度お試しください。"
|
||||
_statistics:
|
||||
title: "サーバー統計"
|
||||
description: "Misskeyサーバーの統計データをグラフでご紹介。"
|
||||
viewFullStats: "詳しい統計を見る"
|
||||
lang: "プライマリ言語"
|
||||
registerAcceptance: "新規登録受付方式"
|
||||
version: "バージョン"
|
||||
notes: "ノート数"
|
||||
users: "ユーザー数"
|
||||
servers: "サーバー数"
|
||||
|
@ -12,7 +12,7 @@
|
||||
<div class="relative px-6 py-8">
|
||||
<GDots class="absolute top-0 left-0 w-32 h-32 text-accent-600" />
|
||||
<GDots class="absolute bottom-0 right-0 w-32 h-32 text-accent-600" />
|
||||
<div class="relative bg-white dark:bg-slate-800 shadow-lg rounded-lg lg:w-64 p-6 space-y-4">
|
||||
<div class="relative bg-white dark:bg-slate-800 shadow-lg rounded-lg w-full lg:w-80 p-6 space-y-4 break-words">
|
||||
<dl>
|
||||
<dt>{{ $t('_servers._statistics.notes') }}</dt>
|
||||
<dd class="font-bold text-accent-600 text-2xl">{{ instancesStats?.notesCount?.toLocaleString() || $t('loading') }}</dd>
|
||||
@ -25,6 +25,9 @@
|
||||
<dt>{{ $t('_servers._statistics.servers') }}</dt>
|
||||
<dd class="font-bold text-accent-600 text-2xl">{{ instancesStats?.instancesCount?.toLocaleString() || $t('loading') }}</dd>
|
||||
</dl>
|
||||
<div class="!mt-2">
|
||||
<GNuxtLink class="hover:underline underline-offset-2" :to="localePath('/servers/stats/')">{{ $t('_servers._statistics.viewFullStats') }}<ArrowRightIco class="ml-1" /></GNuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -45,8 +48,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { InstancesStatsObj } from '@/types/instances-info';
|
||||
import ArrowRightIco from "bi/arrow-right.svg";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const localePath = useLocalePath();
|
||||
const route = useRoute();
|
||||
|
||||
const instancesStats = ref<InstancesStatsObj>();
|
41
pages/servers/stats.vue
Normal file
41
pages/servers/stats.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<GHero>
|
||||
<template #title>{{ $t('_servers._statistics.title') }}</template>
|
||||
<template #description>
|
||||
{{ $t('_servers._statistics.description') }}
|
||||
</template>
|
||||
<template #icon>
|
||||
<div class="hidden lg:block relative px-6 py-8">
|
||||
<GDots class="absolute top-0 left-0 w-32 h-32 text-accent-600" />
|
||||
<GDots class="absolute bottom-0 right-0 w-32 h-32 text-accent-600" />
|
||||
<div class="relative lg:w-64">
|
||||
<img class="drop-shadow-xl" src="/img/emojis/chart_increasing_3d.png" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GHero>
|
||||
<div class="pb-12 lg:mt-12 pt-6 bg-white dark:bg-slate-950 min-h-screen">
|
||||
<ClientOnly>
|
||||
<ServersStatsViewer />
|
||||
<template #fallback>
|
||||
<div class="container mx-auto max-w-screen-xl p-6">
|
||||
<MkLoading class="mx-auto text-accent-600"></MkLoading>
|
||||
<p class="text-center">{{ $t('loading') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
route.meta.title = t('_servers._statistics.title');
|
||||
route.meta.description = t('_servers._statistics.description');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
BIN
public/img/emojis/chart_increasing_3d.png
Normal file
BIN
public/img/emojis/chart_increasing_3d.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
@ -38,6 +38,7 @@ export default <Config> {
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
...defaultTheme.fontFamily,
|
||||
'title': ['Capriola', 'var(--mi-localized-font, \'\')', ...defaultTheme.fontFamily.sans],
|
||||
'sans': ['Nunito', 'var(--mi-localized-font, \'\')', ...defaultTheme.fontFamily.sans],
|
||||
'content-sans': ['Nunito', 'var(--mi-localized-font-p, var(--mi-localized-font))', ...defaultTheme.fontFamily.sans],
|
||||
|
Loading…
Reference in New Issue
Block a user