enhance(client): improve control panel

This commit is contained in:
syuilo 2022-06-25 23:01:40 +09:00
parent c67c0df762
commit 0248a2a989
7 changed files with 996 additions and 403 deletions

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
<template> <template>
<div v-size="{ max: [740] }" class="edbbcaef"> <MkSpacer :content-max="900">
<div v-if="stats" class="cfcdecdf" style="margin: var(--margin)"> <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="number _panel">
<div class="label">Users</div> <div class="label">Users</div>
<div class="value _monospace"> <div class="value _monospace">
@ -16,30 +20,25 @@
</div> </div>
</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> </div>
</MkContainer>
<div class="queue"> <div class="container queue">
<MkContainer :foldable="true" :thin="true" class="deliver"> <div class="title">Job queue</div>
<template #header>Queue: deliver</template> <div class="body deliver">
<MkQueueChart :connection="queueStatsConnection" domain="deliver"/> <div class="title">Deliver</div>
</MkContainer> <XQueueChart :connection="queueStatsConnection" domain="deliver"/>
<MkContainer :foldable="true" :thin="true" class="inbox"> </div>
<template #header>Queue: inbox</template> <div class="body inbox">
<MkQueueChart :connection="queueStatsConnection" domain="inbox"/> <div class="title">Inbox</div>
</MkContainer> <XQueueChart :connection="queueStatsConnection" domain="inbox"/>
</div>
</div> </div>
<!--<XMetrics/>--> <!--<XMetrics/>-->
<MkFolder style="margin: var(--margin)"> <div class="container env">
<template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template> <div class="title">Enviroment</div>
<div class="cfcdecdf"> <div class="body">
<div class="number _panel"> <div class="number _panel">
<div class="label">Misskey</div> <div class="label">Misskey</div>
<div class="value _monospace">{{ version }}</div> <div class="value _monospace">{{ version }}</div>
@ -61,32 +60,259 @@
<div class="value _monospace">{{ vueVersion }}</div> <div class="value _monospace">{{ vueVersion }}</div>
</div> </div>
</div> </div>
</MkFolder> </div>
</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="container federation">
<div class="title">Active instances</div>
<div class="body">
<XFederation/>
</div>
</div>
</div>
</div>
</MkSpacer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; 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 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 MkInstanceStats from '@/components/instance-stats.vue';
import MkNumberDiff from '@/components/number-diff.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 { version, url } from '@/config';
import number from '@/filters/number'; import number from '@/filters/number';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; 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 stats: any = $ref(null);
let serverInfo: any = $ref(null); let serverInfo: any = $ref(null);
let usersComparedToThePrevDay: any = $ref(null); let usersComparedToThePrevDay: any = $ref(null);
let notesComparedToThePrevDay: any = $ref(null); let notesComparedToThePrevDay: any = $ref(null);
const queueStatsConnection = markRaw(stream.useChannel('queueStats')); 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 () => { onMounted(async () => {
/*
const magicGrid = new MagicGrid({
container: rootEl,
static: true,
animate: true,
});
magicGrid.listen();
*/
renderChart();
os.api('stats', {}).then(statsResponse => { os.api('stats', {}).then(statsResponse => {
stats = statsResponse; stats = statsResponse;
@ -128,13 +354,29 @@ definePageMetadata({
<style lang="scss" scoped> <style lang="scss" scoped>
.edbbcaef { .edbbcaef {
.cfcdecdf { display: flex;
> .left, > .right {
box-sizing: border-box;
width: 50%;
> .container {
margin: 32px 0;
> .title {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 16px;
}
&.stats {
> .body {
display: grid; display: grid;
grid-gap: 8px; grid-gap: 16px;
grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
> .number { > .number {
padding: 12px 16px; padding: 14px 20px;
> .label { > .label {
opacity: 0.7; opacity: 0.7;
@ -143,7 +385,7 @@ definePageMetadata({
> .value { > .value {
font-weight: bold; font-weight: bold;
font-size: 1.2em; font-size: 1.5em;
> .diff { > .diff {
font-size: 0.8em; font-size: 0.8em;
@ -151,40 +393,69 @@ definePageMetadata({
} }
} }
} }
> .charts {
margin: var(--margin);
} }
> .queue { &.env {
margin: var(--margin); > .body {
display: flex; display: grid;
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
> .deliver, > .number {
> .inbox { padding: 14px 20px;
flex: 1;
width: 50%;
&:not(:first-child) { > .label {
margin-left: var(--margin); opacity: 0.7;
font-size: 0.8em;
}
> .value {
font-size: 1.2em;
}
} }
} }
} }
&.max-width_740px { &.charts {
> .queue { > .body {
display: block; padding: 32px;
background: var(--panel);
border-radius: var(--radius);
}
}
> .deliver, &.federation {
> .inbox { > .body {
width: 100%; 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 {
&:not(:first-child) {
margin-top: var(--margin);
margin-left: 0;
} }
} }
} }
} }
}
> .left {
padding-right: 16px;
}
> .right {
padding-left: 16px;
}
} }
</style> </style>

View file

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

View file

@ -1,7 +1,5 @@
<template> <template>
<div class="_debobigegoItem"> <div class="pumxzjhg">
<div class="_debobigegoLabel"><slot name="title"></slot></div>
<div class="_debobigegoPanel pumxzjhg">
<div class="_table status"> <div class="_table status">
<div class="_row"> <div class="_row">
<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
@ -10,8 +8,23 @@
<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
</div> </div>
</div> </div>
<div class=""> <div class="charts">
<MkQueueChart :domain="domain" :connection="connection"/> <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>
<div class="jobs"> <div class="jobs">
<div v-if="jobs.length > 0"> <div v-if="jobs.length > 0">
@ -22,59 +35,114 @@
</div> </div>
<span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
</div> </div>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <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 number from '@/filters/number';
import MkQueueChart from '@/components/queue-chart.vue';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream';
const connection = markRaw(stream.useChannel('queueStats'));
const activeSincePrevTick = ref(0); const activeSincePrevTick = ref(0);
const active = ref(0); const active = ref(0);
const waiting = ref(0);
const delayed = ref(0); const delayed = ref(0);
const waiting = ref(0);
const jobs = ref([]); 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<{ const props = defineProps<{
domain: string, domain: string;
connection: any,
}>(); }>();
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(() => { onMounted(() => {
os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
jobs.value = result; jobs.value = result;
}); });
const onStats = (stats) => { connection.on('stats', onStats);
activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; connection.on('statsLog', onStatsLog);
active.value = stats[props.domain].active; connection.send('requestLog', {
waiting.value = stats[props.domain].waiting; id: Math.random().toString().substr(2, 8),
delayed.value = stats[props.domain].delayed; length: 200,
};
props.connection.on('stats', onStats);
onUnmounted(() => {
props.connection.off('stats', onStats);
}); });
}); });
onUnmounted(() => {
connection.off('stats', onStats);
connection.off('statsLog', onStatsLog);
connection.dispose();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.pumxzjhg { .pumxzjhg {
> .status { > .status {
padding: 16px; 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 { > .jobs {
margin-top: 16px;
padding: 16px; padding: 16px;
border-top: solid 0.5px var(--divider);
max-height: 180px; max-height: 180px;
overflow: auto; overflow: auto;
background: var(--panel);
border-radius: var(--radius);
} }
} }
</style> </style>

View file

@ -1,14 +1,9 @@
<template> <template>
<MkStickyContainer> <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"> <MkSpacer :content-max="800">
<XQueue :connection="connection" domain="inbox"> <XQueue v-if="tab === 'deliver'" domain="deliver"/>
<template #title>In</template> <XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
</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>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -19,12 +14,11 @@ import XQueue from './queue.chart.vue';
import XHeader from './_header_.vue'; import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream';
import * as config from '@/config'; import * as config from '@/config';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
const connection = markRaw(stream.useChannel('queueStats')); let tab = $ref('deliver');
function clear() { function clear() {
os.confirm({ 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(() => [{ const headerActions = $computed(() => [{
asFullButton: true, asFullButton: true,
icon: 'fas fa-up-right-from-square', 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({ definePageMetadata({
title: i18n.ts.jobQueue, title: i18n.ts.jobQueue,