From 0248a2a98926d47bf10ada8446393cb6fe0e0238 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 25 Jun 2022 23:01:40 +0900
Subject: [PATCH] enhance(client): improve control panel

---
 .../client/src/components/queue-chart.vue     | 232 ---------
 .../src/pages/admin/overview.federation.vue   | 105 ++++
 .../src/pages/admin/overview.queue-chart.vue  | 213 ++++++++
 packages/client/src/pages/admin/overview.vue  | 487 ++++++++++++++----
 .../src/pages/admin/queue.chart.chart.vue     | 181 +++++++
 .../client/src/pages/admin/queue.chart.vue    | 146 ++++--
 packages/client/src/pages/admin/queue.vue     |  35 +-
 7 files changed, 996 insertions(+), 403 deletions(-)
 delete mode 100644 packages/client/src/components/queue-chart.vue
 create mode 100644 packages/client/src/pages/admin/overview.federation.vue
 create mode 100644 packages/client/src/pages/admin/overview.queue-chart.vue
 create mode 100644 packages/client/src/pages/admin/queue.chart.chart.vue

diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue
deleted file mode 100644
index 7bb548cf06..0000000000
--- a/packages/client/src/components/queue-chart.vue
+++ /dev/null
@@ -1,232 +0,0 @@
-<template>
-<canvas ref="chartEl"></canvas>
-</template>
-
-<script lang="ts">
-import { defineComponent, 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';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-export default defineComponent({
-	props: {
-		domain: {
-			type: String,
-			required: true,
-		},
-		connection: {
-			required: true,
-		},
-	},
-
-	setup(props) {
-		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');
-
-		onMounted(() => {
-			const chartInstance = new Chart(chartEl.value, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'Process',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderJoinStyle: 'round',
-						borderColor: '#00E396',
-						backgroundColor: alpha('#00E396', 0.1),
-						data: []
-					}, {
-						label: 'Active',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderJoinStyle: 'round',
-						borderColor: '#00BCD4',
-						backgroundColor: alpha('#00BCD4', 0.1),
-						data: []
-					}, {
-						label: 'Waiting',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderJoinStyle: 'round',
-						borderColor: '#FFB300',
-						backgroundColor: alpha('#FFB300', 0.1),
-						yAxisID: 'y2',
-						data: []
-					}, {
-						label: 'Delayed',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderJoinStyle: 'round',
-						borderColor: '#E53935',
-						borderDash: [5, 5],
-						fill: false,
-						yAxisID: 'y2',
-						data: []
-					}],
-				},
-				options: {
-					aspectRatio: 2.5,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 8,
-						},
-					},
-					scales: {
-						x: {
-							grid: {
-								display: true,
-								color: gridColor,
-								borderColor: 'rgb(0, 0, 0, 0)',
-							},
-							ticks: {
-								display: false,
-								maxTicksLimit: 10
-							},
-						},
-						y: {
-							min: 0,
-							stack: 'queue',
-							stackWeight: 2,
-							grid: {
-								color: gridColor,
-								borderColor: 'rgb(0, 0, 0, 0)',
-							},
-						},
-						y2: {
-							min: 0,
-							offset: true,
-							stack: 'queue',
-							stackWeight: 1,
-							grid: {
-								color: gridColor,
-								borderColor: 'rgb(0, 0, 0, 0)',
-							},
-						},
-					},
-					interaction: {
-						intersect: false,
-					},
-					plugins: {
-						legend: {
-							position: 'bottom',
-							labels: {
-								boxWidth: 16,
-							},
-						},
-						tooltip: {
-							mode: 'index',
-							animation: {
-								duration: 0,
-							},
-						},
-					},
-				},
-			});
-
-			const onStats = (stats) => {
-				chartInstance.data.labels.push('');
-				chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
-				chartInstance.data.datasets[1].data.push(stats[props.domain].active);
-				chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
-				chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
-				if (chartInstance.data.datasets[0].data.length > 200) {
-					chartInstance.data.labels.shift();
-					chartInstance.data.datasets[0].data.shift();
-					chartInstance.data.datasets[1].data.shift();
-					chartInstance.data.datasets[2].data.shift();
-					chartInstance.data.datasets[3].data.shift();
-				}
-				chartInstance.update();
-			};
-
-			const onStatsLog = (statsLog) => {
-				for (const stats of [...statsLog].reverse()) {
-					chartInstance.data.labels.push('');
-					chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
-					chartInstance.data.datasets[1].data.push(stats[props.domain].active);
-					chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
-					chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
-					if (chartInstance.data.datasets[0].data.length > 200) {
-						chartInstance.data.labels.shift();
-						chartInstance.data.datasets[0].data.shift();
-						chartInstance.data.datasets[1].data.shift();
-						chartInstance.data.datasets[2].data.shift();
-						chartInstance.data.datasets[3].data.shift();
-					}
-				}
-				chartInstance.update();
-			};
-
-			props.connection.on('stats', onStats);
-			props.connection.on('statsLog', onStatsLog);
-
-			onUnmounted(() => {
-				props.connection.off('stats', onStats);
-				props.connection.off('statsLog', onStatsLog);
-			});
-		});
-
-		return {
-			chartEl,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue
new file mode 100644
index 0000000000..6709c30c64
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.federation.vue
@@ -0,0 +1,105 @@
+<template>
+<div class="wbrkwale">
+	<MkLoading v-if="fetching"/>
+	<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
+		<div v-for="(instance, i) in instances" :key="instance.id" class="instance">
+			<img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/>
+			<div class="body">
+				<a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.name ?? instance.host }}</a>
+				<p>{{ instance.host }}</p>
+			</div>
+			<MkMiniChart class="chart" :src="charts[i].requests.received"/>
+		</div>
+	</transition-group>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+
+const instances = ref([]);
+const charts = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+	const fetchedInstances = await os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 5,
+	});
+	const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+	instances.value = fetchedInstances;
+	charts.value = fetchedCharts;
+	fetching.value = false;
+};
+
+let intervalId;
+
+onMounted(() => {
+	fetch();
+	intervalId = window.setInterval(fetch, 1000 * 60);
+});
+
+onUnmounted(() => {
+	window.clearInterval(intervalId);
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwale {
+	> .instances {
+		.chart-move {
+			transition: transform 1s ease;
+		}
+
+		> .instance {
+			display: flex;
+			align-items: center;
+			padding: 16px 20px;
+
+			&:not(:last-child) {
+				border-bottom: solid 0.5px var(--divider);
+			}
+
+			> img {
+				display: block;
+				width: 34px;
+				height: 34px;
+				object-fit: cover;
+				border-radius: 4px;
+				margin-right: 12px;
+			}
+
+			> .body {
+				flex: 1;
+				overflow: hidden;
+				font-size: 0.9em;
+				color: var(--fg);
+				padding-right: 8px;
+
+				> .a {
+					display: block;
+					width: 100%;
+					white-space: nowrap;
+					overflow: hidden;
+					text-overflow: ellipsis;
+				}
+
+				> p {
+					margin: 0;
+					font-size: 75%;
+					opacity: 0.7;
+					white-space: nowrap;
+					overflow: hidden;
+					text-overflow: ellipsis;
+				}
+			}
+
+			> .chart {
+				height: 30px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/pages/admin/overview.queue-chart.vue b/packages/client/src/pages/admin/overview.queue-chart.vue
new file mode 100644
index 0000000000..646d1ac2f3
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.queue-chart.vue
@@ -0,0 +1,213 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent, 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';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+);
+
+const props = defineProps<{
+	domain: string;
+	connection: any;
+}>();
+
+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;
+
+const onStats = (stats) => {
+	chartInstance.data.labels.push('');
+	chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+	chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+	chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+	chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+	if (chartInstance.data.datasets[0].data.length > 200) {
+		chartInstance.data.labels.shift();
+		chartInstance.data.datasets[0].data.shift();
+		chartInstance.data.datasets[1].data.shift();
+		chartInstance.data.datasets[2].data.shift();
+		chartInstance.data.datasets[3].data.shift();
+	}
+	chartInstance.update();
+};
+
+const onStatsLog = (statsLog) => {
+	for (const stats of [...statsLog].reverse()) {
+		chartInstance.data.labels.push('');
+		chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+		chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+		chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+		chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+		if (chartInstance.data.datasets[0].data.length > 200) {
+			chartInstance.data.labels.shift();
+			chartInstance.data.datasets[0].data.shift();
+			chartInstance.data.datasets[1].data.shift();
+			chartInstance.data.datasets[2].data.shift();
+			chartInstance.data.datasets[3].data.shift();
+		}
+	}
+	chartInstance.update();
+};
+
+onMounted(() => {
+	chartInstance = new Chart(chartEl.value, {
+		type: 'line',
+		data: {
+			labels: [],
+			datasets: [{
+				label: 'Process',
+				pointRadius: 0,
+				tension: 0.3,
+				borderWidth: 2,
+				borderJoinStyle: 'round',
+				borderColor: '#00E396',
+				backgroundColor: alpha('#00E396', 0.1),
+				data: [],
+			}, {
+				label: 'Active',
+				pointRadius: 0,
+				tension: 0.3,
+				borderWidth: 2,
+				borderJoinStyle: 'round',
+				borderColor: '#00BCD4',
+				backgroundColor: alpha('#00BCD4', 0.1),
+				data: [],
+			}, {
+				label: 'Waiting',
+				pointRadius: 0,
+				tension: 0.3,
+				borderWidth: 2,
+				borderJoinStyle: 'round',
+				borderColor: '#FFB300',
+				backgroundColor: alpha('#FFB300', 0.1),
+				data: [],
+			}, {
+				label: 'Delayed',
+				pointRadius: 0,
+				tension: 0.3,
+				borderWidth: 2,
+				borderJoinStyle: 'round',
+				borderColor: '#E53935',
+				borderDash: [5, 5],
+				fill: false,
+				data: [],
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					grid: {
+						display: false,
+					},
+					ticks: {
+						display: false,
+						maxTicksLimit: 10,
+					},
+				},
+				y: {
+					min: 0,
+					grid: {
+						display: false,
+					},
+					ticks: {
+						display: false,
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+			},
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+			},
+		},
+	});
+
+	props.connection.on('stats', onStats);
+	props.connection.on('statsLog', onStatsLog);
+
+	props.connection.send('requestLog', {
+		id: Math.random().toString().substr(2, 8),
+	});
+});
+
+onUnmounted(() => {
+	props.connection.off('stats', onStats);
+	props.connection.off('statsLog', onStatsLog);
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
index f81f6104c7..22d9d72a70 100644
--- a/packages/client/src/pages/admin/overview.vue
+++ b/packages/client/src/pages/admin/overview.vue
@@ -1,92 +1,318 @@
 <template>
-<div v-size="{ max: [740] }" class="edbbcaef">
-	<div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
-		<div class="number _panel">
-			<div class="label">Users</div>
-			<div class="value _monospace">
-				{{ number(stats.originalUsersCount) }}
-				<MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+<MkSpacer :content-max="900">
+	<div ref="rootEl" v-size="{ max: [740] }" class="edbbcaef">
+		<div class="left">
+			<div v-if="stats" class="container stats">
+				<div class="title">Stats</div>
+				<div class="body">
+					<div class="number _panel">
+						<div class="label">Users</div>
+						<div class="value _monospace">
+							{{ number(stats.originalUsersCount) }}
+							<MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+						</div>
+					</div>
+					<div class="number _panel">
+						<div class="label">Notes</div>
+						<div class="value _monospace">
+							{{ number(stats.originalNotesCount) }}
+							<MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<div class="container queue">
+				<div class="title">Job queue</div>
+				<div class="body deliver">
+					<div class="title">Deliver</div>
+					<XQueueChart :connection="queueStatsConnection" domain="deliver"/>
+				</div>
+				<div class="body inbox">
+					<div class="title">Inbox</div>
+					<XQueueChart :connection="queueStatsConnection" domain="inbox"/>
+				</div>
+			</div>
+
+			<!--<XMetrics/>-->
+
+			<div class="container env">
+				<div class="title">Enviroment</div>
+				<div class="body">
+					<div class="number _panel">
+						<div class="label">Misskey</div>
+						<div class="value _monospace">{{ version }}</div>
+					</div>
+					<div v-if="serverInfo" class="number _panel">
+						<div class="label">Node.js</div>
+						<div class="value _monospace">{{ serverInfo.node }}</div>
+					</div>
+					<div v-if="serverInfo" class="number _panel">
+						<div class="label">PostgreSQL</div>
+						<div class="value _monospace">{{ serverInfo.psql }}</div>
+					</div>
+					<div v-if="serverInfo" class="number _panel">
+						<div class="label">Redis</div>
+						<div class="value _monospace">{{ serverInfo.redis }}</div>
+					</div>
+					<div class="number _panel">
+						<div class="label">Vue</div>
+						<div class="value _monospace">{{ vueVersion }}</div>
+					</div>
+				</div>
 			</div>
 		</div>
-		<div class="number _panel">
-			<div class="label">Notes</div>
-			<div class="value _monospace">
-				{{ number(stats.originalNotesCount) }}
-				<MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+		<div class="right">
+			<div class="container charts">
+				<div class="title">Active users</div>
+				<div class="body">
+					<canvas ref="chartEl"></canvas>
+				</div>
+			</div>
+			<div class="container federation">
+				<div class="title">Active instances</div>
+				<div class="body">
+					<XFederation/>
+				</div>
 			</div>
 		</div>
 	</div>
-
-	<MkContainer :foldable="true" class="charts">
-		<template #header><i class="fas fa-chart-bar"></i>{{ i18n.ts.charts }}</template>
-		<div style="padding: 12px;">
-			<MkInstanceStats :chart-limit="500" :detailed="true"/>
-		</div>
-	</MkContainer>
-
-	<div class="queue">
-		<MkContainer :foldable="true" :thin="true" class="deliver">
-			<template #header>Queue: deliver</template>
-			<MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
-		</MkContainer>
-		<MkContainer :foldable="true" :thin="true" class="inbox">
-			<template #header>Queue: inbox</template>
-			<MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
-		</MkContainer>
-	</div>
-
-	<!--<XMetrics/>-->
-
-	<MkFolder style="margin: var(--margin)">
-		<template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template>
-		<div class="cfcdecdf">
-			<div class="number _panel">
-				<div class="label">Misskey</div>
-				<div class="value _monospace">{{ version }}</div>
-			</div>
-			<div v-if="serverInfo" class="number _panel">
-				<div class="label">Node.js</div>
-				<div class="value _monospace">{{ serverInfo.node }}</div>
-			</div>
-			<div v-if="serverInfo" class="number _panel">
-				<div class="label">PostgreSQL</div>
-				<div class="value _monospace">{{ serverInfo.psql }}</div>
-			</div>
-			<div v-if="serverInfo" class="number _panel">
-				<div class="label">Redis</div>
-				<div class="value _monospace">{{ serverInfo.redis }}</div>
-			</div>
-			<div class="number _panel">
-				<div class="label">Vue</div>
-				<div class="value _monospace">{{ vueVersion }}</div>
-			</div>
-		</div>
-	</MkFolder>
-</div>
+</MkSpacer>
 </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 MagicGrid from 'magic-grid';
 import XMetrics from './metrics.vue';
+import XFederation from './overview.federation.vue';
+import XQueueChart from './overview.queue-chart.vue';
 import MkInstanceStats from '@/components/instance-stats.vue';
 import MkNumberDiff from '@/components/number-diff.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkFolder from '@/components/ui/folder.vue';
-import MkQueueChart from '@/components/queue-chart.vue';
 import { version, url } from '@/config';
 import number from '@/filters/number';
 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 { useChartTooltip } from '@/scripts/use-chart-tooltip';
 
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	//gradient,
+);
+
+const rootEl = $ref<HTMLElement>();
+const chartEl = $ref<HTMLCanvasElement>(null);
 let stats: any = $ref(null);
 let serverInfo: any = $ref(null);
 let usersComparedToThePrevDay: any = $ref(null);
 let notesComparedToThePrevDay: any = $ref(null);
 const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
+const now = new Date();
+let chartInstance: Chart = null;
+const chartLimit = 30;
+
+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 color = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent'));
+
+	chartInstance = new Chart(chartEl, {
+		type: 'bar',
+		data: {
+			//labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+			datasets: [{
+				parsing: false,
+				label: 'a',
+				data: format(raw.readWrite).slice().reverse(),
+				tension: 0.3,
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 3,
+				backgroundColor: color,
+				/*gradient: props.bar ? undefined : {
+					backgroundColor: {
+						axis: 'y',
+						colors: {
+							0: alpha(x.color ? x.color : getColor(i), 0),
+							[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2),
+						},
+					},
+				},*/
+				barPercentage: 0.9,
+				categoryPercentage: 0.9,
+				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: 'month',
+					},
+					grid: {
+						display: false,
+					},
+					ticks: {
+						display: false,
+					},
+					adapters: {
+						date: {
+							locale: enUS,
+						},
+					},
+					min: getDate(chartLimit).getTime(),
+				},
+				y: {
+					position: 'left',
+					stacked: true,
+					grid: {
+						display: false,
+					},
+					ticks: {
+						display: false,
+						//mirror: true,
+					},
+				},
+			},
+			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: [{
+			id: 'vLine',
+			beforeDraw(chart, args, options) {
+				if (chart.tooltip._active && chart.tooltip._active.length) {
+					const activePoint = chart.tooltip._active[0];
+					const ctx = chart.ctx;
+					const x = activePoint.element.x;
+					const topY = chart.scales.y.top;
+					const bottomY = chart.scales.y.bottom;
+
+					ctx.save();
+					ctx.beginPath();
+					ctx.moveTo(x, bottomY);
+					ctx.lineTo(x, topY);
+					ctx.lineWidth = 1;
+					ctx.strokeStyle = vLineColor;
+					ctx.stroke();
+					ctx.restore();
+				}
+			},
+		}],
+	});
+}
+
+onMounted(async () => {
+	/*
+	const magicGrid = new MagicGrid({
+		container: rootEl,
+		static: true,
+		animate: true,
+	});
+
+	magicGrid.listen();
+	*/
+
+	renderChart();
 
-onMounted(async () => {	
 	os.api('stats', {}).then(statsResponse => {
 		stats = statsResponse;
 
@@ -128,63 +354,108 @@ definePageMetadata({
 
 <style lang="scss" scoped>
 .edbbcaef {
-	.cfcdecdf {
-		display: grid;
-		grid-gap: 8px;
-		grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
+	display: flex;
 
-		> .number {
-			padding: 12px 16px;
+	> .left, > .right {
+		box-sizing: border-box;
+		width: 50%;
 
-			> .label {
-				opacity: 0.7;
-				font-size: 0.8em;
-			}
+		> .container {
+			margin: 32px 0;
 
-			> .value {
-				font-weight: bold;
+			> .title {
 				font-size: 1.2em;
+				font-weight: bold;
+				margin-bottom: 16px;
+			}
 
-				> .diff {
-					font-size: 0.8em;
+			&.stats {
+				> .body {
+					display: grid;
+					grid-gap: 16px;
+					grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+
+					> .number {
+						padding: 14px 20px;
+
+						> .label {
+							opacity: 0.7;
+							font-size: 0.8em;
+						}
+
+						> .value {
+							font-weight: bold;
+							font-size: 1.5em;
+
+							> .diff {
+								font-size: 0.8em;
+							}
+						}
+					}
+				}
+			}
+
+			&.env {
+				> .body {
+					display: grid;
+					grid-gap: 16px;
+					grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+
+					> .number {
+						padding: 14px 20px;
+
+						> .label {
+							opacity: 0.7;
+							font-size: 0.8em;
+						}
+
+						> .value {
+							font-size: 1.2em;
+						}
+					}
+				}
+			}
+
+			&.charts {
+				> .body {
+					padding: 32px;
+					background: var(--panel);
+					border-radius: var(--radius);
+				}
+			}
+
+			&.federation {
+				> .body {
+					background: var(--panel);
+					border-radius: var(--radius);
+					overflow: clip;
+				}
+			}
+
+			&.queue {
+				> .body {
+					padding: 32px;
+					background: var(--panel);
+					border-radius: var(--radius);
+
+					&:not(:last-child) {
+						margin-bottom: 16px;
+					}
+
+					> .title {
+
+					}
 				}
 			}
 		}
 	}
 
-	> .charts {
-		margin: var(--margin);
+	> .left {
+		padding-right: 16px;
 	}
 
-	> .queue {
-		margin: var(--margin);
-		display: flex;
-
-		> .deliver,
-		> .inbox {
-			flex: 1;
-			width: 50%;
-
-			&:not(:first-child) {
-				margin-left: var(--margin);
-			}
-		}
-	}
-
-	&.max-width_740px {
-		> .queue {
-			display: block;
-
-			> .deliver,
-			> .inbox {
-				width: 100%;
-
-				&:not(:first-child) {
-					margin-top: var(--margin);
-					margin-left: 0;
-				}
-			}
-		}
+	> .right {
+		padding-left: 16px;
 	}
 }
 </style>
diff --git a/packages/client/src/pages/admin/queue.chart.chart.vue b/packages/client/src/pages/admin/queue.chart.chart.vue
new file mode 100644
index 0000000000..dbfaf6caa4
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.chart.chart.vue
@@ -0,0 +1,181 @@
+<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';
+
+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(() => {
+	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.1),
+				data: [],
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					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,
+				},
+			},
+		},
+	});
+});
+
+defineExpose({
+	setData,
+	pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
index be63830bdd..c213037b65 100644
--- a/packages/client/src/pages/admin/queue.chart.vue
+++ b/packages/client/src/pages/admin/queue.chart.vue
@@ -1,80 +1,148 @@
 <template>
-<div class="_debobigegoItem">
-	<div class="_debobigegoLabel"><slot name="title"></slot></div>
-	<div class="_debobigegoPanel 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 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>
-		<div class="">
-			<MkQueueChart :domain="domain" :connection="connection"/>
-		</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;">{{ $ts.noJobs }}</span>
-		</div>
+		<span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
 	</div>
 </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './queue.chart.chart.vue';
 import number from '@/filters/number';
-import MkQueueChart from '@/components/queue-chart.vue';
 import * as os from '@/os';
+import { stream } from '@/stream';
+
+const connection = markRaw(stream.useChannel('queueStats'));
 
 const activeSincePrevTick = ref(0);
 const active = ref(0);
-const waiting = 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,
-	connection: any,
+	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;
 	});
 
-	const onStats = (stats) => {
-		activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
-		active.value = stats[props.domain].active;
-		waiting.value = stats[props.domain].waiting;
-		delayed.value = stats[props.domain].delayed;
-	};
-
-	props.connection.on('stats', onStats);
-
-	onUnmounted(() => {
-		props.connection.off('stats', onStats);
+	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;
-		border-bottom: solid 0.5px var(--divider);
+	}
+
+	> .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;
-		border-top: solid 0.5px var(--divider);
 		max-height: 180px;
 		overflow: auto;
+		background: var(--panel);
+		border-radius: var(--radius);
 	}
+
 }
 </style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
index c2865525ab..d091fe647c 100644
--- a/packages/client/src/pages/admin/queue.vue
+++ b/packages/client/src/pages/admin/queue.vue
@@ -1,14 +1,9 @@
 <template>
 <MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="800">
-		<XQueue :connection="connection" domain="inbox">
-			<template #title>In</template>
-		</XQueue>
-		<XQueue :connection="connection" domain="deliver">
-			<template #title>Out</template>
-		</XQueue>
-		<MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
+		<XQueue v-if="tab === 'deliver'" domain="deliver"/>
+		<XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -19,12 +14,11 @@ import XQueue from './queue.chart.vue';
 import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import { stream } from '@/stream';
 import * as config from '@/config';
 import { i18n } from '@/i18n';
 import { definePageMetadata } from '@/scripts/page-metadata';
 
-const connection = markRaw(stream.useChannel('queueStats'));
+let tab = $ref('deliver');
 
 function clear() {
 	os.confirm({
@@ -38,19 +32,6 @@ function clear() {
 	});
 }
 
-onMounted(() => {
-	nextTick(() => {
-		connection.send('requestLog', {
-			id: Math.random().toString().substr(2, 8),
-			length: 200,
-		});
-	});
-});
-
-onBeforeUnmount(() => {
-	connection.dispose();
-});
-
 const headerActions = $computed(() => [{
 	asFullButton: true,
 	icon: 'fas fa-up-right-from-square',
@@ -60,7 +41,13 @@ const headerActions = $computed(() => [{
 	},
 }]);
 
-const headerTabs = $computed(() => []);
+const headerTabs = $computed(() => [{
+	key: 'deliver',
+	title: 'Deliver',
+}, {
+	key: 'inbox',
+	title: 'Inbox',
+}]);
 
 definePageMetadata({
 	title: i18n.ts.jobQueue,