enhance(client): enhance dashboard of control panel

This commit is contained in:
syuilo 2022-12-23 15:21:55 +09:00
parent c0fc0b92d3
commit 052e667f03
14 changed files with 1145 additions and 752 deletions

View file

@ -32,6 +32,7 @@ You should also include the user name that made the change.
- Client: Add new gabber kick sounds (thanks for noizenecio)
- Client: Compress non-animated PNG files @saschanaz
- Client: Youtube window player @sim1222
- Client: enhance dashboard of control panel @syuilo
### Bugfixes
- Server: 引用内の文章がnyaizeされてしまう問題を修正 @kabo2468

View file

@ -38,6 +38,7 @@ 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';
const props = defineProps({
src: {
@ -311,27 +312,7 @@ const render = () => {
gradient,
},
},
plugins: [{
id: 'vLine',
beforeDraw(chart, args, options) {
if (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();
}
},
}],
plugins: [chartVLine(vLineColor)],
});
};

View file

@ -0,0 +1,223 @@
<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,
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 * as os from '@/os';
import 'chartjs-adapter-date-fns';
import { defaultStore } from '@/store';
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
import gradient from 'chartjs-plugin-gradient';
import { chartVLine } from '@/scripts/chart-vline';
Chart.register(
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
CategoryScale,
LinearScale,
TimeScale,
Legend,
Title,
Tooltip,
SubTitle,
Filler,
gradient,
);
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 now = new Date();
let chartInstance: Chart = null;
const chartLimit = 50;
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 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')).toHexString();
const max = Math.max(...raw.read);
chartInstance = new Chart(chartEl, {
type: 'line',
data: {
//labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
datasets: [{
parsing: false,
label: 'active',
data: format(raw.read).slice().reverse(),
tension: 0.3,
pointRadius: 0,
borderWidth: 2,
borderColor: color,
borderJoinStyle: 'round',
//backgroundColor: alpha(color, 0.1),
gradient: {
backgroundColor: {
axis: 'y',
colors: {
0: alpha(color, 0),
[max]: alpha(color, 0.35),
},
},
},
barPercentage: 0.9,
categoryPercentage: 0.9,
fill: true,
clip: 8,
}],
},
options: {
aspectRatio: 2.5,
layout: {
padding: {
left: 0,
right: 8,
top: 0,
bottom: 0,
},
},
scales: {
x: {
type: 'time',
offset: false,
time: {
stepSize: 1,
unit: 'day',
},
grid: {
display: false,
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
},
ticks: {
display: true,
maxRotation: 0,
autoSkipPadding: 16,
},
adapters: {
date: {
locale: enUS,
},
},
min: getDate(chartLimit).getTime(),
},
y: {
position: 'left',
suggestedMax: 10,
grid: {
display: false,
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
},
ticks: {
display: true,
//mirror: true,
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
elements: {
point: {
hoverRadius: 5,
hoverBorderWidth: 2,
},
},
animation: true,
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>

View file

@ -0,0 +1,263 @@
<template>
<div>
<MkLoading v-if="fetching"/>
<div v-show="!fetching" :class="$style.root">
<div class="chart _panel">
<canvas ref="chartEl"></canvas>
</div>
</div>
</div>
</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,
} from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import { enUS } from 'date-fns/locale';
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';
Chart.register(
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
CategoryScale,
LinearScale,
TimeScale,
Legend,
Title,
Tooltip,
SubTitle,
Filler,
gradient,
);
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 chartLimit = 50;
const chartEl = $ref<HTMLCanvasElement>();
let fetching = $ref(true);
const { handler: externalTooltipHandler } = 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 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)';
const succColor = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--success')).toHexString();
const failColor = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--error')).toHexString();
const succMax = Math.max(...raw.deliverSucceeded);
const failMax = Math.max(...raw.deliverFailed);
//
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
new Chart(chartEl, {
type: 'bar',
data: {
//labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
datasets: [{
stack: 'a',
parsing: false,
label: 'Succ',
data: format(raw.deliverSucceeded).slice().reverse(),
tension: 0.3,
pointRadius: 0,
borderWidth: 0,
borderJoinStyle: 'round',
borderRadius: 4,
//backgroundColor: alpha(color, 0.1),
gradient: {
backgroundColor: {
axis: 'y',
colors: {
0: alpha(succColor, 0.3),
[succMax]: alpha(succColor, 1),
},
},
},
barPercentage: 0.9,
categoryPercentage: 0.9,
fill: true,
clip: 8,
}, {
stack: 'a',
parsing: false,
label: 'Fail',
data: formatMinus(raw.deliverFailed).slice().reverse(),
tension: 0.3,
pointRadius: 0,
borderWidth: 0,
borderJoinStyle: 'round',
borderRadius: 4,
//backgroundColor: alpha(color, 0.1),
gradient: {
backgroundColor: {
axis: 'y',
colors: {
0: alpha(failColor, 0.3),
[-failMax]: alpha(failColor, 1),
},
},
},
barPercentage: 0.9,
categoryPercentage: 0.9,
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: false,
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
},
ticks: {
display: true,
maxRotation: 0,
autoSkipPadding: 16,
},
adapters: {
date: {
locale: enUS,
},
},
min: getDate(chartLimit).getTime(),
},
y: {
stacked: true,
position: 'left',
suggestedMax: 10,
grid: {
display: false,
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
},
ticks: {
display: true,
//mirror: true,
callback: (value, index, values) => value < 0 ? -value : value,
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
elements: {
point: {
hoverRadius: 5,
hoverBorderWidth: 2,
},
},
animation: true,
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
mode: 'index',
animation: {
duration: 0,
},
external: externalTooltipHandler,
},
gradient,
},
},
plugins: [chartVLine(vLineColor)],
});
fetching = false;
});
</script>
<style lang="scss" module>
.root {
&:global {
> .chart {
padding: 16px;
margin-bottom: 16px;
}
}
}
</style>

View file

@ -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>
<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>
<MkMiniChart class="chart" :src="charts[i].requests.received"/>
</MkA>
</transition-group>
</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 {
> .instance {
display: flex;
align-items: center;
padding: 16px 20px;
&:global {
> .pies {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
grid-gap: 16px;
margin-bottom: 16px;
&:not(:last-child) {
border-bottom: solid 0.5px var(--divider);
}
> .pie {
position: relative;
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;
}
> .host {
margin: 0;
font-size: 75%;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
> .title {
position: absolute;
top: 20px;
left: 20px;
font-size: 90%;
}
> .chart {
height: 30px;
max-height: 150px;
}
> .subTitle {
position: absolute;
bottom: 20px;
right: 20px;
font-size: 85%;
}
}
}
> .items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
grid-gap: 16px;
> .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;
}
&.sub {
> .icon {
background: #d5ba0026;
color: #dfc300;
}
}
&.pub {
> .icon {
background: #00cf2326;
color: #00cd5b;
}
}
> .body {
padding: 4px 0;
> .value {
font-size: 1.3em;
font-weight: bold;
> .diff {
font-size: 0.65em;
font-weight: normal;
}
}
> .label {
font-size: 0.8em;
opacity: 0.5;
}
}
}
}
}
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<div class="wbrkwale">
<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">
<MkInstanceCardMini :instance="instance"/>
</MkA>
</transition-group>
</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: '+lastCommunicatedAt',
limit: 6,
});
instances.value = fetchedInstances;
fetching.value = false;
};
useInterval(fetch, 1000 * 60, {
immediate: true,
afterMounted: true,
});
</script>
<style lang="scss" scoped>
.wbrkwale {
> .instances {
.chart-move {
transition: transform 1s ease;
}
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
grid-gap: 12px;
> .instance:hover {
text-decoration: none;
}
}
}
</style>

