(add) サーバー統計ページ (#58)

* (add) サーバー統計ページ

* fix

* add jsdoc

* Update CircGraph.vue
This commit is contained in:
かっこかり 2023-12-18 14:11:34 +09:00 committed by GitHub
parent 76dc26237a
commit 4c47eb20ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 405 additions and 1 deletions

View 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>

View 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>

View file

@ -5,6 +5,7 @@ clickToExpand: "(クリックして展開)"
copy: "コピー" copy: "コピー"
share: "共有する" share: "共有する"
note: "ノート" note: "ノート"
other: "その他"
_error: _error:
notFound: "ページが見つかりませんでした" notFound: "ページが見つかりませんでした"
@ -102,6 +103,12 @@ _servers:
_system: _system:
fetchError: "データの読み込みに失敗しました。後でもう一度お試しください。" fetchError: "データの読み込みに失敗しました。後でもう一度お試しください。"
_statistics: _statistics:
title: "サーバー統計"
description: "Misskeyサーバーの統計データをグラフでご紹介。"
viewFullStats: "詳しい統計を見る"
lang: "プライマリ言語"
registerAcceptance: "新規登録受付方式"
version: "バージョン"
notes: "ノート数" notes: "ノート数"
users: "ユーザー数" users: "ユーザー数"
servers: "サーバー数" servers: "サーバー数"

View file

@ -12,7 +12,7 @@
<div class="relative px-6 py-8"> <div class="relative px-6 py-8">
<GDots class="absolute top-0 left-0 w-32 h-32 text-accent-600" /> <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" /> <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> <dl>
<dt>{{ $t('_servers._statistics.notes') }}</dt> <dt>{{ $t('_servers._statistics.notes') }}</dt>
<dd class="font-bold text-accent-600 text-2xl">{{ instancesStats?.notesCount?.toLocaleString() || $t('loading') }}</dd> <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> <dt>{{ $t('_servers._statistics.servers') }}</dt>
<dd class="font-bold text-accent-600 text-2xl">{{ instancesStats?.instancesCount?.toLocaleString() || $t('loading') }}</dd> <dd class="font-bold text-accent-600 text-2xl">{{ instancesStats?.instancesCount?.toLocaleString() || $t('loading') }}</dd>
</dl> </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>
</div> </div>
</template> </template>
@ -45,8 +48,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InstancesStatsObj } from '@/types/instances-info'; import type { InstancesStatsObj } from '@/types/instances-info';
import ArrowRightIco from "bi/arrow-right.svg";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const localePath = useLocalePath();
const route = useRoute(); const route = useRoute();
const instancesStats = ref<InstancesStatsObj>(); const instancesStats = ref<InstancesStatsObj>();

41
pages/servers/stats.vue Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -38,6 +38,7 @@ export default <Config> {
}, },
}, },
fontFamily: { fontFamily: {
...defaultTheme.fontFamily,
'title': ['Capriola', 'var(--mi-localized-font, \'\')', ...defaultTheme.fontFamily.sans], 'title': ['Capriola', 'var(--mi-localized-font, \'\')', ...defaultTheme.fontFamily.sans],
'sans': ['Nunito', '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], 'content-sans': ['Nunito', 'var(--mi-localized-font-p, var(--mi-localized-font))', ...defaultTheme.fontFamily.sans],