summaryrefslogtreecommitdiff
path: root/packages/client/src/pages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-06-25 23:01:40 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-06-25 23:01:40 +0900
commit0248a2a98926d47bf10ada8446393cb6fe0e0238 (patch)
treeb17171d036f23a20c943a9304f5fdaba14331d54 /packages/client/src/pages
parentfix notification-setting-window.vue (diff)
downloadmisskey-0248a2a98926d47bf10ada8446393cb6fe0e0238.tar.gz
misskey-0248a2a98926d47bf10ada8446393cb6fe0e0238.tar.bz2
misskey-0248a2a98926d47bf10ada8446393cb6fe0e0238.zip
enhance(client): improve control panel
Diffstat (limited to 'packages/client/src/pages')
-rw-r--r--packages/client/src/pages/admin/overview.federation.vue105
-rw-r--r--packages/client/src/pages/admin/overview.queue-chart.vue213
-rw-r--r--packages/client/src/pages/admin/overview.vue469
-rw-r--r--packages/client/src/pages/admin/queue.chart.chart.vue181
-rw-r--r--packages/client/src/pages/admin/queue.chart.vue142
-rw-r--r--packages/client/src/pages/admin/queue.vue35
6 files changed, 985 insertions, 160 deletions
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>
- <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>
- <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>
+ <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/>-->
+ <!--<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 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 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="right">
+ <div class="container charts">
+ <div class="title">Active users</div>
+ <div class="body">
+ <canvas ref="chartEl"></canvas>
+ </div>
</div>
- <div class="number _panel">
- <div class="label">Vue</div>
- <div class="value _monospace">{{ vueVersion }}</div>
+ <div class="container federation">
+ <div class="title">Active instances</div>
+ <div class="body">
+ <XFederation/>
+ </div>
</div>
</div>
- </MkFolder>
-</div>
+ </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;
+ }
+ }
+ }
}
}
- }
- }
- > .charts {
- margin: var(--margin);
- }
+ &.env {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
- > .queue {
- margin: var(--margin);
- display: flex;
+ > .number {
+ padding: 14px 20px;
- > .deliver,
- > .inbox {
- flex: 1;
- width: 50%;
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
- &:not(:first-child) {
- margin-left: var(--margin);
+ > .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;
+ }
}
- }
- }
- &.max-width_740px {
- > .queue {
- display: block;
+ &.queue {
+ > .body {
+ padding: 32px;
+ background: var(--panel);
+ border-radius: var(--radius);
- > .deliver,
- > .inbox {
- width: 100%;
+ &:not(:last-child) {
+ margin-bottom: 16px;
+ }
- &:not(:first-child) {
- margin-top: var(--margin);
- margin-left: 0;
+ > .title {
+
+ }
}
}
}
}
+
+ > .left {
+ padding-right: 16px;
+ }
+
+ > .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>
+<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="">
- <MkQueueChart :domain="domain" :connection="connection"/>
+ <div class="chart">
+ <div class="title">Active</div>
+ <XChart ref="chartActive" type="active"/>
</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 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>
- <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,