View file

@ -3,7 +3,7 @@
</template>
<script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
import { watch, onMounted, onUnmounted, ref } from 'vue';
import {
Chart,
ArcElement,
@ -25,6 +25,7 @@ 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';
Chart.register(
ArcElement,
@ -44,8 +45,7 @@ Chart.register(
);
const props = defineProps<{
domain: string;
connection: any;
type: string;
}>();
const alpha = (hex, a) => {
@ -67,81 +67,59 @@ const { handler: externalTooltipHandler } = useChartTooltip();
let chartInstance: Chart;
const onStats = (stats) => {
function setData(values) {
if (chartInstance == null) return;
for (const value of values) {
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);
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.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()) {
function pushData(value) {
if (chartInstance == null) return;
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);
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.data.datasets[1].data.shift();
chartInstance.data.datasets[2].data.shift();
chartInstance.data.datasets[3].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: 'Process',
label: label,
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,
borderColor: color,
backgroundColor: alpha(color, 0.1),
data: [],
}],
},
@ -157,9 +135,10 @@ onMounted(() => {
},
scales: {
x: {
display: false,
grid: {
display: false,
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
},
ticks: {
display: false,
@ -167,13 +146,10 @@ onMounted(() => {
},
},
y: {
display: false,
min: 0,
grid: {
display: false,
},
ticks: {
display: false,
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
},
},
},
@ -194,15 +170,13 @@ onMounted(() => {
},
},
},
plugins: [chartVLine(vLineColor)],
});
props.connection.on('stats', onStats);
props.connection.on('statsLog', onStatsLog);
});
onUnmounted(() => {
props.connection.off('stats', onStats);
props.connection.off('statsLog', onStatsLog);
defineExpose({
setData,
pushData,
});
</script>

View 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 = $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;
}>();
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: 16px;
> .chart {
min-width: 0;
padding: 16px;
background: var(--panel);
border-radius: var(--radius);
> .title {
font-size: 0.85em;
}
}
}
}
}
</style>

