feat: chart hover shows all data on empty area, single series on line; click to toggle solo mode; add timestamp to axis
CI/CD / build-and-push (push) Failing after 56s
Details
CI/CD / build-and-push (push) Failing after 56s
Details
This commit is contained in:
parent
aad5645c2a
commit
7f020a6514
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
import type { NetworkMetrics } from '@/types/metrics';
|
import type { NetworkMetrics } from '@/types/metrics';
|
||||||
import { formatTimestamp, formatBytes } from '@/lib/formatters';
|
import { formatTimestamp, formatBytes } from '@/lib/formatters';
|
||||||
|
|
||||||
|
|
@ -13,6 +14,9 @@ interface NetworkChartProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NetworkChart({ title = 'Network Traffic', data, height = 300 }: NetworkChartProps) {
|
export default function NetworkChart({ title = 'Network Traffic', data, height = 300 }: NetworkChartProps) {
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
const soloIndex = useRef<number | null>(null);
|
||||||
|
|
||||||
if (!data.receive.length && !data.transmit.length) {
|
if (!data.receive.length && !data.transmit.length) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 border rounded-lg p-4 text-center text-gray-400 text-sm" style={{ height }}>
|
<div className="bg-gray-50 border rounded-lg p-4 text-center text-gray-400 text-sm" style={{ height }}>
|
||||||
|
|
@ -26,6 +30,7 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
|
emphasis: { focus: 'series' as const },
|
||||||
areaStyle: { opacity: 0.3 },
|
areaStyle: { opacity: 0.3 },
|
||||||
data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value]),
|
data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value]),
|
||||||
}));
|
}));
|
||||||
|
|
@ -35,25 +40,44 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
|
emphasis: { focus: 'series' as const },
|
||||||
lineStyle: { type: 'dashed' as const },
|
lineStyle: { type: 'dashed' as const },
|
||||||
data: inst.dataPoints.map((d) => [d.timestamp * 1000, -d.value]),
|
data: inst.dataPoints.map((d) => [d.timestamp * 1000, -d.value]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const allSeries = [...rxSeries, ...txSeries];
|
||||||
|
const seriesNames = allSeries.map((s) => s.name);
|
||||||
|
|
||||||
const option = {
|
const option = {
|
||||||
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
formatter: (params: any[]) => {
|
axisPointer: { type: 'cross' },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
if (!Array.isArray(params)) params = [params];
|
||||||
|
if (params.length === 0) return '';
|
||||||
const time = formatTimestamp(params[0].data[0] / 1000);
|
const time = formatTimestamp(params[0].data[0] / 1000);
|
||||||
const lines = params.map(
|
const lines = params.map(
|
||||||
(p: any) => `${p.marker} ${p.seriesName}: ${formatBytes(Math.abs(p.data[1]))}/s`
|
(p: any) => `${p.marker} ${p.seriesName}: ${formatBytes(Math.abs(p.data[1]))}/s`
|
||||||
);
|
);
|
||||||
return `${time}<br/>${lines.join('<br/>')}`;
|
return `<b>${time}</b><br/>${lines.join('<br/>')}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { bottom: 0, type: 'scroll' },
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
type: 'scroll',
|
||||||
|
selectedMode: 'multiple',
|
||||||
|
},
|
||||||
grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true },
|
grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true },
|
||||||
xAxis: { type: 'time' },
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (val: number) => {
|
||||||
|
const d = new Date(val);
|
||||||
|
return `${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getDate().toString().padStart(2, '0')}\n${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
|
|
@ -64,8 +88,37 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
|
||||||
{ type: 'inside', start: 0, end: 100 },
|
{ type: 'inside', start: 0, end: 100 },
|
||||||
{ type: 'slider', start: 0, end: 100, height: 20, bottom: 30 },
|
{ type: 'slider', start: 0, end: 100, height: 20, bottom: 30 },
|
||||||
],
|
],
|
||||||
series: [...rxSeries, ...txSeries],
|
series: allSeries,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReactECharts option={option} style={{ height }} notMerge lazyUpdate />;
|
const onEvents = useCallback(() => ({
|
||||||
|
click: (params: any) => {
|
||||||
|
const chart = chartRef.current?.getEchartsInstance();
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
if (soloIndex.current === params.seriesIndex) {
|
||||||
|
const selected: Record<string, boolean> = {};
|
||||||
|
seriesNames.forEach((name) => { selected[name] = true; });
|
||||||
|
chart.setOption({ legend: { selected } });
|
||||||
|
soloIndex.current = null;
|
||||||
|
} else {
|
||||||
|
const selected: Record<string, boolean> = {};
|
||||||
|
seriesNames.forEach((name) => { selected[name] = false; });
|
||||||
|
selected[params.seriesName] = true;
|
||||||
|
chart.setOption({ legend: { selected } });
|
||||||
|
soloIndex.current = params.seriesIndex;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), [seriesNames]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
ref={chartRef}
|
||||||
|
option={option}
|
||||||
|
style={{ height }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
onEvents={onEvents()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
import type { MetricInstance } from '@/types/metrics';
|
import type { MetricInstance } from '@/types/metrics';
|
||||||
import { formatTimestamp } from '@/lib/formatters';
|
import { formatTimestamp } from '@/lib/formatters';
|
||||||
|
|
||||||
|
|
@ -19,11 +20,15 @@ export default function TimeSeriesChart({
|
||||||
unit = '%',
|
unit = '%',
|
||||||
height = 300,
|
height = 300,
|
||||||
}: TimeSeriesChartProps) {
|
}: TimeSeriesChartProps) {
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
const soloIndex = useRef<number | null>(null);
|
||||||
|
|
||||||
const series = instances.map((inst) => ({
|
const series = instances.map((inst) => ({
|
||||||
name: inst.instance,
|
name: inst.instance || 'total',
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
|
emphasis: { focus: 'series' as const },
|
||||||
data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value.toFixed(2)]),
|
data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value.toFixed(2)]),
|
||||||
markArea: {
|
markArea: {
|
||||||
silent: true,
|
silent: true,
|
||||||
|
|
@ -39,15 +44,24 @@ export default function TimeSeriesChart({
|
||||||
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
formatter: (params: any[]) => {
|
axisPointer: { type: 'cross' },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
if (!Array.isArray(params)) params = [params];
|
||||||
|
if (params.length === 0) return '';
|
||||||
const time = formatTimestamp(params[0].data[0] / 1000);
|
const time = formatTimestamp(params[0].data[0] / 1000);
|
||||||
const lines = params.map(
|
const lines = params
|
||||||
(p: any) => `${p.marker} ${p.seriesName}: ${parseFloat(p.data[1]).toFixed(1)}${unit}`
|
.sort((a: any, b: any) => parseFloat(b.data[1]) - parseFloat(a.data[1]))
|
||||||
);
|
.map(
|
||||||
return `${time}<br/>${lines.join('<br/>')}`;
|
(p: any) => `${p.marker} ${p.seriesName}: ${parseFloat(p.data[1]).toFixed(1)}${unit}`
|
||||||
|
);
|
||||||
|
return `<b>${time}</b><br/>${lines.join('<br/>')}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { bottom: 0, type: 'scroll' },
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
type: 'scroll',
|
||||||
|
selectedMode: 'multiple',
|
||||||
|
},
|
||||||
grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true },
|
grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
|
|
@ -69,5 +83,37 @@ export default function TimeSeriesChart({
|
||||||
series,
|
series,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReactECharts option={option} style={{ height }} notMerge lazyUpdate />;
|
const onEvents = useCallback(() => ({
|
||||||
|
click: (params: any) => {
|
||||||
|
const chart = chartRef.current?.getEchartsInstance();
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
const seriesNames = instances.map((inst) => inst.instance || 'total');
|
||||||
|
|
||||||
|
if (soloIndex.current === params.seriesIndex) {
|
||||||
|
const selected: Record<string, boolean> = {};
|
||||||
|
seriesNames.forEach((name) => { selected[name] = true; });
|
||||||
|
chart.dispatchAction({ type: 'legendSelect', batch: seriesNames.map(name => ({ name })) });
|
||||||
|
chart.setOption({ legend: { selected } });
|
||||||
|
soloIndex.current = null;
|
||||||
|
} else {
|
||||||
|
const selected: Record<string, boolean> = {};
|
||||||
|
seriesNames.forEach((name) => { selected[name] = false; });
|
||||||
|
selected[params.seriesName] = true;
|
||||||
|
chart.setOption({ legend: { selected } });
|
||||||
|
soloIndex.current = params.seriesIndex;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), [instances]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
ref={chartRef}
|
||||||
|
option={option}
|
||||||
|
style={{ height }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
onEvents={onEvents()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue