1import {
2 ArcElement, BarElement,
3 CategoryScale,
4 Chart as ChartJS,
5 Legend,
6 LinearScale,
7 LineElement,
8 PointElement,
9 RadialLinearScale,
10 Title,
11 Tooltip,
12 Filler
13} from "chart.js";
14import {useEffect, useState, useCallback, startTransition, useDeferredValue} from "react";
15import {jsonRequest} from "@/common/utils/RequestUtil";
16import DateRangePicker from "@/common/components/DateRangePicker";
17import ChartModal from "@/common/components/ChartModal";
18import SpeedChart from "@/pages/Statistics/charts/SpeedChart";
19import LatestTestChart from "@/pages/Statistics/charts/LatestTestChart";
20import PingChart from "@/pages/Statistics/charts/PingChart";
21import OverviewChart from "@/pages/Statistics/charts/OverviewChart";
22import AverageChart from "@/pages/Statistics/charts/AverageChart";
23import HourlyChart from "@/pages/Statistics/charts/HourlyChart.jsx";
24import ConsistencyChart from "@/pages/Statistics/charts/ConsistencyChart";
25import ExportButton from "@/common/components/ExportButton";
26import i18n, {t} from "i18next";
27import "./styles.sass";
28
29ChartJS.register(ArcElement, Tooltip, CategoryScale, LinearScale, PointElement, LineElement, Title, Legend, BarElement, RadialLinearScale, Filler);
30
31const crosshairPlugin = {
32 id: 'crosshair',
33 afterDraw: (chart) => {
34 if (chart.tooltip?._active?.length) {
35 const ctx = chart.ctx;
36 const activePoint = chart.tooltip._active[0];
37 const x = activePoint.element.x;
38 const topY = chart.scales.y.top;
39 const bottomY = chart.scales.y.bottom;
40
41 ctx.save();
42 ctx.beginPath();
43 ctx.setLineDash([5, 5]);
44 ctx.moveTo(x, topY);
45 ctx.lineTo(x, bottomY);
46 ctx.lineWidth = 1;
47 ctx.strokeStyle = 'hsla(215, 20%, 65%, 0.6)';
48 ctx.stroke();
49 ctx.restore();
50 }
51 }
52};
53
54if (!ChartJS.registry.plugins.get('crosshair')) ChartJS.register(crosshairPlugin);
55
56ChartJS.defaults.color = "hsl(215, 20%, 55%)";
57ChartJS.defaults.font.color = "hsl(215, 20%, 55%)";
58ChartJS.defaults.font.family = "Inter, sans-serif";
59ChartJS.defaults.font.weight = 500;
60ChartJS.defaults.font.size = 11;
61ChartJS.defaults.elements.line.tension = 0.35;
62ChartJS.defaults.elements.line.borderWidth = 2.5;
63ChartJS.defaults.elements.point.radius = 0;
64ChartJS.defaults.elements.point.hoverRadius = 5;
65ChartJS.defaults.elements.point.hoverBorderWidth = 2;
66ChartJS.defaults.elements.arc.borderWidth = 0;
67ChartJS.defaults.plugins.legend.labels.usePointStyle = true;
68ChartJS.defaults.plugins.legend.labels.pointStyle = 'circle';
69ChartJS.defaults.plugins.legend.labels.padding = 16;
70ChartJS.defaults.plugins.legend.labels.boxWidth = 8;
71ChartJS.defaults.plugins.legend.labels.boxHeight = 8;
72
73
74export const Statistics = () => {
75 const [statistics, setStatistics] = useState(null);
76 const [latestTest, setLatestTest] = useState(null);
77 const [loading, setLoading] = useState(true);
78 const [expandedChart, setExpandedChart] = useState(null);
79 const [mountPhase, setMountPhase] = useState(0);
80
81 const [dateRange, setDateRange] = useState(() => {
82 const to = new Date();
83 const from = new Date();
84 from.setDate(from.getDate() - 7);
85 return { from, to };
86 });
87
88 const deferredStatistics = useDeferredValue(statistics);
89 const isStale = deferredStatistics !== statistics;
90
91 useEffect(() => {
92 const timer = setTimeout(() => setMountPhase(1), 50);
93 return () => clearTimeout(timer);
94 }, []);
95
96 useEffect(() => {
97 if (mountPhase === 1) {
98 const timer = setTimeout(() => setMountPhase(2), 150);
99 return () => clearTimeout(timer);
100 }
101 }, [mountPhase]);
102
103 const formatDateParam = (date) => {
104 const year = date.getFullYear();
105 const month = String(date.getMonth() + 1).padStart(2, '0');
106 const day = String(date.getDate()).padStart(2, '0');
107 return `${year}-${month}-${day}`;
108 };
109
110 const updateStats = useCallback(() => {
111 const fromParam = formatDateParam(dateRange.from);
112 const toParam = formatDateParam(dateRange.to);
113 startTransition(() => setLoading(true));
114 Promise.all([
115 jsonRequest(`/speedtests/statistics/?from=${fromParam}&to=${toParam}`),
116 jsonRequest("/speedtests?limit=1")
117 ]).then(([stats, tests]) => {
118 startTransition(() => {
119 setStatistics(stats);
120 setLatestTest(tests.length > 0 ? tests[0] : null);
121 setLoading(false);
122 });
123 }).catch(error => {
124 console.error("Failed to load statistics:", error);
125 startTransition(() => setLoading(false));
126 });
127 }, [dateRange]);
128
129 const handleDateRangeChange = (from, to) => setDateRange({ from, to });
130
131 useEffect(() => {
132 if (mountPhase >= 2) updateStats();
133 }, [mountPhase, updateStats]);
134
135 useEffect(() => {
136 const callback = () => updateStats();
137 i18n.on("languageChanged", callback);
138 return () => i18n.off("languageChanged", callback);
139 }, []);
140
141 if (mountPhase === 0) return null;
142
143 if (loading && !deferredStatistics) {
144 return (
145 <div className="statistic-area statistic-loading">
146 <div className="statistics-header">
147 <div className="skeleton-picker skeleton-visible"></div>
148 </div>
149 <div className="skeleton-chart skeleton-visible"></div>
150 <div className="skeleton-chart skeleton-visible"></div>
151 <div className="skeleton-chart skeleton-visible"></div>
152 </div>
153 );
154 }
155
156 if (!deferredStatistics) return <></>;
157 if (!deferredStatistics.tests || deferredStatistics.tests.total === 0) return (
158 <div className="statistic-area">
159 <div className="statistics-header">
160 <DateRangePicker
161 from={dateRange.from}
162 to={dateRange.to}
163 onChange={handleDateRangeChange}
164 />
165 </div>
166 <div className="statistics-empty">
167 <p>{t("test.not_available")}</p>
168 </div>
169 </div>
170 );
171
172 const renderChart = (chartType) => {
173 switch (chartType) {
174 case 'overview':
175 return <OverviewChart tests={deferredStatistics.tests} time={deferredStatistics.time} dateRange={dateRange}/>;
176 case 'latest':
177 return <LatestTestChart test={latestTest} expanded/>;
178 case 'consistency':
179 return <ConsistencyChart consistency={deferredStatistics.consistency}/>;
180 case 'download':
181 return <SpeedChart labels={deferredStatistics.labels} data={deferredStatistics.data} dataKey="download" titleKey="latest.down" color="hsl(187, 94%, 43%)" failed={deferredStatistics.failed} errors={deferredStatistics.errors} />;
182 case 'upload':
183 return <SpeedChart labels={deferredStatistics.labels} data={deferredStatistics.data} dataKey="upload" titleKey="latest.up" color="hsl(258, 90%, 66%)" failed={deferredStatistics.failed} errors={deferredStatistics.errors} />;
184 case 'ping':
185 return <PingChart labels={deferredStatistics.labels} data={deferredStatistics.data} failed={deferredStatistics.failed} errors={deferredStatistics.errors}/>;
186 case 'hourly':
187 return <HourlyChart hourlyAverages={deferredStatistics.hourlyAverages}/>;
188 case 'avgDownload':
189 return <AverageChart title={t("statistics.values.down")} data={deferredStatistics.download}/>;
190 case 'avgUpload':
191 return <AverageChart title={t("statistics.values.up")} data={deferredStatistics.upload}/>;
192 default:
193 return null;
194 }
195 };
196
197 return (
198 <div className={`statistic-area${isStale ? ' statistic-stale' : ''}`}>
199 <div className="statistics-header">
200 <DateRangePicker
201 from={dateRange.from}
202 to={dateRange.to}
203 onChange={handleDateRangeChange}
204 />
205 <ExportButton dateRange={dateRange} />
206 </div>
207
208 <OverviewChart tests={deferredStatistics.tests} time={deferredStatistics.time} dateRange={dateRange} onClick={() => setExpandedChart('overview')}/>
209 <LatestTestChart test={latestTest} onClick={() => setExpandedChart('latest')}/>
210 <ConsistencyChart consistency={deferredStatistics.consistency} onClick={() => setExpandedChart('consistency')}/>
211
212 <SpeedChart labels={deferredStatistics.labels} data={deferredStatistics.data} dataKey="download" titleKey="latest.down" color="hsl(187, 94%, 43%)" failed={deferredStatistics.failed} errors={deferredStatistics.errors} onClick={() => setExpandedChart('download')} compact/>
213 <SpeedChart labels={deferredStatistics.labels} data={deferredStatistics.data} dataKey="upload" titleKey="latest.up" color="hsl(258, 90%, 66%)" failed={deferredStatistics.failed} errors={deferredStatistics.errors} onClick={() => setExpandedChart('upload')} compact/>
214 <PingChart labels={deferredStatistics.labels} data={deferredStatistics.data} failed={deferredStatistics.failed} errors={deferredStatistics.errors} onClick={() => setExpandedChart('ping')} compact/>
215
216 <HourlyChart hourlyAverages={deferredStatistics.hourlyAverages} onClick={() => setExpandedChart('hourly')}/>
217
218 <AverageChart title={t("statistics.values.down")} data={deferredStatistics.download} onClick={() => setExpandedChart('avgDownload')}/>
219 <AverageChart title={t("statistics.values.up")} data={deferredStatistics.upload} onClick={() => setExpandedChart('avgUpload')}/>
220
221 <ChartModal
222 isOpen={!!expandedChart}
223 onClose={() => setExpandedChart(null)}
224 isChart={['download', 'upload', 'ping', 'hourly'].includes(expandedChart)}
225 >
226 {expandedChart && renderChart(expandedChart)}
227 </ChartModal>
228 </div>
229 );
230}