View file

@ -0,0 +1,153 @@
<template>
<div>
<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">
{{ number(stats.originalUsersCount) }}
<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">
{{ number(stats.originalNotesCount) }}
<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">
{{ number(stats.instances) }}
</div>
<div class="label">Instances</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">
{{ number(onlineUsersCount) }}
</div>
<div class="label">Online</div>
</div>
</div>
</div>
</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 { i18n } from '@/i18n';
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(230px, 1fr));
grid-gap: 16px;
&: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;
}
}
&.online {
> .icon {
background: #8a00d126;
color: #c01ac3;
}
}
> .body {
padding: 4px 0;
> .value {
font-size: 1.3em;
font-weight: bold;
> .diff {
font-size: 0.65em;
font-weight: normal;
}
}
> .label {
font-size: 0.8em;
opacity: 0.5;
}
}
}
}
}
</style>

View file

@ -1,76 +0,0 @@
<template>
<MkA :class="[$style.root]" :to="`/user-info/${user.id}`">
<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<div class="body">
<span class="name"><MkUserName class="name" :user="user"/></span>
<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
</div>
<MkMiniChart v-if="chart" class="chart" :src="chart.inc"/>
</MkA>
</template>
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import MkMiniChart from '@/components/MkMiniChart.vue';
import * as os from '@/os';
import { acct } from '@/filters/user';
const props = defineProps<{
user: misskey.entities.User;
}>();
let chart = $ref(null);
os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16, span: 'day' }).then(res => {
chart = res;
});
</script>
<style lang="scss" module>
.root {
$bodyTitleHieght: 18px;
$bodyInfoHieght: 16px;
display: flex;
align-items: center;
> :global(.avatar) {
display: block;
width: ($bodyTitleHieght + $bodyInfoHieght);
height: ($bodyTitleHieght + $bodyInfoHieght);
margin-right: 12px;
}
> :global(.body) {
flex: 1;
overflow: hidden;
font-size: 0.9em;
color: var(--fg);
padding-right: 8px;
> :global(.name) {
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: $bodyTitleHieght;
}
> :global(.sub) {
display: block;
width: 100%;
font-size: 95%;
opacity: 0.7;
line-height: $bodyInfoHieght;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
> :global(.chart) {
height: 30px;
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<div :class="$style.root">
<MkLoading v-if="fetching"/>
<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="users">
<MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user">
<MkUserCardMini :user="user"/>
</MkA>
</transition-group>
</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(230px, 1fr));
grid-gap: 12px;
> .user:hover {
text-decoration: none;
}
}
}
}
</style>

View file

