feat: ✨ new admin panel data from Mk v13
This commit is contained in:
parent
e9d55ff44b
commit
21e7529725
20 changed files with 1649 additions and 123 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "calckey",
|
||||
"version": "13.2.0-dev7",
|
||||
"version": "13.2.0-dev8",
|
||||
"codename": "aqua",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
"lint": "pnpm rome check \"src/**/*.{ts,vue}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@khmyznikov/pwa-install": "^0.2.0"
|
||||
"@khmyznikov/pwa-install": "^0.2.0",
|
||||
"chartjs-chart-matrix": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@discordapp/twemoji": "14.0.2",
|
||||
|
|
218
packages/client/src/components/MkHeatmap.vue
Normal file
218
packages/client/src/components/MkHeatmap.vue
Normal file
|
@ -0,0 +1,218 @@
|
|||
<template>
|
||||
<div ref="rootEl">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else>
|
||||
<canvas ref="chartEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { chartVLine } from '@/scripts/chart-vline';
|
||||
import { alpha } from '@/scripts/color';
|
||||
import { initChart } from '@/scripts/init-chart';
|
||||
|
||||
initChart();
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
}>();
|
||||
|
||||
const rootEl = $shallowRef<HTMLDivElement>(null);
|
||||
const chartEl = $shallowRef<HTMLCanvasElement>(null);
|
||||
const now = new Date();
|
||||
let chartInstance: Chart = null;
|
||||
let fetching = $ref(true);
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
|
||||
async function renderChart() {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const wide = rootEl.offsetWidth > 700;
|
||||
const narrow = rootEl.offsetWidth < 400;
|
||||
|
||||
const weeks = wide ? 50 : narrow ? 10 : 25;
|
||||
const chartLimit = 7 * weeks;
|
||||
|
||||
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) => {
|
||||
const dt = getDate(i);
|
||||
const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`;
|
||||
return {
|
||||
x: iso,
|
||||
y: dt.getDay(),
|
||||
d: iso,
|
||||
v,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let values;
|
||||
|
||||
if (props.src === 'active-users') {
|
||||
const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
|
||||
values = raw.readWrite;
|
||||
} else if (props.src === 'notes') {
|
||||
const raw = await os.api('charts/notes', { limit: chartLimit, span: 'day' });
|
||||
values = raw.local.inc;
|
||||
} else if (props.src === 'ap-requests-inbox-received') {
|
||||
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
|
||||
values = raw.inboxReceived;
|
||||
} else if (props.src === 'ap-requests-deliver-succeeded') {
|
||||
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
|
||||
values = raw.deliverSucceeded;
|
||||
} else if (props.src === 'ap-requests-deliver-failed') {
|
||||
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
|
||||
values = raw.deliverFailed;
|
||||
}
|
||||
|
||||
fetching = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
|
||||
|
||||
// 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする
|
||||
const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||||
|
||||
const min = Math.max(0, Math.min(...values) - 1);
|
||||
|
||||
const marginEachCell = 4;
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
type: 'matrix',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: 'Read & Write',
|
||||
data: format(values),
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 3,
|
||||
backgroundColor(c) {
|
||||
const value = c.dataset.data[c.dataIndex].v;
|
||||
let a = (value - min) / max;
|
||||
if (value !== 0) { // 0でない限りは完全に不可視にはしない
|
||||
a = Math.max(a, 0.05);
|
||||
}
|
||||
return alpha(color, a);
|
||||
},
|
||||
fill: true,
|
||||
width(c) {
|
||||
const a = c.chart.chartArea ?? {};
|
||||
return (a.right - a.left) / weeks - marginEachCell;
|
||||
},
|
||||
height(c) {
|
||||
const a = c.chart.chartArea ?? {};
|
||||
return (a.bottom - a.top) / 7 - marginEachCell;
|
||||
},
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 8,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
offset: true,
|
||||
position: 'bottom',
|
||||
time: {
|
||||
unit: 'week',
|
||||
round: 'week',
|
||||
isoWeekday: 0,
|
||||
displayFormats: {
|
||||
day: 'M/d',
|
||||
month: 'Y/M',
|
||||
week: 'M/d',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
offset: true,
|
||||
reverse: true,
|
||||
position: 'right',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
padding: 1,
|
||||
font: {
|
||||
size: 9,
|
||||
},
|
||||
callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
callbacks: {
|
||||
title(context) {
|
||||
const v = context[0].dataset.data[context[0].dataIndex];
|
||||
return v.d;
|
||||
},
|
||||
label(context) {
|
||||
const v = context.dataset.data[context.dataIndex];
|
||||
return ['Active: ' + v.v];
|
||||
},
|
||||
},
|
||||
//mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => props.src, () => {
|
||||
fetching = true;
|
||||
renderChart();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
|
@ -32,6 +32,19 @@
|
|||
<div class="chart">
|
||||
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
|
||||
</div>
|
||||
<div class="item">
|
||||
<template #header>Active users heatmap</template>
|
||||
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;">
|
||||
<option value="active-users">Active users</option>
|
||||
<option value="notes">Notes</option>
|
||||
<option value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
|
||||
<option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
|
||||
<option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
|
||||
</MkSelect>
|
||||
<div class="_panel heatmap">
|
||||
<MkHeatmap :src="heatmapSrc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subpub">
|
||||
|
@ -69,6 +82,7 @@ import {
|
|||
} from 'chart.js';
|
||||
import MkSelect from '@/components/form/select.vue';
|
||||
import MkChart from '@/components/MkChart.vue';
|
||||
import MkHeatmap from '@/components/MkHeatmap.vue';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
@ -98,13 +112,18 @@ const props = withDefaults(defineProps<{
|
|||
chartLimit: 90,
|
||||
});
|
||||
|
||||
const chartSpan = $ref<'hour' | 'day'>('hour');
|
||||
const chartSrc = $ref('active-users');
|
||||
let chartSpan = $ref<'hour' | 'day'>('hour');
|
||||
let chartSrc = $ref('active-users');
|
||||
let heatmapSrc = $ref('active-users');
|
||||
let subDoughnutEl = $ref<HTMLCanvasElement>();
|
||||
let pubDoughnutEl = $ref<HTMLCanvasElement>();
|
||||
|
||||
const { handler: externalTooltipHandler1 } = useChartTooltip();
|
||||
const { handler: externalTooltipHandler2 } = useChartTooltip();
|
||||
const { handler: externalTooltipHandler1 } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
const { handler: externalTooltipHandler2 } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
|
||||
function createDoughnut(chartEl, tooltip, data) {
|
||||
const chartInstance = new Chart(chartEl, {
|
||||
|
@ -189,6 +208,10 @@ onMounted(() => {
|
|||
> .chart {
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
> .heatmap {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
167
packages/client/src/pages/admin/overview.active-users.vue
Normal file
167
packages/client/src/pages/admin/overview.active-users.vue
Normal file
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-show="!fetching" :class="$style.root" class="_panel">
|
||||
<canvas ref="chartEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import gradient from 'chartjs-plugin-gradient';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { chartVLine } from '@/scripts/chart-vline';
|
||||
import { alpha } from '@/scripts/color';
|
||||
import { initChart } from '@/scripts/init-chart';
|
||||
|
||||
initChart();
|
||||
|
||||
const chartEl = $shallowRef<HTMLCanvasElement>(null);
|
||||
const now = new Date();
|
||||
let chartInstance: Chart = null;
|
||||
const chartLimit = 7;
|
||||
let fetching = $ref(true);
|
||||
|
||||
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 vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
|
||||
const colorRead = '#3498db';
|
||||
const colorWrite = '#2ecc71';
|
||||
|
||||
const max = Math.max(...raw.read);
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
datasets: [{
|
||||
parsing: false,
|
||||
label: 'Read',
|
||||
data: format(raw.read).slice().reverse(),
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 4,
|
||||
backgroundColor: colorRead,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.5,
|
||||
fill: true,
|
||||
}, {
|
||||
parsing: false,
|
||||
label: 'Write',
|
||||
data: format(raw.write).slice().reverse(),
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 4,
|
||||
backgroundColor: colorWrite,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.5,
|
||||
fill: true,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 2.5,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
offset: true,
|
||||
time: {
|
||||
stepSize: 1,
|
||||
unit: 'day',
|
||||
displayFormats: {
|
||||
day: 'M/d',
|
||||
month: 'Y/M',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left',
|
||||
suggestedMax: 10,
|
||||
grid: {
|
||||
display: true,
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
//mirror: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
gradient,
|
||||
},
|
||||
},
|
||||
plugins: [chartVLine(vLineColor)],
|
||||
});
|
||||
|
||||
fetching = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
287
packages/client/src/pages/admin/overview.ap-requests.vue
Normal file
287
packages/client/src/pages/admin/overview.ap-requests.vue
Normal file
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-show="!fetching" :class="$style.root">
|
||||
<div class="charts _panel">
|
||||
<div class="chart">
|
||||
<canvas ref="chartEl2"></canvas>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<canvas ref="chartEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import gradient from 'chartjs-plugin-gradient';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||
import * as os from '@/os';
|
||||
import number from '@/filters/number';
|
||||
import MkNumberDiff from '@/components/MkNumberDiff.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { chartVLine } from '@/scripts/chart-vline';
|
||||
import { defaultStore } from '@/store';
|
||||
import { alpha } from '@/scripts/color';
|
||||
import { initChart } from '@/scripts/init-chart';
|
||||
|
||||
initChart();
|
||||
|
||||
const chartLimit = 50;
|
||||
const chartEl = $shallowRef<HTMLCanvasElement>();
|
||||
const chartEl2 = $shallowRef<HTMLCanvasElement>();
|
||||
let fetching = $ref(true);
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip();
|
||||
const { handler: externalTooltipHandler2 } = useChartTooltip();
|
||||
|
||||
onMounted(async () => {
|
||||
const now = new Date();
|
||||
|
||||
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 formatMinus = (arr) => {
|
||||
return arr.map((v, i) => ({
|
||||
x: getDate(i).getTime(),
|
||||
y: -v,
|
||||
}));
|
||||
};
|
||||
|
||||
const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
|
||||
|
||||
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
const succColor = '#87e000';
|
||||
const failColor = '#ff4400';
|
||||
|
||||
const succMax = Math.max(...raw.deliverSucceeded);
|
||||
const failMax = Math.max(...raw.deliverFailed);
|
||||
|
||||
new Chart(chartEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
stack: 'a',
|
||||
parsing: false,
|
||||
label: 'Out: Succ',
|
||||
data: format(raw.deliverSucceeded).slice().reverse(),
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: succColor,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 4,
|
||||
backgroundColor: alpha(succColor, 0.35),
|
||||
fill: true,
|
||||
clip: 8,
|
||||
}, {
|
||||
stack: 'a',
|
||||
parsing: false,
|
||||
label: 'Out: Fail',
|
||||
data: formatMinus(raw.deliverFailed).slice().reverse(),
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: failColor,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 4,
|
||||
backgroundColor: alpha(failColor, 0.35),
|
||||
fill: true,
|
||||
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: 'day',
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 16,
|
||||
},
|
||||
min: getDate(chartLimit).getTime(),
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
position: 'left',
|
||||
suggestedMax: 10,
|
||||
grid: {
|
||||
display: true,
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
//mirror: true,
|
||||
callback: (value, index, values) => value < 0 ? -value : value,
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
hoverRadius: 5,
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
gradient,
|
||||
},
|
||||
},
|
||||
plugins: [chartVLine(vLineColor)],
|
||||
});
|
||||
|
||||
new Chart(chartEl2, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
datasets: [{
|
||||
parsing: false,
|
||||
label: 'In',
|
||||
data: format(raw.inboxReceived).slice().reverse(),
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#0cc2d6',
|
||||
barPercentage: 0.8,
|
||||
categoryPercentage: 0.9,
|
||||
fill: true,
|
||||
clip: 8,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 5,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
offset: false,
|
||||
time: {
|
||||
stepSize: 1,
|
||||
unit: 'day',
|
||||
displayFormats: {
|
||||
day: 'M/d',
|
||||
month: 'Y/M',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 16,
|
||||
},
|
||||
min: getDate(chartLimit).getTime(),
|
||||
},
|
||||
y: {
|
||||
position: 'left',
|
||||
suggestedMax: 10,
|
||||
grid: {
|
||||
display: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
hoverRadius: 5,
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler2,
|
||||
},
|
||||
gradient,
|
||||
},
|
||||
},
|
||||
plugins: [chartVLine(vLineColor)],
|
||||
});
|
||||
|
||||
fetching = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
&:global {
|
||||
> .charts {
|
||||
> .chart {
|
||||
padding: 16px;
|
||||
|
||||
&:first-child {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,100 +1,185 @@
|
|||
<template>
|
||||
<div class="wbrkwale">
|
||||
<div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
|
||||
<MkA v-for="(instance, i) in instances" :key="instance.id" :to="`/instance-info/${instance.host}`" class="instance">
|
||||
<img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/>
|
||||
<div class="body">
|
||||
<div class="name">{{ instance.name ?? instance.host }}</div>
|
||||
<div class="host">{{ instance.host }}</div>
|
||||
<div v-show="!fetching" :class="$style.root">
|
||||
<div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies">
|
||||
<div class="pie deliver _panel">
|
||||
<div class="title">Sub</div>
|
||||
<XPie :data="topSubInstancesForPie" class="chart"/>
|
||||
<div class="subTitle">Top 10</div>
|
||||
</div>
|
||||
<MkMiniChart class="chart" :src="charts[i].requests.received"/>
|
||||
</MkA>
|
||||
</transition-group>
|
||||
<div class="pie inbox _panel">
|
||||
<div class="title">Pub</div>
|
||||
<XPie :data="topPubInstancesForPie" class="chart"/>
|
||||
<div class="subTitle">Top 10</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!fetching" class="items">
|
||||
<div class="item _panel sub">
|
||||
<div class="icon"><i class="ti ti-world-download"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
{{ number(federationSubActive) }}
|
||||
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff>
|
||||
</div>
|
||||
<div class="label">Sub</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item _panel pub">
|
||||
<div class="icon"><i class="ti ti-world-upload"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
{{ number(federationPubActive) }}
|
||||
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff>
|
||||
</div>
|
||||
<div class="label">Pub</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import XPie from './overview.pie.vue';
|
||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||
import * as os from '@/os';
|
||||
import { useInterval } from '@/scripts/use-interval';
|
||||
import number from '@/filters/number';
|
||||
import MkNumberDiff from '@/components/MkNumberDiff.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
|
||||
const instances = ref([]);
|
||||
const charts = ref([]);
|
||||
const fetching = ref(true);
|
||||
let topSubInstancesForPie: any = $ref(null);
|
||||
let topPubInstancesForPie: any = $ref(null);
|
||||
let federationPubActive = $ref<number | null>(null);
|
||||
let federationPubActiveDiff = $ref<number | null>(null);
|
||||
let federationSubActive = $ref<number | null>(null);
|
||||
let federationSubActiveDiff = $ref<number | null>(null);
|
||||
let fetching = $ref(true);
|
||||
|
||||
const fetch = async () => {
|
||||
const fetchedInstances = await os.api('federation/instances', {
|
||||
sort: '+lastCommunicatedAt',
|
||||
limit: 5,
|
||||
const { handler: externalTooltipHandler } = useChartTooltip();
|
||||
|
||||
onMounted(async () => {
|
||||
const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' });
|
||||
federationPubActive = chart.pubActive[0];
|
||||
federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
|
||||
federationSubActive = chart.subActive[0];
|
||||
federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
|
||||
|
||||
os.apiGet('federation/stats', { limit: 10 }).then(res => {
|
||||
topSubInstancesForPie = res.topSubInstances.map(x => ({
|
||||
name: x.host,
|
||||
color: x.themeColor,
|
||||
value: x.followersCount,
|
||||
onClick: () => {
|
||||
os.pageWindow(`/instance-info/${x.host}`);
|
||||
},
|
||||
})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
|
||||
topPubInstancesForPie = res.topPubInstances.map(x => ({
|
||||
name: x.host,
|
||||
color: x.themeColor,
|
||||
value: x.followingCount,
|
||||
onClick: () => {
|
||||
os.pageWindow(`/instance-info/${x.host}`);
|
||||
},
|
||||
})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
fetching = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wbrkwale {
|
||||
> .instances {
|
||||
.chart-move {
|
||||
transition: transform 1s ease;
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
|
||||
&:global {
|
||||
> .pies {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
> .pie {
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
|
||||
> .title {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
> .chart {
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
> .subTitle {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
font-size: 85%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .instance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
> .items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
grid-gap: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
}
|
||||
> .item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
|
||||
> 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;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
> .icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
margin-right: 12px;
|
||||
background: var(--accentedBg);
|
||||
color: var(--accent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
> .host {
|
||||
margin: 0;
|
||||
font-size: 75%;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
&.sub {
|
||||
> .icon {
|
||||
background: #d5ba0026;
|
||||
color: #dfc300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .chart {
|
||||
height: 30px;
|
||||
&.pub {
|
||||
> .icon {
|
||||
background: #00cf2326;
|
||||
color: #00cd5b;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 2px 0;
|
||||
|
||||
> .value {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
|
||||
> .diff {
|
||||
font-size: 0.65em;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
25
packages/client/src/pages/admin/overview.heatmap.vue
Normal file
25
packages/client/src/pages/admin/overview.heatmap.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="_panel" :class="$style.root">
|
||||
<MkSelect v-model="src" style="margin: 0 0 12px 0;" small>
|
||||
<option value="active-users">Active users</option>
|
||||
<option value="notes">Notes</option>
|
||||
<option value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
|
||||
<option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
|
||||
<option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
|
||||
</MkSelect>
|
||||
<MkHeatmap :src="src"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkHeatmap from '@/components/MkHeatmap.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
|
||||
let src = $ref('active-users');
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
50
packages/client/src/pages/admin/overview.instances.vue
Normal file
50
packages/client/src/pages/admin/overview.instances.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div class="wbrkwale">
|
||||
<Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else class="instances">
|
||||
<MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance">
|
||||
<MkInstanceCardMini :instance="instance"/>
|
||||
</MkA>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { useInterval } from '@/scripts/use-interval';
|
||||
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
|
||||
|
||||
const instances = ref([]);
|
||||
const fetching = ref(true);
|
||||
|
||||
const fetch = async () => {
|
||||
const fetchedInstances = await os.api('federation/instances', {
|
||||
sort: '+latestRequestReceivedAt',
|
||||
limit: 6,
|
||||
});
|
||||
instances.value = fetchedInstances;
|
||||
fetching.value = false;
|
||||
};
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wbrkwale {
|
||||
> .instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
grid-gap: 12px;
|
||||
|
||||
> .instance:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
55
packages/client/src/pages/admin/overview.moderators.vue
Normal file
55
packages/client/src/pages/admin/overview.moderators.vue
Normal file
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div>
|
||||
<Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else :class="$style.root" class="_panel">
|
||||
<MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`">
|
||||
<MkAvatar :user="user" class="avatar" indicator/>
|
||||
</MkA>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import number from '@/filters/number';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
let moderators: any = $ref(null);
|
||||
let fetching = $ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
moderators = await os.api('admin/show-users', {
|
||||
sort: '+lastActiveDate',
|
||||
state: 'adminOrModerator',
|
||||
limit: 30,
|
||||
});
|
||||
|
||||
fetching = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
|
||||
grid-gap: 12px;
|
||||
place-content: center;
|
||||
padding: 12px;
|
||||
|
||||
&:global {
|
||||
> .user {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
|
||||
> .avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -3,57 +3,24 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
Chart,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
DoughnutController,
|
||||
} from 'chart.js';
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import number from '@/filters/number';
|
||||
import { defaultStore } from '@/store';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { initChart } from '@/scripts/init-chart';
|
||||
|
||||
Chart.register(
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
DoughnutController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
);
|
||||
initChart();
|
||||
|
||||
const props = defineProps<{
|
||||
data: { name: string; value: number; color: string; onClick?: () => void }[];
|
||||
}>();
|
||||
|
||||
const chartEl = ref<HTMLCanvasElement>(null);
|
||||
const chartEl = shallowRef<HTMLCanvasElement>(null);
|
||||
|
||||
// フォントカラー
|
||||
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip();
|
||||
const { handler: externalTooltipHandler } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
|
||||
let chartInstance: Chart;
|
||||
|
||||
|
|
140
packages/client/src/pages/admin/overview.queue.chart.vue
Normal file
140
packages/client/src/pages/admin/overview.queue.chart.vue
Normal file
|
@ -0,0 +1,140 @@
|
|||
<template>
|
||||
<canvas ref="chartEl"></canvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import number from '@/filters/number';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { chartVLine } from '@/scripts/chart-vline';
|
||||
import { alpha } from '@/scripts/color';
|
||||
import { initChart } from '@/scripts/init-chart';
|
||||
|
||||
initChart();
|
||||
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
}>();
|
||||
|
||||
const chartEl = shallowRef<HTMLCanvasElement>(null);
|
||||
|
||||
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 > 100) {
|
||||
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 > 100) {
|
||||
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(() => {
|
||||
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
|
||||
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.2),
|
||||
fill: true,
|
||||
data: [],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 2.5,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
grid: {
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [chartVLine(vLineColor)],
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
setData,
|
||||
pushData,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
127
packages/client/src/pages/admin/overview.queue.vue
Normal file
127
packages/client/src/pages/admin/overview.queue.vue
Normal file
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<div class="_table status">
|
||||
<div class="_row">
|
||||
<div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
|
||||
<div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div>
|
||||
<div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
|
||||
<div class="_cell" style="text-align: center;"><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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { markRaw, onMounted, onUnmounted, ref } from 'vue';
|
||||
import XChart from './overview.queue.chart.vue';
|
||||
import number from '@/filters/number';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const connection = markRaw(stream.useChannel('queueStats'));
|
||||
|
||||
const activeSincePrevTick = ref(0);
|
||||
const active = ref(0);
|
||||
const delayed = ref(0);
|
||||
const waiting = ref(0);
|
||||
let chartProcess = $shallowRef<InstanceType<typeof XChart>>();
|
||||
let chartActive = $shallowRef<InstanceType<typeof XChart>>();
|
||||
let chartDelayed = $shallowRef<InstanceType<typeof XChart>>();
|
||||
let chartWaiting = $shallowRef<InstanceType<typeof XChart>>();
|
||||
|
||||
const props = defineProps<{
|
||||
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(() => {
|
||||
connection.on('stats', onStats);
|
||||
connection.on('statsLog', onStatsLog);
|
||||
connection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
length: 100,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
connection.off('stats', onStats);
|
||||
connection.off('statsLog', onStatsLog);
|
||||
connection.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
&:global {
|
||||
> .status {
|
||||
padding: 0 0 16px 0;
|
||||
}
|
||||
|
||||
> .charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
|
||||
> .chart {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
|
||||
> .title {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
173
packages/client/src/pages/admin/overview.stats.vue
Normal file
173
packages/client/src/pages/admin/overview.stats.vue
Normal file
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<div>
|
||||
<Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else :class="$style.root">
|
||||
<div class="item _panel users">
|
||||
<div class="icon"><i class="ti ti-users"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
<MkNumber :value="stats.originalUsersCount" style="margin-right: 0.5em;"/>
|
||||
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff>
|
||||
</div>
|
||||
<div class="label">Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item _panel notes">
|
||||
<div class="icon"><i class="ti ti-pencil"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
<MkNumber :value="stats.originalNotesCount" style="margin-right: 0.5em;"/>
|
||||
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff>
|
||||
</div>
|
||||
<div class="label">Notes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item _panel instances">
|
||||
<div class="icon"><i class="ti ti-planet"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
<MkNumber :value="stats.instances" style="margin-right: 0.5em;"/>
|
||||
</div>
|
||||
<div class="label">Instances</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item _panel emojis">
|
||||
<div class="icon"><i class="ti ti-icons"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
<MkNumber :value="customEmojis.length" style="margin-right: 0.5em;"/>
|
||||
</div>
|
||||
<div class="label">Custom emojis</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item _panel online">
|
||||
<div class="icon"><i class="ti ti-access-point"></i></div>
|
||||
<div class="body">
|
||||
<div class="value">
|
||||
<MkNumber :value="onlineUsersCount" style="margin-right: 0.5em;"/>
|
||||
</div>
|
||||
<div class="label">Online</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||
import * as os from '@/os';
|
||||
import number from '@/filters/number';
|
||||
import MkNumberDiff from '@/components/MkNumberDiff.vue';
|
||||
import MkNumber from '@/components/MkNumber.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { customEmojis } from '@/custom-emojis';
|
||||
|
||||
let stats: any = $ref(null);
|
||||
let usersComparedToThePrevDay = $ref<number>();
|
||||
let notesComparedToThePrevDay = $ref<number>();
|
||||
let onlineUsersCount = $ref(0);
|
||||
let fetching = $ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
const [_stats, _onlineUsersCount] = await Promise.all([
|
||||
os.api('stats', {}),
|
||||
os.api('get-online-users-count').then(res => res.count),
|
||||
]);
|
||||
stats = _stats;
|
||||
onlineUsersCount = _onlineUsersCount;
|
||||
|
||||
os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => {
|
||||
usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1];
|
||||
});
|
||||
|
||||
os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => {
|
||||
notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1];
|
||||
});
|
||||
|
||||
fetching = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
grid-gap: 12px;
|
||||
|
||||
&:global {
|
||||
> .item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
|
||||
> .icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
margin-right: 12px;
|
||||
background: var(--accentedBg);
|
||||
color: var(--accent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&.users {
|
||||
> .icon {
|
||||
background: #0088d726;
|
||||
color: #3d96c1;
|
||||
}
|
||||
}
|
||||
|
||||
&.notes {
|
||||
> .icon {
|
||||
background: #86b30026;
|
||||
color: #86b300;
|
||||
}
|
||||
}
|
||||
|
||||
&.instances {
|
||||
> .icon {
|
||||
background: #e96b0026;
|
||||
color: #d76d00;
|
||||
}
|
||||
}
|
||||
|
||||
&.emojis {
|
||||
> .icon {
|
||||
background: #d5ba0026;
|
||||
color: #dfc300;
|
||||
}
|
||||
}
|
||||
|
||||
&.online {
|
||||
> .icon {
|
||||
background: #8a00d126;
|
||||
color: #c01ac3;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 2px 0;
|
||||
|
||||
> .value {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
|
||||
> .diff {
|
||||
font-size: 0.65em;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
57
packages/client/src/pages/admin/overview.users.vue
Normal file
57
packages/client/src/pages/admin/overview.users.vue
Normal file
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else class="users">
|
||||
<MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user">
|
||||
<MkUserCardMini :user="user"/>
|
||||
</MkA>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { useInterval } from '@/scripts/use-interval';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
|
||||
let newUsers = $ref(null);
|
||||
let fetching = $ref(true);
|
||||
|
||||
const fetch = async () => {
|
||||
const _newUsers = await os.api('admin/show-users', {
|
||||
limit: 5,
|
||||
sort: '+createdAt',
|
||||
origin: 'local',
|
||||
});
|
||||
newUsers = _newUsers;
|
||||
fetching = false;
|
||||
};
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
&:global {
|
||||
> .users {
|
||||
.chart-move {
|
||||
transition: transform 1s ease;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-gap: 12px;
|
||||
|
||||
> .user:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -129,6 +129,55 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<template #header>Stats</template>
|
||||
<XStats/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Active users</template>
|
||||
<XActiveUsers/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Heatmap</template>
|
||||
<XHeatmap/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Moderators</template>
|
||||
<XModerators/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Federation</template>
|
||||
<XFederation/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Instances</template>
|
||||
<XInstances/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Ap requests</template>
|
||||
<XApRequests/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>New users</template>
|
||||
<XUsers/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Deliver queue</template>
|
||||
<XQueue domain="deliver"/>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<template #header>Inbox queue</template>
|
||||
<XQueue domain="inbox"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
|
@ -159,6 +208,12 @@ import MagicGrid from 'magic-grid';
|
|||
import XMetrics from './metrics.vue';
|
||||
import XFederation from './overview.federation.vue';
|
||||
import XQueueChart from './overview.queue-chart.vue';
|
||||
import XApRequests from './overview.ap-requests.vue';
|
||||
import XUsers from './overview.users.vue';
|
||||
import XActiveUsers from './overview.active-users.vue';
|
||||
import XStats from './overview.stats.vue';
|
||||
import XModerators from './overview.moderators.vue';
|
||||
import XHeatmap from './overview.heatmap.vue';
|
||||
import XUser from './overview.user.vue';
|
||||
import XPie from './overview.pie.vue';
|
||||
import MkNumberDiff from '@/components/MkNumberDiff.vue';
|
||||
|
|
7
packages/client/src/scripts/color.ts
Normal file
7
packages/client/src/scripts/color.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const alpha = (hex: string, a: number): string => {
|
||||
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})`;
|
||||
};
|
58
packages/client/src/scripts/init-chart.ts
Normal file
58
packages/client/src/scripts/init-chart.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
Chart,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
DoughnutController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import gradient from "chartjs-plugin-gradient";
|
||||
import zoomPlugin from "chartjs-plugin-zoom";
|
||||
import { MatrixController, MatrixElement } from "chartjs-chart-matrix";
|
||||
import { defaultStore } from "@/store";
|
||||
import "chartjs-adapter-date-fns";
|
||||
|
||||
export function initChart() {
|
||||
Chart.register(
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
DoughnutController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
MatrixController,
|
||||
MatrixElement,
|
||||
zoomPlugin,
|
||||
gradient,
|
||||
);
|
||||
|
||||
// フォントカラー
|
||||
Chart.defaults.color = getComputedStyle(
|
||||
document.documentElement,
|
||||
).getPropertyValue("--fg");
|
||||
|
||||
Chart.defaults.borderColor = defaultStore.state.darkMode
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(0, 0, 0, 0.1)";
|
||||
|
||||
Chart.defaults.animation = false;
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import { onUnmounted, ref } from "vue";
|
||||
import { onUnmounted, onDeactivated, ref } from "vue";
|
||||
import * as os from "@/os";
|
||||
import MkChartTooltip from "@/components/MkChartTooltip.vue";
|
||||
|
||||
export function useChartTooltip() {
|
||||
export function useChartTooltip(
|
||||
opts: { position: "top" | "middle" } = { position: "top" },
|
||||
) {
|
||||
const tooltipShowing = ref(false);
|
||||
const tooltipX = ref(0);
|
||||
const tooltipY = ref(0);
|
||||
|
@ -28,6 +30,10 @@ export function useChartTooltip() {
|
|||
if (disposeTooltipComponent) disposeTooltipComponent();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
tooltipShowing.value = false;
|
||||
});
|
||||
|
||||
function handler(context) {
|
||||
if (context.tooltip.opacity === 0) {
|
||||
tooltipShowing.value = false;
|
||||
|
@ -45,7 +51,11 @@ export function useChartTooltip() {
|
|||
|
||||
tooltipShowing.value = true;
|
||||
tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
|
||||
tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
|
||||
if (opts.position === "top") {
|
||||
tooltipY.value = rect.top + window.pageYOffset;
|
||||
} else if (opts.position === "middle") {
|
||||
tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -417,6 +417,7 @@ importers:
|
|||
calckey-js: ^0.0.22
|
||||
chart.js: 4.1.1
|
||||
chartjs-adapter-date-fns: 2.0.1
|
||||
chartjs-chart-matrix: ^2.0.1
|
||||
chartjs-plugin-gradient: 0.5.1
|
||||
chartjs-plugin-zoom: 1.2.1
|
||||
compare-versions: 5.0.3
|
||||
|
@ -464,6 +465,7 @@ importers:
|
|||
vuedraggable: 4.1.0
|
||||
dependencies:
|
||||
'@khmyznikov/pwa-install': 0.2.0
|
||||
chartjs-chart-matrix: 2.0.1_chart.js@4.1.1
|
||||
devDependencies:
|
||||
'@discordapp/twemoji': 14.0.2
|
||||
'@rollup/plugin-alias': 3.1.9_rollup@3.9.1
|
||||
|
@ -1266,7 +1268,6 @@ packages:
|
|||
|
||||
/@kurkle/color/0.3.2:
|
||||
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
|
||||
dev: true
|
||||
|
||||
/@lit-labs/ssr-dom-shim/1.0.0:
|
||||
resolution: {integrity: sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw==}
|
||||
|
@ -3387,7 +3388,7 @@ packages:
|
|||
/axios/0.25.0_debug@4.3.4:
|
||||
resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==}
|
||||
dependencies:
|
||||
follow-redirects: 1.15.2
|
||||
follow-redirects: 1.15.2_debug@4.3.4
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
dev: true
|
||||
|
@ -4007,7 +4008,6 @@ packages:
|
|||
engines: {pnpm: ^7.0.0}
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.2
|
||||
dev: true
|
||||
|
||||
/chartjs-adapter-date-fns/2.0.1_chart.js@4.1.1:
|
||||
resolution: {integrity: sha512-v3WV9rdnQ05ce3A0ZCjzUekJCAbfm6+3HqSoeY2BIkdMYZoYr/4T+ril1tZyDl869lz6xdNVMXejUFT9YKpw4A==}
|
||||
|
@ -4017,6 +4017,14 @@ packages:
|
|||
chart.js: 4.1.1
|
||||
dev: true
|
||||
|
||||
/chartjs-chart-matrix/2.0.1_chart.js@4.1.1:
|
||||
resolution: {integrity: sha512-BGfeY+/PHnITyDlc7WfnKJ1RyOfgOzIqWp/gxzzl7pUjyoGzHDcw51qd2xJF9gdT9Def7ZwOnOMm8GJUXDxI0w==}
|
||||
peerDependencies:
|
||||
chart.js: '>=3.0.0'
|
||||
dependencies:
|
||||
chart.js: 4.1.1
|
||||
dev: false
|
||||
|
||||
/chartjs-plugin-gradient/0.5.1_chart.js@4.1.1:
|
||||
resolution: {integrity: sha512-vhwlYGZWan4MGZZ4Wj64Y4aIql1uCPCU1JcggLWn3cgYEv4G7pXp1YgM4XH5ugmyn6BVCgQqAhiJ2h6hppzHmQ==}
|
||||
peerDependencies:
|
||||
|
@ -6372,6 +6380,19 @@ packages:
|
|||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
dev: false
|
||||
|
||||
/follow-redirects/1.15.2_debug@4.3.4:
|
||||
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
dev: true
|
||||
|
||||
/for-each/0.3.3:
|
||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||
|
|
Loading…
Reference in a new issue