@ -1,207 +1,66 @@
<template>
<MkSpacer :content-max="900">
<MkSpacer :content-max="1000">
<div ref="rootEl" v-size="{ max: [740] }" class="edbbcaef">
<div class="left">
<div v-if="stats" class="container stats">
<div class="title">Stats</div>
<div class="body">
<div class="number _panel">
<div class="label">Users</div>
<div class="value _monospace">
{{ number(stats.originalUsersCount) }}
<MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
</div>
</div>
<div class="number _panel">
<div class="label">Notes</div>
<div class="value _monospace">
{{ number(stats.originalNotesCount) }}
<MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
</div>
</div>
</div>
</div>
<div class="container queue">
<div class="title">Job queue</div>
<div class="body">
<div class="chart deliver">
<div class="title">Deliver</div>
<XQueueChart :connection="queueStatsConnection" domain="deliver"/>
</div>
<div class="chart inbox">
<div class="title">Inbox</div>
<XQueueChart :connection="queueStatsConnection" domain="inbox"/>
</div>
</div>
</div>
<div class="container users">
<div class="title">New users</div>
<div v-if="newUsers" class="body">
<XUser v-for="user in newUsers" :key="user.id" class="user" :user="user"/>
</div>
</div>
<div class="container files">
<div class="title">Recent files</div>
<div class="body">
<MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
</div>
</div>
<div class="container env">
<div class="title">Environment</div>
<div class="body">
<div class="number _panel">
<div class="label">Misskey</div>
<div class="value _monospace">{{ version }}</div>
</div>
<div v-if="serverInfo" class="number _panel">
<div class="label">Node.js</div>
<div class="value _monospace">{{ serverInfo.node }}</div>
</div>
<div v-if="serverInfo" class="number _panel">
<div class="label">PostgreSQL</div>
<div class="value _monospace">{{ serverInfo.psql }}</div>
</div>
<div v-if="serverInfo" class="number _panel">
<div class="label">Redis</div>
<div class="value _monospace">{{ serverInfo.redis }}</div>
</div>
<div class="number _panel">
<div class="label">Vue</div>
<div class="value _monospace">{{ vueVersion }}</div>
</div>
</div>
</div>
</div>
<div class="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">
<MkFolder class="item">
<template #header>Stats</template>
<XStats/>
</MkFolder>
<MkFolder class="item">
<template #header>Active users</template>
<XActiveUsers/>
</MkFolder>
<MkFolder class="item">
<template #header>Federation</template>
<XFederation/>
</div>
</div>
<div v-if="stats" class="container federationStats">
<div class="title">Federation</div>
<div class="body">
<div class="number _panel">
<div class="label">Sub</div>
<div class="value _monospace">
{{ number(federationSubActive) }}
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff>
</div>
</div>
<div class="number _panel">
<div class="label">Pub</div>
<div class="value _monospace">
{{ number(federationPubActive) }}
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff>
</div>
</div>
</div>
</div>
<div class="container tagCloud">
<div class="body">
<MkTagCloud v-if="activeInstances">
<li v-for="instance in activeInstances">
<a @click.prevent="onInstanceClick(instance)">
<img style="width: 32px;" :src="instance.iconUrl">
</a>
</li>
</MkTagCloud>
</div>
</div>
<div v-if="topSubInstancesForPie && topPubInstancesForPie" class="container federationPies">
<div class="body">
<div class="chart deliver">
<div class="title">Sub</div>
<XPie :data="topSubInstancesForPie"/>
<div class="subTitle">Top 10</div>
</div>
<div class="chart inbox">
<div class="title">Pub</div>
<XPie :data="topPubInstancesForPie"/>
<div class="subTitle">Top 10</div>
</div>
</div>
</div>
</div>
</MkFolder>
<MkFolder class="item">
<template #header>Instances</template>
<XInstances/>
</MkFolder>
<MkFolder class="item">
<template #header>Ap requests</template>
<XApRequests/>
</MkFolder>
<MkFolder class="item">
<template #header>New users</template>
<XUsers/>
</MkFolder>
<MkFolder class="item">
<template #header>Deliver queue</template>
<XQueue domain="deliver"/>
</MkFolder>
<MkFolder class="item">
<template #header>Inbox queue</template>
<XQueue domain="inbox"/>
</MkFolder>
</div>
</MkSpacer>
</template>
<script lang="ts" setup>
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
import {
Chart,
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
CategoryScale,
LinearScale,
TimeScale,
Legend,
Title,
Tooltip,
SubTitle,
Filler,
} from 'chart.js';
import { enUS } from 'date-fns/locale';
import tinycolor from 'tinycolor2';
import XFederation from './overview.federation.vue';
import XQueueChart from './overview.queue-chart.vue';
import XUser from './overview.user.vue';
import XPie from './overview.pie.vue';
import MkNumberDiff from '@/components/MkNumberDiff.vue';
import XInstances from './overview.instances.vue';
import XQueue from './overview.queue.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 MkTagCloud from '@/components/MkTagCloud.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';
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
Chart.register(
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
CategoryScale,
LinearScale,
TimeScale,
Legend,
Title,
Tooltip,
SubTitle,
Filler,
//gradient,
);
import MkFolder from '@/components/MkFolder.vue';
const rootEl = $ref<HTMLElement>();
const chartEl = $ref<HTMLCanvasElement>(null);
let stats: any = $ref(null);
let serverInfo: any = $ref(null);
let topSubInstancesForPie: any = $ref(null);
let topPubInstancesForPie: any = $ref(null);
let usersComparedToThePrevDay: any = $ref(null);
let notesComparedToThePrevDay: any = $ref(null);
let federationPubActive = $ref<number | null>(null);
let federationPubActiveDiff = $ref<number | null>(null);
let federationSubActive = $ref<number | null>(null);
@ -210,170 +69,12 @@ let newUsers = $ref(null);
let activeInstances = $shallowRef(null);
const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
const now = new Date();
let chartInstance: Chart = null;
const chartLimit = 30;
const filesPagination = {
endpoint: 'admin/drive/files' as const,
limit: 9,
noPaging: 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 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: 0,
top: 0,
bottom: 0,
},
},
scales: {
x: {
type: 'time',
display: false,
stacked: true,
offset: false,
time: {
stepSize: 1,
unit: 'month',
},
grid: {
display: false,
},
ticks: {
display: false,
},
adapters: {
date: {
locale: enUS,
},
},
min: getDate(chartLimit).getTime(),
},
y: {
display: false,
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?.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();
}
},
}],
});
}
function onInstanceClick(i) {
os.pageWindow(`/instance-info/${i.host}`);
}
@ -389,20 +90,6 @@ onMounted(async () => {
magicGrid.listen();
*/
renderChart();
os.api('stats', {}).then(statsResponse => {
stats = statsResponse;
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];
});
});
os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => {
federationPubActive = chart.pubActive[0];
federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
@ -471,165 +158,8 @@ definePageMetadata({
<style lang="scss" scoped>
.edbbcaef {
display: flex;
> .left, > .right {
box-sizing: border-box;
width: 50%;
> .container {
margin: 32px 0;
> .title {
font-weight: bold;
margin-bottom: 16px;
}
&.stats, &.federationStats {
> .body {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 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.7em;
}
}
}
}
}
&.env {
> .body {
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
> .number {
padding: 14px 20px;
> .label {
opacity: 0.7;
font-size: 0.8em;
}
> .value {
font-size: 1.1em;
}
}
}
}
&.charts {
> .body {
padding: 32px;
background: var(--panel);
border-radius: var(--radius);
}
}
&.users {
> .body {
background: var(--panel);
border-radius: var(--radius);
> .user {
padding: 16px 20px;
&:not(:last-child) {
border-bottom: solid 0.5px var(--divider);
}
}
}
}
&.federation {
> .body {
background: var(--panel);
border-radius: var(--radius);
overflow: clip;
}
}
&.queue {
> .body {
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
> .chart {
position: relative;
padding: 20px;
background: var(--panel);
border-radius: var(--radius);
> .title {
position: absolute;
top: 20px;
left: 20px;
font-size: 90%;
}
}
}
}
&.federationPies {
> .body {
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
> .chart {
position: relative;
padding: 20px;
background: var(--panel);
border-radius: var(--radius);
> .title {
position: absolute;
top: 20px;
left: 20px;
font-size: 90%;
}
> .subTitle {
position: absolute;
bottom: 20px;
right: 20px;
font-size: 85%;
}
}
}
}
&.tagCloud {
> .body {
background: var(--panel);
border-radius: var(--radius);
overflow: clip;
}
}
}
}
> .left {
padding-right: 16px;
}
> .right {
padding-left: 16px;
}
}
</style>

View file

@ -25,6 +25,7 @@ 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';
Chart.register(
ArcElement,
@ -105,6 +106,8 @@ const color =
'?' 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: {
@ -167,6 +170,7 @@ onMounted(() => {
},
},
},
plugins: [chartVLine(vLineColor)],
});
});

View file

@ -0,0 +1,21 @@
export const chartVLine = (vLineColor: string) => ({
id: 'vLine',
beforeDraw(chart, args, options) {
if (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();
}
},
});